castle-web-cli 0.4.113 → 0.4.115
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/castleJson.d.ts +1 -0
- package/dist/editorConfig.d.ts +34 -0
- package/dist/editorConfig.js +101 -0
- package/dist/ide.js +245 -120
- package/dist/imports.d.ts +6 -0
- package/dist/imports.js +90 -7
- package/dist/init.js +15 -2
- package/dist/serve.js +16 -0
- package/dist/shell/assets/index-CUamb8rK.js +144 -0
- package/dist/shell/assets/index-Y6cJRRCX.css +1 -0
- package/dist/shell/index.html +2 -2
- package/dist/unsupportedMedia.d.ts +1 -0
- package/dist/unsupportedMedia.js +54 -0
- package/dist/vitePlugins.js +12 -19
- package/kits/basic-2d/castle.json +1 -1
- package/kits/basic-2d/editors/behaviorRegistry.js +5 -7
- package/kits/physics-2d/CLAUDE.md +42 -23
- package/kits/physics-2d/{physics/behaviors → behaviors}/AnalogStick.jsx +3 -3
- package/kits/physics-2d/behaviors/Collider.jsx +1 -1
- package/kits/physics-2d/{physics/behaviors → behaviors}/Draggable.jsx +3 -3
- package/kits/physics-2d/{physics/behaviors → behaviors}/Joints.jsx +4 -4
- package/kits/physics-2d/{physics/behaviors → behaviors}/RigidBody.jsx +2 -2
- package/kits/physics-2d/{physics/behaviors → behaviors}/Slingshot.jsx +3 -3
- package/kits/physics-2d/behaviors/Sound.jsx +152 -0
- package/kits/physics-2d/behaviors/Sprite.jsx +98 -33
- package/kits/physics-2d/behaviors/Tone.jsx +83 -0
- package/kits/physics-2d/behaviors/Video.jsx +111 -0
- package/kits/physics-2d/behaviors/tint.js +24 -9
- package/kits/physics-2d/castle.json +64 -2
- package/kits/physics-2d/editors/BlueprintLibrary.jsx +7 -3
- package/kits/physics-2d/editors/ImageViewer.jsx +206 -0
- package/kits/physics-2d/editors/MediaPlayer.jsx +57 -0
- package/kits/physics-2d/editors/SceneEditor.jsx +7 -3
- package/kits/physics-2d/editors/SingleEditor.jsx +8 -0
- package/kits/physics-2d/editors/behaviorRegistry.js +5 -7
- package/kits/physics-2d/editors/mediaFile.js +20 -0
- package/kits/physics-2d/editors/mediaViewer.module.css +128 -0
- package/kits/physics-2d/engine/ScenePlayer.jsx +1 -0
- package/kits/physics-2d/engine/art.js +105 -0
- package/kits/physics-2d/engine/assets.js +11 -3
- package/kits/physics-2d/engine/audioContext.js +34 -0
- package/kits/physics-2d/engine/collider.js +28 -17
- package/kits/physics-2d/engine/files.js +74 -0
- package/kits/physics-2d/engine/media.js +212 -0
- package/kits/physics-2d/{physics → engine/physics}/PhysicsSystem.js +1 -1
- package/kits/physics-2d/{physics → engine/physics}/jointArt.js +1 -2
- package/kits/physics-2d/{physics → engine/physics}/matterBridge.js +2 -9
- package/kits/physics-2d/engine/scene.js +12 -0
- package/kits/physics-2d/engine/tone.js +112 -0
- package/kits/physics-2d/systems/media.js +34 -0
- package/kits/physics-2d/systems/physics.js +12 -3
- package/package.json +1 -1
- package/dist/shell/assets/index-BjOuaDJM.js +0 -144
- package/dist/shell/assets/index-CdXEpv_P.css +0 -1
- package/kits/physics-2d/physics/index.js +0 -26
- /package/kits/physics-2d/{physics → engine/physics}/controls.js +0 -0
- /package/kits/physics-2d/{physics → engine/physics}/joints.js +0 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { resolveDeckFile } from 'castle-web-sdk';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { mediaElement, playMedia, playOneShot, setMediaPan, stopMedia } from '../engine/media';
|
|
4
|
+
import { mediaFilesOfKind } from '../engine/files';
|
|
5
|
+
import { Panel, SelectField } from '../engine/ui';
|
|
6
|
+
import { AutoFields, overrideProps } from '../engine/autoInspector';
|
|
7
|
+
|
|
8
|
+
// Plays a sound file (.mp3 / .wav / .m4a) from an actor.
|
|
9
|
+
//
|
|
10
|
+
// Two ways to use it. Set `playOnStart` and it plays when the scene starts --
|
|
11
|
+
// music, ambience, a jingle on a game-over screen. Or leave it off and trigger
|
|
12
|
+
// it from a behavior, through the handle this puts on the actor:
|
|
13
|
+
//
|
|
14
|
+
// scene.getActor('sfx').runtime.sound.play(); // from the top
|
|
15
|
+
// await actor.runtime.sound.play(); // ...and wait for it to end
|
|
16
|
+
// actor.runtime.sound.stop();
|
|
17
|
+
//
|
|
18
|
+
// `play()` on a `polyphonic` sound overlaps copies instead of cutting the last
|
|
19
|
+
// one off, which is what a sound effect fired twice in a row should do. Without
|
|
20
|
+
// it, a second play restarts the one element, which is what music wants.
|
|
21
|
+
//
|
|
22
|
+
// Sound only plays during play, never in the editor: `update` is what wires the
|
|
23
|
+
// element up, and the editor doesn't run it. Pressing Stop silences everything,
|
|
24
|
+
// as does `scene.sound.stopAll()` (see systems/media.js).
|
|
25
|
+
//
|
|
26
|
+
// Browsers refuse to play audio until the player has touched the page. A sound
|
|
27
|
+
// that starts before that isn't lost -- it's queued and starts on the first tap
|
|
28
|
+
// or key (engine/media.js), which for a game is usually the first input anyway.
|
|
29
|
+
export class Sound {
|
|
30
|
+
static behaviorName = 'Sound';
|
|
31
|
+
|
|
32
|
+
static defaultProps = {
|
|
33
|
+
file: '',
|
|
34
|
+
volume: 1,
|
|
35
|
+
playbackRate: 1,
|
|
36
|
+
pan: 0,
|
|
37
|
+
loop: false,
|
|
38
|
+
polyphonic: false,
|
|
39
|
+
playOnStart: true,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
constructor(props) {
|
|
43
|
+
this.props = props;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
update(actor) {
|
|
47
|
+
if (!actor.runtime || actor.runtime.collected) return;
|
|
48
|
+
const file = resolveDeckFile(this.props.file);
|
|
49
|
+
const element = mediaElement(actor.id, 'audio', file);
|
|
50
|
+
if (!element) {
|
|
51
|
+
actor.runtime.sound = null;
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
// Props are live: turning the volume down, the pitch up, or the loop on in
|
|
55
|
+
// the inspector takes effect on the sound already playing.
|
|
56
|
+
const settings = readSettings(this.props);
|
|
57
|
+
element.volume = settings.volume;
|
|
58
|
+
element.playbackRate = settings.playbackRate;
|
|
59
|
+
element.loop = settings.loop;
|
|
60
|
+
if (settings.pan !== 0) setMediaPan(element, settings.pan);
|
|
61
|
+
|
|
62
|
+
const handle = actor.runtime.sound;
|
|
63
|
+
if (!handle || handle.file !== file) {
|
|
64
|
+
actor.runtime.sound = makeHandle(file, element, () => readSettings(this.props));
|
|
65
|
+
if (this.props.playOnStart) void actor.runtime.sound.play();
|
|
66
|
+
} else {
|
|
67
|
+
handle.settings = settings;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
static Inspector({ component, setComponent, override }) {
|
|
72
|
+
return (
|
|
73
|
+
<Panel title="Sound" overridden={override?.anyOverridden()}>
|
|
74
|
+
<SelectField
|
|
75
|
+
label="File"
|
|
76
|
+
value={component.file}
|
|
77
|
+
onChange={(file) => setComponent({ file })}
|
|
78
|
+
options={mediaFilesOfKind('audio')}
|
|
79
|
+
{...overrideProps(override, 'file')}
|
|
80
|
+
/>
|
|
81
|
+
<AutoFields
|
|
82
|
+
defaultProps={Sound.defaultProps}
|
|
83
|
+
component={component}
|
|
84
|
+
setComponent={setComponent}
|
|
85
|
+
only={['volume', 'playbackRate', 'pan', 'loop', 'polyphonic', 'playOnStart']}
|
|
86
|
+
override={override}
|
|
87
|
+
/>
|
|
88
|
+
</Panel>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function clamp(value, min, max, fallback) {
|
|
94
|
+
return Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : fallback;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function readSettings(props) {
|
|
98
|
+
return {
|
|
99
|
+
volume: clamp(props.volume, 0, 1, 1),
|
|
100
|
+
// The same ceiling the classic editor used: past 10x a sound is a click.
|
|
101
|
+
playbackRate: clamp(props.playbackRate, 0.01, 10, 1),
|
|
102
|
+
pan: clamp(props.pan, -1, 1, 0),
|
|
103
|
+
loop: Boolean(props.loop),
|
|
104
|
+
polyphonic: Boolean(props.polyphonic),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// What a behavior gets at `actor.runtime.sound`. Plain methods over the element,
|
|
109
|
+
// so game code never has to know an <audio> is involved.
|
|
110
|
+
//
|
|
111
|
+
// `play()` returns a promise that resolves when the sound ends, so a behavior
|
|
112
|
+
// can `await` one before doing the next thing. A looping sound resolves right
|
|
113
|
+
// away rather than never -- awaiting something that by definition doesn't end
|
|
114
|
+
// would hang the caller forever, and that is never what was meant.
|
|
115
|
+
function makeHandle(file, element, currentSettings) {
|
|
116
|
+
const handle = {
|
|
117
|
+
file,
|
|
118
|
+
element,
|
|
119
|
+
settings: currentSettings(),
|
|
120
|
+
play() {
|
|
121
|
+
const s = handle.settings;
|
|
122
|
+
if (s.polyphonic) {
|
|
123
|
+
return playOneShot(file, { volume: s.volume, playbackRate: s.playbackRate, pan: s.pan });
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
element.currentTime = 0;
|
|
127
|
+
} catch {
|
|
128
|
+
// Not seekable yet (still loading) -- it plays from the start anyway.
|
|
129
|
+
}
|
|
130
|
+
playMedia(element);
|
|
131
|
+
if (s.loop) return Promise.resolve();
|
|
132
|
+
return new Promise((resolve) => {
|
|
133
|
+
const done = () => resolve();
|
|
134
|
+
element.addEventListener('ended', done, { once: true });
|
|
135
|
+
element.addEventListener('error', done, { once: true });
|
|
136
|
+
});
|
|
137
|
+
},
|
|
138
|
+
resume() {
|
|
139
|
+
playMedia(element);
|
|
140
|
+
},
|
|
141
|
+
pause() {
|
|
142
|
+
element.pause();
|
|
143
|
+
},
|
|
144
|
+
stop() {
|
|
145
|
+
stopMedia(element);
|
|
146
|
+
},
|
|
147
|
+
get playing() {
|
|
148
|
+
return !element.paused;
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
return handle;
|
|
152
|
+
}
|
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
import { resolveDeckFile } from 'castle-web-sdk';
|
|
2
2
|
import React from 'react';
|
|
3
|
-
import {
|
|
3
|
+
import { artFrameCount, isImageArt, renderArtFrame } from '../engine/art';
|
|
4
4
|
import { renderSmoothSpriteFrame } from '../engine/pxartSmooth';
|
|
5
5
|
import { spriteDestRect } from '../engine/spriteGeometry';
|
|
6
6
|
import { Panel, SelectField } from '../engine/ui';
|
|
7
7
|
import { AutoFields, overrideProps } from '../engine/autoInspector';
|
|
8
|
-
import { parseTint,
|
|
8
|
+
import { parseTint, tintCanvas } from './tint';
|
|
9
9
|
|
|
10
|
-
// Runtime behavior for the pixel-art
|
|
11
|
-
//
|
|
12
|
-
//
|
|
10
|
+
// Runtime behavior for a deck's 2D art: the pixel-art `.pxart` format, or an
|
|
11
|
+
// image file (png / jpg / gif / webp / svg) the deck uploaded. Both come out of
|
|
12
|
+
// the scene's sprite map the same way (see engine/art.js), so everything below
|
|
13
|
+
// -- draw modes, tint, tiling, the collider's auto-fit -- works on either.
|
|
14
|
+
//
|
|
15
|
+
// It looks up the art, composites the current animation frame into a cached
|
|
16
|
+
// offscreen canvas (tinted), and blits it into the actor's
|
|
13
17
|
// Layout box with smoothing disabled — unless the FILE itself opts into a
|
|
14
18
|
// `cornerRadius` (a property of the .pxart asset, not a Sprite prop) greater
|
|
15
19
|
// than 0, in which case the cached canvas holds a corner-rounded vector
|
|
@@ -66,15 +70,21 @@ export class Sprite {
|
|
|
66
70
|
if (!layout) return;
|
|
67
71
|
const sprite = this.resolveSprite(scene);
|
|
68
72
|
if (!sprite) return;
|
|
69
|
-
const total =
|
|
73
|
+
const total = artFrameCount(sprite);
|
|
70
74
|
const frameIndex = Math.min(Math.max(actor.runtime?.spriteAnim?.frameIndex ?? 0, 0), total - 1);
|
|
71
75
|
const canvas = getSpriteCanvas(sprite, frameIndex, this.props.tint);
|
|
76
|
+
if (!canvas) return;
|
|
77
|
+
// `canvas` is a <canvas> for pixel art and may be the <img> itself for an
|
|
78
|
+
// untinted image, so size comes from the art rather than the element.
|
|
79
|
+
const artSize = sprite.resolution;
|
|
72
80
|
const prevSmoothing = ctx.imageSmoothingEnabled;
|
|
73
81
|
// File-level `cornerRadius > 0` sprites are pre-rendered as supersampled,
|
|
74
82
|
// corner-rounded vector fills (see engine/pxartSmooth.js); blit those with
|
|
75
83
|
// smoothing on so downscaling to the Layout box stays antialiased. Plain
|
|
76
84
|
// pixel sprites (`cornerRadius === 0`) keep nearest-neighbor blitting.
|
|
77
|
-
|
|
85
|
+
// Image files are resampled too -- nearest neighbour is right for pixel art
|
|
86
|
+
// and wrong for a photograph.
|
|
87
|
+
const smoothing = sprite.cornerRadius > 0 || isImageArt(sprite);
|
|
78
88
|
|
|
79
89
|
// Snap the destination rect to whole device pixels so tiles that abut
|
|
80
90
|
// exactly in card units (e.g. adjacent 100-unit-wide tiles) land on the
|
|
@@ -94,7 +104,7 @@ export class Sprite {
|
|
|
94
104
|
// Everything downstream (snapping, tiling's clip box) works off `dest`: the
|
|
95
105
|
// Layout box itself for every mode except `fit`/`cover`, which resize to the
|
|
96
106
|
// art's own aspect ratio (see `spriteDestRect`).
|
|
97
|
-
const dest = spriteDestRect(this.props.mode, layout,
|
|
107
|
+
const dest = spriteDestRect(this.props.mode, layout, artSize);
|
|
98
108
|
if (transform.b === 0 && transform.c === 0) {
|
|
99
109
|
const x0 = dest.x * transform.a + transform.e;
|
|
100
110
|
const y0 = dest.y * transform.d + transform.f;
|
|
@@ -108,7 +118,7 @@ export class Sprite {
|
|
|
108
118
|
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
109
119
|
ctx.imageSmoothingEnabled = smoothing;
|
|
110
120
|
if (tiling) {
|
|
111
|
-
drawTilesSnapped(ctx, canvas, this.props.tileSize, dest, transform, x0, y0, rx0, ry0, rx1, ry1);
|
|
121
|
+
drawTilesSnapped(ctx, canvas, artSize, this.props.tileSize, dest, transform, x0, y0, rx0, ry0, rx1, ry1);
|
|
112
122
|
} else {
|
|
113
123
|
// Clip cover's overflow to the snapped Layout box (undone by restore).
|
|
114
124
|
if (covering) {
|
|
@@ -126,7 +136,7 @@ export class Sprite {
|
|
|
126
136
|
} else {
|
|
127
137
|
ctx.imageSmoothingEnabled = smoothing;
|
|
128
138
|
if (tiling) {
|
|
129
|
-
drawTilesUnsnapped(ctx, canvas, this.props.tileSize, dest);
|
|
139
|
+
drawTilesUnsnapped(ctx, canvas, artSize, this.props.tileSize, dest);
|
|
130
140
|
} else if (covering) {
|
|
131
141
|
// Rotated: clip cover's overflow to the Layout box in card units.
|
|
132
142
|
ctx.save();
|
|
@@ -142,8 +152,8 @@ export class Sprite {
|
|
|
142
152
|
}
|
|
143
153
|
}
|
|
144
154
|
|
|
145
|
-
static Inspector({ component, setComponent,
|
|
146
|
-
const spriteFiles = getSpriteFiles(
|
|
155
|
+
static Inspector({ component, setComponent, sprites, override }) {
|
|
156
|
+
const spriteFiles = getSpriteFiles(sprites);
|
|
147
157
|
return (
|
|
148
158
|
<Panel title="Sprite" overridden={override?.anyOverridden()}>
|
|
149
159
|
<SelectField
|
|
@@ -183,9 +193,9 @@ function validTileSize(value) {
|
|
|
183
193
|
// using the same per-edge-rounding discipline as the box snap in `draw`, so
|
|
184
194
|
// neighboring tiles within this actor land on identical device pixels with
|
|
185
195
|
// no hairline seams.
|
|
186
|
-
function drawTilesSnapped(ctx, canvas, tileSize, layout, transform, x0, y0, rx0, ry0, rx1, ry1) {
|
|
196
|
+
function drawTilesSnapped(ctx, canvas, artSize, tileSize, layout, transform, x0, y0, rx0, ry0, rx1, ry1) {
|
|
187
197
|
const cellHeight = validTileSize(tileSize);
|
|
188
|
-
const cellWidth = cellHeight * (
|
|
198
|
+
const cellWidth = cellHeight * (artSize.width / artSize.height);
|
|
189
199
|
const cellDeviceWidth = cellWidth * transform.a;
|
|
190
200
|
const cellDeviceHeight = cellHeight * transform.d;
|
|
191
201
|
const cols = Math.ceil(layout.width / cellWidth);
|
|
@@ -208,9 +218,9 @@ function drawTilesSnapped(ctx, canvas, tileSize, layout, transform, x0, y0, rx0,
|
|
|
208
218
|
|
|
209
219
|
// Rotated actors have no axis-aligned device edges to snap to, so tile
|
|
210
220
|
// unsnapped in card units, matching the unsnapped stretch fallback in `draw`.
|
|
211
|
-
function drawTilesUnsnapped(ctx, canvas, tileSize, layout) {
|
|
221
|
+
function drawTilesUnsnapped(ctx, canvas, artSize, tileSize, layout) {
|
|
212
222
|
const cellHeight = validTileSize(tileSize);
|
|
213
|
-
const cellWidth = cellHeight * (
|
|
223
|
+
const cellWidth = cellHeight * (artSize.width / artSize.height);
|
|
214
224
|
const cols = Math.ceil(layout.width / cellWidth);
|
|
215
225
|
const rows = Math.ceil(layout.height / cellHeight);
|
|
216
226
|
ctx.save();
|
|
@@ -232,7 +242,57 @@ function drawTilesUnsnapped(ctx, canvas, tileSize, layout) {
|
|
|
232
242
|
// new object naturally misses the WeakMap and rebuilds.
|
|
233
243
|
const spriteCanvasCache = new WeakMap();
|
|
234
244
|
|
|
245
|
+
// Ceiling on a cached sprite bitmap's long side. Nothing is ever rasterized
|
|
246
|
+
// larger than the card itself -- 500x700 card units, times the display's pixel
|
|
247
|
+
// ratio -- so this is roughly the card at 3x, and a copy bigger than it holds
|
|
248
|
+
// detail that no draw can show. It only ever bites uploaded photos: `.pxart`
|
|
249
|
+
// caps at 512 (RESOLUTION_MAX), and a 4000x3000 photo goes from a 46 MB cached
|
|
250
|
+
// canvas to 12 MB with nothing visibly different.
|
|
251
|
+
const MAX_CACHE_LONG_SIDE = 2048;
|
|
252
|
+
|
|
253
|
+
// The raster this frame is drawn from, at its own natural size: the decoded
|
|
254
|
+
// <img> for an image, a freshly rendered canvas for pixel art. Null while an
|
|
255
|
+
// image is still downloading.
|
|
256
|
+
function frameSource(sprite, frameIndex) {
|
|
257
|
+
if (isImageArt(sprite)) return sprite.ready ? sprite.image : null;
|
|
258
|
+
const canvas = document.createElement('canvas');
|
|
259
|
+
if (sprite.cornerRadius > 0) {
|
|
260
|
+
renderSmoothSpriteFrame(sprite, frameIndex, canvas, { cornerRadius: sprite.cornerRadius });
|
|
261
|
+
return canvas;
|
|
262
|
+
}
|
|
263
|
+
return renderArtFrame(sprite, frameIndex, canvas) ? canvas : null;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function sourceSize(source) {
|
|
267
|
+
return {
|
|
268
|
+
width: source.naturalWidth || source.width,
|
|
269
|
+
height: source.naturalHeight || source.height,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// `size` shrunk to fit MAX_CACHE_LONG_SIDE, keeping the aspect ratio. Returned
|
|
274
|
+
// unchanged when it already fits, which is the case for everything but a large
|
|
275
|
+
// uploaded image (so no pixel art is ever resampled by this).
|
|
276
|
+
function cappedSize({ width, height }) {
|
|
277
|
+
const longest = Math.max(width, height);
|
|
278
|
+
if (longest <= MAX_CACHE_LONG_SIDE) return { width, height };
|
|
279
|
+
const scale = MAX_CACHE_LONG_SIDE / longest;
|
|
280
|
+
return {
|
|
281
|
+
width: Math.max(1, Math.round(width * scale)),
|
|
282
|
+
height: Math.max(1, Math.round(height * scale)),
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// The drawable source for one frame: a cached offscreen canvas, or -- for an
|
|
287
|
+
// untinted image -- the decoded <img> itself, since copying it into a canvas
|
|
288
|
+
// would buy nothing and cost a full-resolution bitmap. Null while an image is
|
|
289
|
+
// still downloading; the caller skips the draw and picks it up next frame.
|
|
235
290
|
function getSpriteCanvas(sprite, frameIndex, tint) {
|
|
291
|
+
const tintRgba = parseTint(tint);
|
|
292
|
+
if (isImageArt(sprite)) {
|
|
293
|
+
if (!sprite.ready) return null;
|
|
294
|
+
if (!tintRgba) return sprite.image;
|
|
295
|
+
}
|
|
236
296
|
const key = `${frameIndex}|${tint ?? ''}`;
|
|
237
297
|
let byKey = spriteCanvasCache.get(sprite);
|
|
238
298
|
if (!byKey) {
|
|
@@ -242,21 +302,24 @@ function getSpriteCanvas(sprite, frameIndex, tint) {
|
|
|
242
302
|
const cached = byKey.get(key);
|
|
243
303
|
if (cached) return cached;
|
|
244
304
|
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
if (tintRgba) {
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
const image = octx.getImageData(0, 0, canvas.width, canvas.height);
|
|
256
|
-
tintImageData(image.data, tintRgba);
|
|
257
|
-
octx.putImageData(image, 0, 0);
|
|
258
|
-
}
|
|
305
|
+
const source = frameSource(sprite, frameIndex);
|
|
306
|
+
// Nothing to render yet -- don't cache the blank.
|
|
307
|
+
if (!source) return null;
|
|
308
|
+
const natural = sourceSize(source);
|
|
309
|
+
const size = cappedSize(natural);
|
|
310
|
+
// A copy is only worth making when it changes something: a tint to apply, or
|
|
311
|
+
// a bitmap too big to keep at full size. Otherwise the render IS the cache.
|
|
312
|
+
if (!tintRgba && size.width === natural.width) {
|
|
313
|
+
byKey.set(key, source);
|
|
314
|
+
return source;
|
|
259
315
|
}
|
|
316
|
+
const canvas = document.createElement('canvas');
|
|
317
|
+
canvas.width = size.width;
|
|
318
|
+
canvas.height = size.height;
|
|
319
|
+
const ctx = canvas.getContext('2d');
|
|
320
|
+
if (!ctx) return null;
|
|
321
|
+
ctx.drawImage(source, 0, 0, size.width, size.height);
|
|
322
|
+
if (tintRgba) tintCanvas(canvas, source, tintRgba);
|
|
260
323
|
byKey.set(key, canvas);
|
|
261
324
|
return canvas;
|
|
262
325
|
}
|
|
@@ -272,7 +335,7 @@ function resolveTag(sprite, tagProp) {
|
|
|
272
335
|
// The ordered list of frame indices to step through, honoring the tag range and
|
|
273
336
|
// direction. No tag => the whole timeline, forward.
|
|
274
337
|
function frameSequence(sprite, tag) {
|
|
275
|
-
const total = Math.max(
|
|
338
|
+
const total = Math.max(artFrameCount(sprite), 1);
|
|
276
339
|
let from = 0;
|
|
277
340
|
let to = total - 1;
|
|
278
341
|
let direction = 'forward';
|
|
@@ -352,6 +415,8 @@ function advanceAnim(state, sprite, sequence, tag, dt) {
|
|
|
352
415
|
state.frameIndex = sequence[state.pos];
|
|
353
416
|
}
|
|
354
417
|
|
|
355
|
-
|
|
356
|
-
|
|
418
|
+
// Everything the deck can point a Sprite at: `.pxart` drawings and image files
|
|
419
|
+
// alike, which is exactly what the resolved art map holds.
|
|
420
|
+
function getSpriteFiles(sprites) {
|
|
421
|
+
return Object.keys(sprites ?? {}).sort();
|
|
357
422
|
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { playTone, WAVEFORMS } from '../engine/tone';
|
|
3
|
+
import { Panel, SelectField } from '../engine/ui';
|
|
4
|
+
import { AutoFields, overrideProps } from '../engine/autoInspector';
|
|
5
|
+
|
|
6
|
+
// Plays a synthesized note -- no audio file involved. This is the cheapest sound
|
|
7
|
+
// a deck can make: a blip on a pickup, a thud on a miss, a rising scale as a
|
|
8
|
+
// counter climbs. Nothing is downloaded and nothing is stored.
|
|
9
|
+
//
|
|
10
|
+
// actor.runtime.tone.play(); // the note as configured
|
|
11
|
+
// actor.runtime.tone.play({ note: 72 }); // ...or override it per play
|
|
12
|
+
// await actor.runtime.tone.play(); // wait for the release to finish
|
|
13
|
+
//
|
|
14
|
+
// `note` is a MIDI number: 60 is middle C, 72 an octave up, +1 a semitone. The
|
|
15
|
+
// envelope is attack (silence up to full) then release (full down to silence),
|
|
16
|
+
// so the note's whole length is attack + release seconds.
|
|
17
|
+
export class Tone {
|
|
18
|
+
static behaviorName = 'Tone';
|
|
19
|
+
|
|
20
|
+
static defaultProps = {
|
|
21
|
+
note: 60,
|
|
22
|
+
waveform: 'square',
|
|
23
|
+
attack: 0,
|
|
24
|
+
release: 0.3,
|
|
25
|
+
volume: 0.5,
|
|
26
|
+
pan: 0,
|
|
27
|
+
playOnStart: false,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
constructor(props) {
|
|
31
|
+
this.props = props;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
update(actor) {
|
|
35
|
+
if (!actor.runtime || actor.runtime.collected) return;
|
|
36
|
+
const handle = actor.runtime.tone;
|
|
37
|
+
if (!handle) {
|
|
38
|
+
actor.runtime.tone = makeHandle(() => this.props);
|
|
39
|
+
if (this.props.playOnStart) void actor.runtime.tone.play();
|
|
40
|
+
} else {
|
|
41
|
+
handle.props = this.props;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
static Inspector({ component, setComponent, override }) {
|
|
46
|
+
return (
|
|
47
|
+
<Panel title="Tone" overridden={override?.anyOverridden()}>
|
|
48
|
+
<SelectField
|
|
49
|
+
label="Waveform"
|
|
50
|
+
value={component.waveform}
|
|
51
|
+
onChange={(waveform) => setComponent({ waveform })}
|
|
52
|
+
options={WAVEFORMS}
|
|
53
|
+
{...overrideProps(override, 'waveform')}
|
|
54
|
+
/>
|
|
55
|
+
<AutoFields
|
|
56
|
+
defaultProps={Tone.defaultProps}
|
|
57
|
+
component={component}
|
|
58
|
+
setComponent={setComponent}
|
|
59
|
+
only={['note', 'attack', 'release', 'volume', 'pan', 'playOnStart']}
|
|
60
|
+
override={override}
|
|
61
|
+
/>
|
|
62
|
+
</Panel>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// `actor.runtime.tone`. `play(overrides)` takes the same fields as the props, so
|
|
68
|
+
// one Tone actor can cover a whole scale without a component per note.
|
|
69
|
+
function makeHandle(currentProps) {
|
|
70
|
+
const handle = {
|
|
71
|
+
props: currentProps(),
|
|
72
|
+
voice: null,
|
|
73
|
+
play(overrides) {
|
|
74
|
+
handle.voice = playTone({ ...handle.props, ...overrides });
|
|
75
|
+
return handle.voice.finished;
|
|
76
|
+
},
|
|
77
|
+
stop() {
|
|
78
|
+
handle.voice?.stop();
|
|
79
|
+
handle.voice = null;
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
return handle;
|
|
83
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { resolveDeckFile } from 'castle-web-sdk';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { mediaElement, playMedia, stopMedia } from '../engine/media';
|
|
4
|
+
import { mediaFilesOfKind } from '../engine/files';
|
|
5
|
+
import { spriteDestRect } from '../engine/spriteGeometry';
|
|
6
|
+
import { Panel, SelectField } from '../engine/ui';
|
|
7
|
+
import { AutoFields, overrideProps } from '../engine/autoInspector';
|
|
8
|
+
|
|
9
|
+
// Plays a video file (.mp4 / .webm / .mov / .m4v) inside the actor's Layout box.
|
|
10
|
+
//
|
|
11
|
+
// The video is decoded by the browser and blitted into the scene canvas every
|
|
12
|
+
// frame, so it sits in the scene like any other actor: it moves with Layout,
|
|
13
|
+
// rotates, draws in `z` order, and can have a Collider. `mode` frames it in the
|
|
14
|
+
// box the same way a Sprite does (cover / fit / stretch).
|
|
15
|
+
//
|
|
16
|
+
// In the editor it holds its first frame -- `update` is what starts playback and
|
|
17
|
+
// only play mode runs it -- so you can position it against the real picture.
|
|
18
|
+
// Sound is muted by default: an autoplaying video with sound is blocked by
|
|
19
|
+
// browsers, and a muted one isn't.
|
|
20
|
+
export class Video {
|
|
21
|
+
static behaviorName = 'Video';
|
|
22
|
+
|
|
23
|
+
static defaultProps = {
|
|
24
|
+
file: '',
|
|
25
|
+
mode: 'cover',
|
|
26
|
+
playing: true,
|
|
27
|
+
loop: true,
|
|
28
|
+
muted: true,
|
|
29
|
+
volume: 1,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
constructor(props) {
|
|
33
|
+
this.props = props;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
element(actor) {
|
|
37
|
+
return mediaElement(actor.id, 'video', resolveDeckFile(this.props.file));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
update(actor) {
|
|
41
|
+
if (!actor.runtime || actor.runtime.collected) return;
|
|
42
|
+
const element = this.element(actor);
|
|
43
|
+
if (!element) return;
|
|
44
|
+
element.loop = Boolean(this.props.loop);
|
|
45
|
+
element.muted = Boolean(this.props.muted);
|
|
46
|
+
element.volume = Number.isFinite(this.props.volume)
|
|
47
|
+
? Math.min(1, Math.max(0, this.props.volume))
|
|
48
|
+
: 1;
|
|
49
|
+
if (this.props.playing) playMedia(element);
|
|
50
|
+
else if (!element.paused) element.pause();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
draw(actor, scene, ctx) {
|
|
54
|
+
if (actor.runtime?.collected) return;
|
|
55
|
+
const layout = actor.components.Layout;
|
|
56
|
+
if (!layout) return;
|
|
57
|
+
const element = this.element(actor);
|
|
58
|
+
// HAVE_CURRENT_DATA: there is a frame to draw. Below it the element has
|
|
59
|
+
// nothing decoded yet and drawImage would throw.
|
|
60
|
+
if (!element || element.readyState < 2) return;
|
|
61
|
+
const size = { width: element.videoWidth, height: element.videoHeight };
|
|
62
|
+
if (!size.width || !size.height) return;
|
|
63
|
+
const dest = spriteDestRect(this.props.mode, layout, size);
|
|
64
|
+
const covering = this.props.mode === 'cover';
|
|
65
|
+
if (covering) {
|
|
66
|
+
// `cover` fills the box by overflowing it; the overflow is cropped.
|
|
67
|
+
ctx.save();
|
|
68
|
+
ctx.beginPath();
|
|
69
|
+
ctx.rect(layout.x, layout.y, layout.width, layout.height);
|
|
70
|
+
ctx.clip();
|
|
71
|
+
}
|
|
72
|
+
ctx.drawImage(element, dest.x, dest.y, dest.width, dest.height);
|
|
73
|
+
if (covering) ctx.restore();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Used by the inspector's "stop" affordance and by game code that wants the
|
|
77
|
+
// element itself (`scene.getActor('clip').components.Video` is props; this is
|
|
78
|
+
// the live element).
|
|
79
|
+
static stop(actor) {
|
|
80
|
+
const element = mediaElement(actor.id, 'video', actor.components?.Video?.file);
|
|
81
|
+
if (element) stopMedia(element);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
static Inspector({ component, setComponent, override }) {
|
|
85
|
+
return (
|
|
86
|
+
<Panel title="Video" overridden={override?.anyOverridden()}>
|
|
87
|
+
<SelectField
|
|
88
|
+
label="File"
|
|
89
|
+
value={component.file}
|
|
90
|
+
onChange={(file) => setComponent({ file })}
|
|
91
|
+
options={mediaFilesOfKind('video')}
|
|
92
|
+
{...overrideProps(override, 'file')}
|
|
93
|
+
/>
|
|
94
|
+
<SelectField
|
|
95
|
+
label="Mode"
|
|
96
|
+
value={component.mode}
|
|
97
|
+
onChange={(mode) => setComponent({ mode })}
|
|
98
|
+
options={['stretch', 'fit', 'cover']}
|
|
99
|
+
{...overrideProps(override, 'mode')}
|
|
100
|
+
/>
|
|
101
|
+
<AutoFields
|
|
102
|
+
defaultProps={Video.defaultProps}
|
|
103
|
+
component={component}
|
|
104
|
+
setComponent={setComponent}
|
|
105
|
+
only={['playing', 'loop', 'muted', 'volume']}
|
|
106
|
+
override={override}
|
|
107
|
+
/>
|
|
108
|
+
</Panel>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -35,13 +35,28 @@ export function applyTint(color, tint) {
|
|
|
35
35
|
return '#' + out.map((channel) => channel.toString(16).padStart(2, '0')).join('');
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
// Multiply
|
|
39
|
-
//
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
38
|
+
// Multiply a tint into an already-drawn canvas, using the compositor rather
|
|
39
|
+
// than a pixel loop. `source` is the untinted art the canvas was drawn from
|
|
40
|
+
// (an <img> or a canvas), redrawn at the end to restore the alpha channel.
|
|
41
|
+
//
|
|
42
|
+
// Two steps, and the second is not optional: `multiply` blends alpha as well as
|
|
43
|
+
// color, so the fill lands on the transparent pixels too and the sprite comes
|
|
44
|
+
// out as a solid rectangle. `destination-in` with the original puts the shape
|
|
45
|
+
// back. The tint's own alpha rides in on that same pass.
|
|
46
|
+
//
|
|
47
|
+
// This replaced a getImageData / multiply-in-JS / putImageData round trip, which
|
|
48
|
+
// costs the same on a 16x16 pixel sprite and 3-5x more on an uploaded photo --
|
|
49
|
+
// the bitmap has to leave the GPU and come back. Output is byte-identical,
|
|
50
|
+
// checked at both opaque and translucent tints.
|
|
51
|
+
export function tintCanvas(canvas, source, tint) {
|
|
52
|
+
const ctx = canvas.getContext('2d');
|
|
53
|
+
if (!ctx || canvas.width === 0 || canvas.height === 0) return;
|
|
54
|
+
ctx.globalCompositeOperation = 'multiply';
|
|
55
|
+
ctx.fillStyle = `rgb(${tint[0]},${tint[1]},${tint[2]})`;
|
|
56
|
+
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
57
|
+
ctx.globalCompositeOperation = 'destination-in';
|
|
58
|
+
ctx.globalAlpha = tint[3] / 255;
|
|
59
|
+
ctx.drawImage(source, 0, 0, canvas.width, canvas.height);
|
|
60
|
+
ctx.globalAlpha = 1;
|
|
61
|
+
ctx.globalCompositeOperation = 'source-over';
|
|
47
62
|
}
|