castle-web-cli 0.4.114 → 0.4.116

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 (95) hide show
  1. package/dist/agent-prompts.js +2 -2
  2. package/dist/agent.d.ts +9 -9
  3. package/dist/agent.js +590 -589
  4. package/dist/castleJson.d.ts +7 -0
  5. package/dist/castleJson.js +10 -0
  6. package/dist/editorConfig.d.ts +34 -0
  7. package/dist/editorConfig.js +125 -0
  8. package/dist/ide.js +245 -120
  9. package/dist/imports.d.ts +10 -0
  10. package/dist/imports.js +149 -17
  11. package/dist/init.d.ts +3 -0
  12. package/dist/init.js +113 -88
  13. package/dist/install.d.ts +1 -1
  14. package/dist/install.js +26 -24
  15. package/dist/serve.js +16 -0
  16. package/dist/shell/assets/index-DiPlPGyg.js +144 -0
  17. package/dist/shell/assets/index-Y6cJRRCX.css +1 -0
  18. package/dist/shell/index.html +2 -2
  19. package/dist/unsupportedMedia.d.ts +1 -0
  20. package/dist/unsupportedMedia.js +54 -0
  21. package/dist/vitePlugins.js +13 -20
  22. package/kits/basic-2d/castle.json +1 -1
  23. package/kits/basic-2d/editors/behaviorRegistry.js +5 -7
  24. package/kits/physics-2d/CLAUDE.md +65 -34
  25. package/kits/physics-2d/{physics/behaviors → behaviors}/AnalogStick.jsx +3 -3
  26. package/kits/physics-2d/behaviors/Collider.jsx +1 -1
  27. package/kits/physics-2d/{physics/behaviors → behaviors}/Draggable.jsx +3 -3
  28. package/kits/physics-2d/{physics/behaviors → behaviors}/Joints.jsx +5 -5
  29. package/kits/physics-2d/{physics/behaviors → behaviors}/RigidBody.jsx +2 -2
  30. package/kits/physics-2d/{physics/behaviors → behaviors}/Slingshot.jsx +3 -3
  31. package/kits/physics-2d/behaviors/Sound.jsx +152 -0
  32. package/kits/physics-2d/behaviors/Sprite.jsx +112 -42
  33. package/kits/physics-2d/behaviors/Tone.jsx +83 -0
  34. package/kits/physics-2d/behaviors/Video.jsx +111 -0
  35. package/kits/physics-2d/behaviors/tint.js +24 -9
  36. package/kits/physics-2d/blueprints/ball.scene +1 -1
  37. package/kits/physics-2d/blueprints/block.scene +1 -1
  38. package/kits/physics-2d/blueprints/cauldron.scene +1 -1
  39. package/kits/physics-2d/blueprints/crate.scene +1 -1
  40. package/kits/physics-2d/castle.json +74 -4
  41. package/kits/physics-2d/docs/pxart-format.md +537 -51
  42. package/kits/physics-2d/editors/BlueprintLibrary.jsx +7 -3
  43. package/kits/physics-2d/editors/ImageViewer.jsx +206 -0
  44. package/kits/physics-2d/editors/MediaPlayer.jsx +57 -0
  45. package/kits/physics-2d/editors/PxArtEditor.jsx +1794 -65
  46. package/kits/physics-2d/editors/SceneEditor.jsx +7 -3
  47. package/kits/physics-2d/editors/SingleEditor.jsx +8 -0
  48. package/kits/physics-2d/editors/behaviorRegistry.js +5 -7
  49. package/kits/physics-2d/editors/brushFit.js +535 -0
  50. package/kits/physics-2d/editors/brushShapes.js +140 -0
  51. package/kits/physics-2d/editors/mediaFile.js +31 -0
  52. package/kits/physics-2d/editors/mediaViewer.module.css +128 -0
  53. package/kits/physics-2d/editors/pathOverlay.js +340 -0
  54. package/kits/physics-2d/editors/pathTools.js +1906 -0
  55. package/kits/physics-2d/editors/pixelCanvas.js +13 -0
  56. package/kits/physics-2d/editors/pixelEditorChrome.jsx +2 -2
  57. package/kits/physics-2d/editors/pixelGeometry.js +4 -2
  58. package/kits/physics-2d/editors/pixelInspector.jsx +410 -37
  59. package/kits/physics-2d/editors/pxArtEditorModel.js +172 -16
  60. package/kits/physics-2d/editors/pxArtTimeline.jsx +163 -43
  61. package/kits/physics-2d/editors/pxArtTimeline.module.css +31 -5
  62. package/kits/physics-2d/engine/ScenePlayer.jsx +1 -0
  63. package/kits/physics-2d/engine/art.js +105 -0
  64. package/kits/physics-2d/engine/assets.js +12 -4
  65. package/kits/physics-2d/engine/audioContext.js +34 -0
  66. package/kits/physics-2d/engine/blueprint.js +3 -3
  67. package/kits/physics-2d/engine/collider.js +28 -17
  68. package/kits/physics-2d/engine/files.js +77 -1
  69. package/kits/physics-2d/engine/liveReload.js +1 -1
  70. package/kits/physics-2d/engine/media.js +212 -0
  71. package/kits/physics-2d/{physics → engine/physics}/PhysicsSystem.js +1 -1
  72. package/kits/physics-2d/{physics → engine/physics}/jointArt.js +3 -4
  73. package/kits/physics-2d/{physics → engine/physics}/matterBridge.js +2 -9
  74. package/kits/physics-2d/engine/pxart.js +153 -35
  75. package/kits/physics-2d/engine/pxartPath.js +1356 -0
  76. package/kits/physics-2d/engine/pxartSmooth.js +276 -125
  77. package/kits/physics-2d/engine/scene.js +12 -0
  78. package/kits/physics-2d/engine/tone.js +112 -0
  79. package/kits/physics-2d/engine/ui.jsx +22 -1
  80. package/kits/physics-2d/engine/ui.module.css +36 -12
  81. package/kits/physics-2d/package-lock.json +1 -1
  82. package/kits/physics-2d/scripts/draw.mjs +7 -7
  83. package/kits/physics-2d/scripts/import-svg.mjs +1231 -0
  84. package/kits/physics-2d/scripts/svg-emission-guide.md +92 -0
  85. package/kits/physics-2d/systems/media.js +34 -0
  86. package/kits/physics-2d/systems/physics.js +12 -3
  87. package/package.json +1 -1
  88. package/dist/shell/assets/index-CqpY1xZR.js +0 -144
  89. package/dist/shell/assets/index-DCwVFL5u.css +0 -1
  90. package/kits/physics-2d/physics/index.js +0 -26
  91. /package/kits/physics-2d/drawings/{block.pxart → block.sprite} +0 -0
  92. /package/kits/physics-2d/drawings/{cauldron.pxart → cauldron.sprite} +0 -0
  93. /package/kits/physics-2d/drawings/{joint-rope.pxart → joint-rope.sprite} +0 -0
  94. /package/kits/physics-2d/{physics → engine/physics}/controls.js +0 -0
  95. /package/kits/physics-2d/{physics → engine/physics}/joints.js +0 -0
@@ -1,25 +1,30 @@
1
1
  import { resolveDeckFile } from 'castle-web-sdk';
2
2
  import React from 'react';
3
- import { frameCount, renderSpriteFrame } from '../engine/pxart';
4
- import { renderSmoothSpriteFrame } from '../engine/pxartSmooth';
3
+ import { artFrameCount, isImageArt, renderArtFrame } from '../engine/art';
4
+ import { isVectorRenderer } from '../engine/pxart';
5
+ import { renderSmoothCompositeFrame, renderSmoothSpriteFrame } from '../engine/pxartSmooth';
5
6
  import { spriteDestRect } from '../engine/spriteGeometry';
6
7
  import { Panel, SelectField } from '../engine/ui';
7
8
  import { AutoFields, overrideProps } from '../engine/autoInspector';
8
- import { parseTint, tintImageData } from './tint';
9
+ import { parseTint, tintCanvas } from './tint';
9
10
 
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
11
+ // Runtime behavior for a deck's 2D art: the pixel-art `.pxart` format, or an
12
+ // image file (png / jpg / gif / webp / svg) the deck uploaded. Both come out of
13
+ // the scene's sprite map the same way (see engine/art.js), so everything below
14
+ // -- draw modes, tint, tiling, the collider's auto-fit -- works on either.
15
+ //
16
+ // It looks up the art, composites the current animation frame into a cached
17
+ // offscreen canvas (tinted), and blits it into the actor's
13
18
  // Layout box with smoothing disabled — unless the FILE itself opts into a
14
- // `cornerRadius` (a property of the .pxart asset, not a Sprite prop) greater
15
- // than 0, in which case the cached canvas holds a corner-rounded vector
16
- // render instead and gets blitted with smoothing on.
19
+ // non-default finish (vector renderer, or `cornerRadius` > 0 on a .pxart
20
+ // asset — not a Sprite prop), in which case the cached canvas holds a
21
+ // supersampled render and gets blitted with smoothing on.
17
22
  //
18
23
  // Animation plays in `update` and is stored on `actor.runtime.spriteAnim` (the
19
24
  // behavior instance is recreated every frame, but the actor object persists in
20
25
  // the runtime). In edit-mode previews `update` never runs, so the actor holds
21
26
  // frame 0.
22
- const FALLBACK_FILE = 'drawings/cauldron.pxart';
27
+ const FALLBACK_FILE = 'drawings/cauldron.sprite';
23
28
 
24
29
  export class Sprite {
25
30
  static behaviorName = 'Sprite';
@@ -66,15 +71,21 @@ export class Sprite {
66
71
  if (!layout) return;
67
72
  const sprite = this.resolveSprite(scene);
68
73
  if (!sprite) return;
69
- const total = frameCount(sprite);
74
+ const total = artFrameCount(sprite);
70
75
  const frameIndex = Math.min(Math.max(actor.runtime?.spriteAnim?.frameIndex ?? 0, 0), total - 1);
71
76
  const canvas = getSpriteCanvas(sprite, frameIndex, this.props.tint);
77
+ if (!canvas) return;
78
+ // `canvas` is a <canvas> for pixel art and may be the <img> itself for an
79
+ // untinted image, so size comes from the art rather than the element.
80
+ const artSize = sprite.resolution;
72
81
  const prevSmoothing = ctx.imageSmoothingEnabled;
73
- // File-level `cornerRadius > 0` sprites are pre-rendered as supersampled,
74
- // corner-rounded vector fills (see engine/pxartSmooth.js); blit those with
75
- // smoothing on so downscaling to the Layout box stays antialiased. Plain
76
- // pixel sprites (`cornerRadius === 0`) keep nearest-neighbor blitting.
77
- const smoothing = sprite.cornerRadius > 0;
82
+ // Sprites with a non-default finish are pre-rendered supersampled (see
83
+ // engine/pxartSmooth.js); blit those with smoothing on so downscaling to
84
+ // the Layout box stays antialiased. Plain pixel sprites (grid renderer at
85
+ // radius 0) keep nearest-neighbor blitting. Image files are resampled too
86
+ // -- nearest neighbour is right for pixel art and wrong for a photograph.
87
+ const smoothing =
88
+ isVectorRenderer(sprite.renderer) || sprite.cornerRadius > 0 || isImageArt(sprite);
78
89
 
79
90
  // Snap the destination rect to whole device pixels so tiles that abut
80
91
  // exactly in card units (e.g. adjacent 100-unit-wide tiles) land on the
@@ -94,7 +105,7 @@ export class Sprite {
94
105
  // Everything downstream (snapping, tiling's clip box) works off `dest`: the
95
106
  // Layout box itself for every mode except `fit`/`cover`, which resize to the
96
107
  // art's own aspect ratio (see `spriteDestRect`).
97
- const dest = spriteDestRect(this.props.mode, layout, canvas);
108
+ const dest = spriteDestRect(this.props.mode, layout, artSize);
98
109
  if (transform.b === 0 && transform.c === 0) {
99
110
  const x0 = dest.x * transform.a + transform.e;
100
111
  const y0 = dest.y * transform.d + transform.f;
@@ -108,7 +119,7 @@ export class Sprite {
108
119
  ctx.setTransform(1, 0, 0, 1, 0, 0);
109
120
  ctx.imageSmoothingEnabled = smoothing;
110
121
  if (tiling) {
111
- drawTilesSnapped(ctx, canvas, this.props.tileSize, dest, transform, x0, y0, rx0, ry0, rx1, ry1);
122
+ drawTilesSnapped(ctx, canvas, artSize, this.props.tileSize, dest, transform, x0, y0, rx0, ry0, rx1, ry1);
112
123
  } else {
113
124
  // Clip cover's overflow to the snapped Layout box (undone by restore).
114
125
  if (covering) {
@@ -126,7 +137,7 @@ export class Sprite {
126
137
  } else {
127
138
  ctx.imageSmoothingEnabled = smoothing;
128
139
  if (tiling) {
129
- drawTilesUnsnapped(ctx, canvas, this.props.tileSize, dest);
140
+ drawTilesUnsnapped(ctx, canvas, artSize, this.props.tileSize, dest);
130
141
  } else if (covering) {
131
142
  // Rotated: clip cover's overflow to the Layout box in card units.
132
143
  ctx.save();
@@ -142,8 +153,8 @@ export class Sprite {
142
153
  }
143
154
  }
144
155
 
145
- static Inspector({ component, setComponent, files, override }) {
146
- const spriteFiles = getSpriteFiles(files);
156
+ static Inspector({ component, setComponent, sprites, override }) {
157
+ const spriteFiles = getSpriteFiles(sprites);
147
158
  return (
148
159
  <Panel title="Sprite" overridden={override?.anyOverridden()}>
149
160
  <SelectField
@@ -183,9 +194,9 @@ function validTileSize(value) {
183
194
  // using the same per-edge-rounding discipline as the box snap in `draw`, so
184
195
  // neighboring tiles within this actor land on identical device pixels with
185
196
  // no hairline seams.
186
- function drawTilesSnapped(ctx, canvas, tileSize, layout, transform, x0, y0, rx0, ry0, rx1, ry1) {
197
+ function drawTilesSnapped(ctx, canvas, artSize, tileSize, layout, transform, x0, y0, rx0, ry0, rx1, ry1) {
187
198
  const cellHeight = validTileSize(tileSize);
188
- const cellWidth = cellHeight * (canvas.width / canvas.height);
199
+ const cellWidth = cellHeight * (artSize.width / artSize.height);
189
200
  const cellDeviceWidth = cellWidth * transform.a;
190
201
  const cellDeviceHeight = cellHeight * transform.d;
191
202
  const cols = Math.ceil(layout.width / cellWidth);
@@ -208,9 +219,9 @@ function drawTilesSnapped(ctx, canvas, tileSize, layout, transform, x0, y0, rx0,
208
219
 
209
220
  // Rotated actors have no axis-aligned device edges to snap to, so tile
210
221
  // unsnapped in card units, matching the unsnapped stretch fallback in `draw`.
211
- function drawTilesUnsnapped(ctx, canvas, tileSize, layout) {
222
+ function drawTilesUnsnapped(ctx, canvas, artSize, tileSize, layout) {
212
223
  const cellHeight = validTileSize(tileSize);
213
- const cellWidth = cellHeight * (canvas.width / canvas.height);
224
+ const cellWidth = cellHeight * (artSize.width / artSize.height);
214
225
  const cols = Math.ceil(layout.width / cellWidth);
215
226
  const rows = Math.ceil(layout.height / cellHeight);
216
227
  ctx.save();
@@ -232,7 +243,61 @@ function drawTilesUnsnapped(ctx, canvas, tileSize, layout) {
232
243
  // new object naturally misses the WeakMap and rebuilds.
233
244
  const spriteCanvasCache = new WeakMap();
234
245
 
246
+ // Ceiling on a cached sprite bitmap's long side. Nothing is ever rasterized
247
+ // larger than the card itself -- 500x700 card units, times the display's pixel
248
+ // ratio -- so this is roughly the card at 3x, and a copy bigger than it holds
249
+ // detail that no draw can show. It only ever bites uploaded photos: `.pxart`
250
+ // caps at 512 (RESOLUTION_MAX), and a 4000x3000 photo goes from a 46 MB cached
251
+ // canvas to 12 MB with nothing visibly different.
252
+ const MAX_CACHE_LONG_SIDE = 2048;
253
+
254
+ // The raster this frame is drawn from, at its own natural size: the decoded
255
+ // <img> for an image, a freshly rendered canvas for pixel art. Null while an
256
+ // image is still downloading.
257
+ function frameSource(sprite, frameIndex) {
258
+ if (isImageArt(sprite)) return sprite.ready ? sprite.image : null;
259
+ const canvas = document.createElement('canvas');
260
+ if (isVectorRenderer(sprite.renderer)) {
261
+ renderSmoothCompositeFrame(sprite, frameIndex, canvas);
262
+ return canvas;
263
+ }
264
+ if (sprite.cornerRadius > 0) {
265
+ renderSmoothSpriteFrame(sprite, frameIndex, canvas, { cornerRadius: sprite.cornerRadius });
266
+ return canvas;
267
+ }
268
+ return renderArtFrame(sprite, frameIndex, canvas) ? canvas : null;
269
+ }
270
+
271
+ function sourceSize(source) {
272
+ return {
273
+ width: source.naturalWidth || source.width,
274
+ height: source.naturalHeight || source.height,
275
+ };
276
+ }
277
+
278
+ // `size` shrunk to fit MAX_CACHE_LONG_SIDE, keeping the aspect ratio. Returned
279
+ // unchanged when it already fits, which is the case for everything but a large
280
+ // uploaded image (so no pixel art is ever resampled by this).
281
+ function cappedSize({ width, height }) {
282
+ const longest = Math.max(width, height);
283
+ if (longest <= MAX_CACHE_LONG_SIDE) return { width, height };
284
+ const scale = MAX_CACHE_LONG_SIDE / longest;
285
+ return {
286
+ width: Math.max(1, Math.round(width * scale)),
287
+ height: Math.max(1, Math.round(height * scale)),
288
+ };
289
+ }
290
+
291
+ // The drawable source for one frame: a cached offscreen canvas, or -- for an
292
+ // untinted image -- the decoded <img> itself, since copying it into a canvas
293
+ // would buy nothing and cost a full-resolution bitmap. Null while an image is
294
+ // still downloading; the caller skips the draw and picks it up next frame.
235
295
  function getSpriteCanvas(sprite, frameIndex, tint) {
296
+ const tintRgba = parseTint(tint);
297
+ if (isImageArt(sprite)) {
298
+ if (!sprite.ready) return null;
299
+ if (!tintRgba) return sprite.image;
300
+ }
236
301
  const key = `${frameIndex}|${tint ?? ''}`;
237
302
  let byKey = spriteCanvasCache.get(sprite);
238
303
  if (!byKey) {
@@ -242,21 +307,24 @@ function getSpriteCanvas(sprite, frameIndex, tint) {
242
307
  const cached = byKey.get(key);
243
308
  if (cached) return cached;
244
309
 
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
- }
310
+ const source = frameSource(sprite, frameIndex);
311
+ // Nothing to render yet -- don't cache the blank.
312
+ if (!source) return null;
313
+ const natural = sourceSize(source);
314
+ const size = cappedSize(natural);
315
+ // A copy is only worth making when it changes something: a tint to apply, or
316
+ // a bitmap too big to keep at full size. Otherwise the render IS the cache.
317
+ if (!tintRgba && size.width === natural.width) {
318
+ byKey.set(key, source);
319
+ return source;
259
320
  }
321
+ const canvas = document.createElement('canvas');
322
+ canvas.width = size.width;
323
+ canvas.height = size.height;
324
+ const ctx = canvas.getContext('2d');
325
+ if (!ctx) return null;
326
+ ctx.drawImage(source, 0, 0, size.width, size.height);
327
+ if (tintRgba) tintCanvas(canvas, source, tintRgba);
260
328
  byKey.set(key, canvas);
261
329
  return canvas;
262
330
  }
@@ -272,7 +340,7 @@ function resolveTag(sprite, tagProp) {
272
340
  // The ordered list of frame indices to step through, honoring the tag range and
273
341
  // direction. No tag => the whole timeline, forward.
274
342
  function frameSequence(sprite, tag) {
275
- const total = Math.max(frameCount(sprite), 1);
343
+ const total = Math.max(artFrameCount(sprite), 1);
276
344
  let from = 0;
277
345
  let to = total - 1;
278
346
  let direction = 'forward';
@@ -352,6 +420,8 @@ function advanceAnim(state, sprite, sequence, tag, dt) {
352
420
  state.frameIndex = sequence[state.pos];
353
421
  }
354
422
 
355
- function getSpriteFiles(files) {
356
- return Object.keys(files).filter((path) => path.endsWith('.pxart'));
423
+ // Everything the deck can point a Sprite at: `.pxart` drawings and image files
424
+ // alike, which is exactly what the resolved art map holds.
425
+ function getSpriteFiles(sprites) {
426
+ return Object.keys(sprites ?? {}).sort();
357
427
  }
@@ -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
  }
@@ -4,7 +4,7 @@
4
4
  {
5
5
  "components": {
6
6
  "Layout": { "width": 44, "height": 44, "z": 2 },
7
- "Sprite": { "file": "drawings/cauldron.pxart", "mode": "fit" },
7
+ "Sprite": { "file": "drawings/cauldron.sprite", "mode": "fit" },
8
8
  "Collider": { "shape": "circle", "width": 40, "height": 40, "bounciness": 0.6, "friction": 0.05 },
9
9
  "RigidBody": { "bodyType": "dynamic", "gravityScale": 1, "drag": 0.005 },
10
10
  "Slingshot": { "speed": 0.1, "maxDrag": 200 }
@@ -4,7 +4,7 @@
4
4
  {
5
5
  "components": {
6
6
  "Layout": { "width": 100, "height": 40, "z": 1 },
7
- "Sprite": { "file": "drawings/block.pxart", "mode": "tile", "tileSize": 20 },
7
+ "Sprite": { "file": "drawings/block.sprite", "mode": "tile", "tileSize": 20 },
8
8
  "Collider": { "bounciness": 0.4, "friction": 0.2 }
9
9
  }
10
10
  }
@@ -9,7 +9,7 @@
9
9
  "z": 1
10
10
  },
11
11
  "Sprite": {
12
- "file": "drawings/cauldron.pxart"
12
+ "file": "drawings/cauldron.sprite"
13
13
  },
14
14
  "Collider": {}
15
15
  }
@@ -4,7 +4,7 @@
4
4
  {
5
5
  "components": {
6
6
  "Layout": { "width": 48, "height": 48, "z": 2 },
7
- "Sprite": { "file": "drawings/block.pxart", "mode": "fit" },
7
+ "Sprite": { "file": "drawings/block.sprite", "mode": "fit" },
8
8
  "Collider": { "shape": "box", "width": 46, "height": 46, "bounciness": 0.2, "friction": 0.3 },
9
9
  "RigidBody": { "bodyType": "dynamic", "gravityScale": 1, "drag": 0.02 },
10
10
  "Draggable": {}
@@ -22,7 +22,8 @@
22
22
  "drawings/**",
23
23
  "scenes/**",
24
24
  "blueprints/**",
25
- "behaviors/**"
25
+ "behaviors/**",
26
+ "assets/**"
26
27
  ],
27
28
  "fileTypes": [
28
29
  {
@@ -33,10 +34,17 @@
33
34
  "editor": "kit",
34
35
  "data": true
35
36
  },
37
+ {
38
+ "ext": ".sprite",
39
+ "label": "Sprite",
40
+ "new": "New sprite",
41
+ "icon": "layer-group",
42
+ "editor": "kit",
43
+ "data": true
44
+ },
36
45
  {
37
46
  "ext": ".pxart",
38
- "label": "Pixel Art",
39
- "new": "New drawing",
47
+ "label": "Sprite",
40
48
  "icon": "layer-group",
41
49
  "editor": "kit",
42
50
  "data": true
@@ -46,12 +54,74 @@
46
54
  "label": "Behavior",
47
55
  "new": "New behavior",
48
56
  "icon": "code"
57
+ },
58
+ {
59
+ "ext": ".png",
60
+ "label": "Image",
61
+ "icon": "image",
62
+ "editor": "kit"
63
+ },
64
+ {
65
+ "ext": ".jpg",
66
+ "label": "Image",
67
+ "icon": "image",
68
+ "editor": "kit"
69
+ },
70
+ {
71
+ "ext": ".jpeg",
72
+ "label": "Image",
73
+ "icon": "image",
74
+ "editor": "kit"
75
+ },
76
+ {
77
+ "ext": ".gif",
78
+ "label": "Image",
79
+ "icon": "image",
80
+ "editor": "kit"
81
+ },
82
+ {
83
+ "ext": ".webp",
84
+ "label": "Image",
85
+ "icon": "image",
86
+ "editor": "kit"
87
+ },
88
+ {
89
+ "ext": ".svg",
90
+ "label": "Image",
91
+ "icon": "image",
92
+ "editor": "kit"
93
+ },
94
+ {
95
+ "ext": ".mp3",
96
+ "label": "Audio",
97
+ "icon": "music",
98
+ "editor": "kit"
99
+ },
100
+ {
101
+ "ext": ".wav",
102
+ "label": "Audio",
103
+ "icon": "music",
104
+ "editor": "kit"
105
+ },
106
+ {
107
+ "ext": ".m4a",
108
+ "label": "Audio",
109
+ "icon": "music",
110
+ "editor": "kit"
111
+ },
112
+ {
113
+ "ext": ".mp4",
114
+ "label": "Video",
115
+ "icon": "film",
116
+ "editor": "kit"
49
117
  }
50
118
  ],
51
119
  "defaultPlayFile": "scenes/main.scene"
52
120
  },
53
121
  "deckId": "ckRZGFW4iPrx",
54
122
  "cardId": "_oBFbAW6DxsO",
123
+ "main": "main.jsx",
124
+ "autoUpdateWhenImported": true,
55
125
  "title": "physics-2d",
56
- "publishedVersion": "2026-08-04T21:36:03.351Z"
126
+ "publishedVersion": "2026-08-08T19:00:43.040Z"
57
127
  }