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.
Files changed (57) hide show
  1. package/dist/castleJson.d.ts +1 -0
  2. package/dist/editorConfig.d.ts +34 -0
  3. package/dist/editorConfig.js +101 -0
  4. package/dist/ide.js +245 -120
  5. package/dist/imports.d.ts +6 -0
  6. package/dist/imports.js +90 -7
  7. package/dist/init.js +15 -2
  8. package/dist/serve.js +16 -0
  9. package/dist/shell/assets/index-CUamb8rK.js +144 -0
  10. package/dist/shell/assets/index-Y6cJRRCX.css +1 -0
  11. package/dist/shell/index.html +2 -2
  12. package/dist/unsupportedMedia.d.ts +1 -0
  13. package/dist/unsupportedMedia.js +54 -0
  14. package/dist/vitePlugins.js +12 -19
  15. package/kits/basic-2d/castle.json +1 -1
  16. package/kits/basic-2d/editors/behaviorRegistry.js +5 -7
  17. package/kits/physics-2d/CLAUDE.md +42 -23
  18. package/kits/physics-2d/{physics/behaviors → behaviors}/AnalogStick.jsx +3 -3
  19. package/kits/physics-2d/behaviors/Collider.jsx +1 -1
  20. package/kits/physics-2d/{physics/behaviors → behaviors}/Draggable.jsx +3 -3
  21. package/kits/physics-2d/{physics/behaviors → behaviors}/Joints.jsx +4 -4
  22. package/kits/physics-2d/{physics/behaviors → behaviors}/RigidBody.jsx +2 -2
  23. package/kits/physics-2d/{physics/behaviors → behaviors}/Slingshot.jsx +3 -3
  24. package/kits/physics-2d/behaviors/Sound.jsx +152 -0
  25. package/kits/physics-2d/behaviors/Sprite.jsx +98 -33
  26. package/kits/physics-2d/behaviors/Tone.jsx +83 -0
  27. package/kits/physics-2d/behaviors/Video.jsx +111 -0
  28. package/kits/physics-2d/behaviors/tint.js +24 -9
  29. package/kits/physics-2d/castle.json +64 -2
  30. package/kits/physics-2d/editors/BlueprintLibrary.jsx +7 -3
  31. package/kits/physics-2d/editors/ImageViewer.jsx +206 -0
  32. package/kits/physics-2d/editors/MediaPlayer.jsx +57 -0
  33. package/kits/physics-2d/editors/SceneEditor.jsx +7 -3
  34. package/kits/physics-2d/editors/SingleEditor.jsx +8 -0
  35. package/kits/physics-2d/editors/behaviorRegistry.js +5 -7
  36. package/kits/physics-2d/editors/mediaFile.js +20 -0
  37. package/kits/physics-2d/editors/mediaViewer.module.css +128 -0
  38. package/kits/physics-2d/engine/ScenePlayer.jsx +1 -0
  39. package/kits/physics-2d/engine/art.js +105 -0
  40. package/kits/physics-2d/engine/assets.js +11 -3
  41. package/kits/physics-2d/engine/audioContext.js +34 -0
  42. package/kits/physics-2d/engine/collider.js +28 -17
  43. package/kits/physics-2d/engine/files.js +74 -0
  44. package/kits/physics-2d/engine/media.js +212 -0
  45. package/kits/physics-2d/{physics → engine/physics}/PhysicsSystem.js +1 -1
  46. package/kits/physics-2d/{physics → engine/physics}/jointArt.js +1 -2
  47. package/kits/physics-2d/{physics → engine/physics}/matterBridge.js +2 -9
  48. package/kits/physics-2d/engine/scene.js +12 -0
  49. package/kits/physics-2d/engine/tone.js +112 -0
  50. package/kits/physics-2d/systems/media.js +34 -0
  51. package/kits/physics-2d/systems/physics.js +12 -3
  52. package/package.json +1 -1
  53. package/dist/shell/assets/index-BjOuaDJM.js +0 -144
  54. package/dist/shell/assets/index-CdXEpv_P.css +0 -1
  55. package/kits/physics-2d/physics/index.js +0 -26
  56. /package/kits/physics-2d/{physics → engine/physics}/controls.js +0 -0
  57. /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 { frameCount, renderSpriteFrame } from '../engine/pxart';
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, tintImageData } from './tint';
8
+ import { parseTint, tintCanvas } from './tint';
9
9
 
10
- // Runtime behavior for the pixel-art Sprite format (.pxart). It looks up the
11
- // parsed Sprite from the scene's sprite map, composites the current animation
12
- // frame into a cached offscreen canvas (tinted), and blits it into the actor's
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 = frameCount(sprite);
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
- const smoothing = sprite.cornerRadius > 0;
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, canvas);
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, files, override }) {
146
- const spriteFiles = getSpriteFiles(files);
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 * (canvas.width / canvas.height);
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 * (canvas.width / canvas.height);
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 canvas = document.createElement('canvas');
246
- if (sprite.cornerRadius > 0) {
247
- renderSmoothSpriteFrame(sprite, frameIndex, canvas, { cornerRadius: sprite.cornerRadius });
248
- } else {
249
- renderSpriteFrame(sprite, frameIndex, canvas);
250
- }
251
- const tintRgba = parseTint(tint);
252
- if (tintRgba) {
253
- const octx = canvas.getContext('2d');
254
- if (octx && canvas.width > 0 && canvas.height > 0) {
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(frameCount(sprite), 1);
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
- function getSpriteFiles(files) {
356
- return Object.keys(files).filter((path) => path.endsWith('.pxart'));
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 an ImageData buffer's RGBA channels in place by the tint (0-255).
39
- // Used to tint an already-rendered offscreen canvas (the Sprite path).
40
- export function tintImageData(data, tint) {
41
- for (let i = 0; i < data.length; i += 4) {
42
- data[i] = Math.round((data[i] * tint[0]) / 255);
43
- data[i + 1] = Math.round((data[i + 1] * tint[1]) / 255);
44
- data[i + 2] = Math.round((data[i + 2] * tint[2]) / 255);
45
- data[i + 3] = Math.round((data[i + 3] * tint[3]) / 255);
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
  }