castle-web-cli 0.4.84 → 0.4.86

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 (90) hide show
  1. package/dist/ide.js +35 -13
  2. package/dist/shell/assets/{index-BOgm5T3W.js → index-BJLaUTJE.js} +21 -21
  3. package/dist/shell/index.html +1 -1
  4. package/kits/basic-2d/CLAUDE.md +3 -3
  5. package/kits/basic-2d/behaviors/Collider.jsx +172 -26
  6. package/kits/basic-2d/behaviors/Layout.jsx +24 -0
  7. package/kits/basic-2d/editors/PlayOnly.jsx +11 -9
  8. package/kits/basic-2d/editors/SceneEditor.jsx +49 -16
  9. package/kits/basic-2d/editors/SelectionOverlay.jsx +21 -11
  10. package/kits/basic-2d/editors/behaviorRegistry.js +9 -3
  11. package/kits/basic-2d/engine/behaviorExtensions.js +28 -0
  12. package/kits/basic-2d/engine/collider.js +60 -6
  13. package/kits/basic-2d/engine/scene.js +28 -2
  14. package/kits/basic-2d/engine/systemRegistry.js +12 -0
  15. package/kits/basic-2d/engine/ui.jsx +2 -0
  16. package/kits/basic-2d/main.jsx +3 -2
  17. package/kits/physics-2d/.prettierrc +8 -0
  18. package/kits/physics-2d/CLAUDE.md +329 -0
  19. package/kits/physics-2d/behaviors/Camera.jsx +43 -0
  20. package/kits/physics-2d/behaviors/Collider.jsx +213 -0
  21. package/kits/physics-2d/behaviors/Goal.jsx +29 -0
  22. package/kits/physics-2d/behaviors/Layout.jsx +53 -0
  23. package/kits/physics-2d/behaviors/Sprite.jsx +352 -0
  24. package/kits/physics-2d/behaviors/tint.js +47 -0
  25. package/kits/physics-2d/blueprints/ball.scene +14 -0
  26. package/kits/physics-2d/blueprints/block.scene +12 -0
  27. package/kits/physics-2d/blueprints/cauldron.scene +18 -0
  28. package/kits/physics-2d/blueprints/crate.scene +14 -0
  29. package/kits/physics-2d/blueprints/goal.scene +12 -0
  30. package/kits/physics-2d/castle.json +13 -0
  31. package/kits/physics-2d/docs/pxart-format.md +377 -0
  32. package/kits/physics-2d/drawings/block.pxart +25 -0
  33. package/kits/physics-2d/drawings/cauldron.pxart +113 -0
  34. package/kits/physics-2d/editors/BlueprintLibrary.jsx +247 -0
  35. package/kits/physics-2d/editors/ErrorBoundary.jsx +59 -0
  36. package/kits/physics-2d/editors/PlayOnly.jsx +31 -0
  37. package/kits/physics-2d/editors/PxArtEditor.jsx +954 -0
  38. package/kits/physics-2d/editors/SceneEditor.jsx +1696 -0
  39. package/kits/physics-2d/editors/SelectionOverlay.jsx +909 -0
  40. package/kits/physics-2d/editors/SingleEditor.jsx +122 -0
  41. package/kits/physics-2d/editors/behaviorRegistry.js +30 -0
  42. package/kits/physics-2d/editors/editorHistory.js +157 -0
  43. package/kits/physics-2d/editors/inspectorSheet.js +13 -0
  44. package/kits/physics-2d/editors/pixelCanvas.js +11 -0
  45. package/kits/physics-2d/editors/pixelEditorChrome.jsx +74 -0
  46. package/kits/physics-2d/editors/pixelGeometry.js +140 -0
  47. package/kits/physics-2d/editors/pixelInspector.jsx +633 -0
  48. package/kits/physics-2d/editors/pxArtEditorModel.js +732 -0
  49. package/kits/physics-2d/editors/pxArtPlayback.js +92 -0
  50. package/kits/physics-2d/editors/pxArtTimeline.jsx +752 -0
  51. package/kits/physics-2d/editors/pxArtTimeline.module.css +506 -0
  52. package/kits/physics-2d/editors/pxArtTools.js +232 -0
  53. package/kits/physics-2d/editors/useArtboardFit.js +102 -0
  54. package/kits/physics-2d/engine/ScenePlayer.jsx +196 -0
  55. package/kits/physics-2d/engine/SceneUI.jsx +59 -0
  56. package/kits/physics-2d/engine/assets.js +15 -0
  57. package/kits/physics-2d/engine/autoInspector.jsx +70 -0
  58. package/kits/physics-2d/engine/behaviorExtensions.js +28 -0
  59. package/kits/physics-2d/engine/blueprint.js +521 -0
  60. package/kits/physics-2d/engine/collider.js +200 -0
  61. package/kits/physics-2d/engine/files.js +117 -0
  62. package/kits/physics-2d/engine/liveReload.js +88 -0
  63. package/kits/physics-2d/engine/pxart.js +1032 -0
  64. package/kits/physics-2d/engine/pxartSmooth.js +222 -0
  65. package/kits/physics-2d/engine/scene.js +685 -0
  66. package/kits/physics-2d/engine/spriteGeometry.js +32 -0
  67. package/kits/physics-2d/engine/systemRegistry.js +12 -0
  68. package/kits/physics-2d/engine/ui.jsx +688 -0
  69. package/kits/physics-2d/engine/ui.module.css +2287 -0
  70. package/kits/physics-2d/eslint.config.js +71 -0
  71. package/kits/physics-2d/index.html +24 -0
  72. package/kits/physics-2d/main.jsx +24 -0
  73. package/kits/physics-2d/package-lock.json +2706 -0
  74. package/kits/physics-2d/package.json +42 -0
  75. package/kits/physics-2d/physics/PhysicsSystem.js +290 -0
  76. package/kits/physics-2d/physics/behaviors/AnalogStick.jsx +101 -0
  77. package/kits/physics-2d/physics/behaviors/Draggable.jsx +79 -0
  78. package/kits/physics-2d/physics/behaviors/RigidBody.jsx +55 -0
  79. package/kits/physics-2d/physics/behaviors/Slingshot.jsx +118 -0
  80. package/kits/physics-2d/physics/controls.js +79 -0
  81. package/kits/physics-2d/physics/extensions/collider.js +15 -0
  82. package/kits/physics-2d/physics/index.js +26 -0
  83. package/kits/physics-2d/physics/matterBridge.js +126 -0
  84. package/kits/physics-2d/pnpm-lock.yaml +1761 -0
  85. package/kits/physics-2d/scenes/main.scene +12 -0
  86. package/kits/physics-2d/scenes/sandbox.scene +13 -0
  87. package/kits/physics-2d/scripts/draw.mjs +121 -0
  88. package/kits/physics-2d/systems/physics.js +8 -0
  89. package/kits/physics-2d/vite.config.js +1 -0
  90. package/package.json +1 -1
@@ -0,0 +1,213 @@
1
+ import React, { useState } from 'react';
2
+ import { Icon, NumberField, Panel, SelectField } from '../engine/ui';
3
+ import { AutoFields, overrideProps } from '../engine/autoInspector';
4
+ import { computeAutoFit, getColliderRect, getColliderShape, intersects } from '../engine/collider';
5
+ import { extensionDefaultProps } from '../engine/behaviorExtensions';
6
+
7
+ // Collapsible "Dimensions" section. The header row's label lines up exactly with
8
+ // the field labels above/below (both start at the panel body's 16px left pad);
9
+ // the open/closed caret floats in the left gutter without shifting the label.
10
+ // `margin-bottom: 12px` matches a field row's `padding-bottom`, so the gap to
11
+ // the next entry is the same whether the section is open or closed.
12
+ const dimHeaderRowStyle = {
13
+ position: 'relative',
14
+ display: 'flex',
15
+ alignItems: 'center',
16
+ gap: 10,
17
+ minHeight: 28,
18
+ margin: '0 0 12px',
19
+ };
20
+ const dimToggleStyle = {
21
+ display: 'inline-flex',
22
+ alignItems: 'center',
23
+ background: 'none',
24
+ border: 'none',
25
+ padding: 0,
26
+ margin: 0,
27
+ color: 'var(--castle-inspector-text)',
28
+ fontFamily: 'inherit',
29
+ fontSize: 14,
30
+ cursor: 'pointer',
31
+ };
32
+ // The caret sits in the panel's left gutter (label column starts at x=16).
33
+ const dimCaretStyle = {
34
+ position: 'absolute',
35
+ left: -14,
36
+ top: '50%',
37
+ transform: 'translateY(-50%)',
38
+ display: 'inline-flex',
39
+ alignItems: 'center',
40
+ fontSize: 11,
41
+ opacity: 0.65,
42
+ };
43
+ const autoFitLinkStyle = {
44
+ background: 'none',
45
+ border: 'none',
46
+ padding: 0,
47
+ color: '#4aa3ff',
48
+ fontFamily: 'inherit',
49
+ fontSize: 13,
50
+ cursor: 'pointer',
51
+ };
52
+ const dimBodyStyle = { paddingLeft: 14 };
53
+
54
+ export class Collider {
55
+ static behaviorName = 'Collider';
56
+
57
+ static defaultProps = {
58
+ shape: 'box',
59
+ // width/height/radius are intentionally NOT defaulted: an unset size means
60
+ // "match the actor's Layout box" (see engine/collider.js manualRect and the
61
+ // effective values in the inspector). Defaulting them to a fixed number made
62
+ // every collider shrink to that number instead of tracking the actor.
63
+ radius: 0,
64
+ offsetX: 0,
65
+ offsetY: 0,
66
+ // `isTrigger` marks a sensor: it still reports overlaps (draws yellow, reads
67
+ // as a pass-through zone for pickup/goal logic) but does not block on its
68
+ // own -- collisions are data you act on. Kit modules can register MORE
69
+ // Collider fields from outside this file (the physics module adds
70
+ // bounciness/friction material); see engine/behaviorExtensions.js. That's
71
+ // why this file is byte-identical across kits -- the physics-only fields
72
+ // live in physics/extensions/, not here.
73
+ isTrigger: false,
74
+ debug: false,
75
+ ...extensionDefaultProps('Collider'),
76
+ };
77
+
78
+ constructor(props) {
79
+ this.props = props;
80
+ }
81
+
82
+ // Seed props when the collider is first added to an actor: snap it to the
83
+ // sprite's opaque-pixel bounds (what "Auto-fit to sprite" computes) so a new
84
+ // collider frames the art rather than the whole Layout box. Falls back to the
85
+ // unset (Layout-box) size when there's nothing to fit to -- no sprite, tile
86
+ // mode, or transparent art. The editor's addBehavior calls this (see
87
+ // editors/SceneEditor.jsx initialBehaviorProps).
88
+ static initialProps(actor, ctx) {
89
+ const fit = computeAutoFit(actor, ctx?.sprites);
90
+ return fit ? { ...Collider.defaultProps, ...fit } : { ...Collider.defaultProps };
91
+ }
92
+
93
+ draw(actor, scene, ctx, options) {
94
+ if (!options.showDebugColliders && !this.props.debug) return;
95
+ const geom = getColliderShape(actor, scene.sprites);
96
+ if (!geom) return;
97
+ // A sensor (isTrigger) reads as a pass-through zone; a solid as a wall.
98
+ const isSensor = Boolean(this.props.isTrigger) || this.props.kind === 'pickup';
99
+ ctx.save();
100
+ ctx.strokeStyle = isSensor ? '#ffe17a' : '#8db7ff';
101
+ ctx.lineWidth = 2;
102
+ if (geom.shape === 'circle') {
103
+ ctx.beginPath();
104
+ ctx.arc(geom.cx, geom.cy, geom.radius, 0, Math.PI * 2);
105
+ ctx.stroke();
106
+ } else {
107
+ ctx.strokeRect(geom.x + 1, geom.y + 1, geom.width - 2, geom.height - 2);
108
+ }
109
+ ctx.restore();
110
+ }
111
+
112
+ static Inspector({ actor, component, sprites, setComponent, override }) {
113
+ const [dimOpen, setDimOpen] = useState(true);
114
+ const layout = actor?.components?.Layout ?? {};
115
+ const shape = component.shape ?? Collider.defaultProps.shape;
116
+ // Unset width/height/radius track the actor's Layout box; surface that
117
+ // effective value so a field never reads blank while the collider is sized.
118
+ const width = component.width ?? layout.width ?? 50;
119
+ const height = component.height ?? layout.height ?? 50;
120
+ const effectiveRadius = component.radius > 0 ? component.radius : Math.min(width, height) / 2;
121
+
122
+ // "Auto-fit to sprite": the explicit dims/offset that match the sprite's
123
+ // opaque-pixel bounds. Only offered when the collider isn't already fitted.
124
+ const autoFit = computeAutoFit(actor, sprites);
125
+ let autoFitPatch = null;
126
+ if (autoFit) {
127
+ autoFitPatch =
128
+ shape === 'circle'
129
+ ? { radius: Math.round(Math.min(autoFit.width, autoFit.height) / 2), offsetX: autoFit.offsetX, offsetY: autoFit.offsetY }
130
+ : { width: autoFit.width, height: autoFit.height, offsetX: autoFit.offsetX, offsetY: autoFit.offsetY };
131
+ }
132
+ const currentValue = (key) => {
133
+ if (key === 'radius') return effectiveRadius;
134
+ if (key === 'width') return width;
135
+ if (key === 'height') return height;
136
+ return component[key] ?? 0;
137
+ };
138
+ const alreadyFitted =
139
+ autoFitPatch && Object.entries(autoFitPatch).every(([key, value]) => Math.abs(currentValue(key) - value) < 0.6);
140
+
141
+ return (
142
+ <Panel title="Collider" overridden={override?.anyOverridden()}>
143
+ <SelectField
144
+ label="Shape"
145
+ value={component.shape}
146
+ onChange={(value) => setComponent({ shape: value })}
147
+ options={['box', 'circle']}
148
+ {...overrideProps(override, 'shape')}
149
+ />
150
+ <div style={dimHeaderRowStyle}>
151
+ <span style={dimCaretStyle}>
152
+ <Icon name={dimOpen ? 'chevron-down' : 'chevron-right'} />
153
+ </span>
154
+ <button type="button" onClick={() => setDimOpen((open) => !open)} style={dimToggleStyle}>
155
+ Dimensions
156
+ </button>
157
+ {autoFitPatch && !alreadyFitted ? (
158
+ <button type="button" onClick={() => setComponent(autoFitPatch)} style={autoFitLinkStyle}>
159
+ Auto-fit to sprite
160
+ </button>
161
+ ) : null}
162
+ </div>
163
+ {dimOpen ? (
164
+ <div style={dimBodyStyle}>
165
+ {shape === 'circle' ? (
166
+ <NumberField
167
+ label="Radius"
168
+ value={effectiveRadius}
169
+ onChange={(value) => setComponent({ radius: value })}
170
+ {...overrideProps(override, 'radius')}
171
+ />
172
+ ) : (
173
+ <>
174
+ <NumberField
175
+ label="Width"
176
+ value={width}
177
+ onChange={(value) => setComponent({ width: value })}
178
+ {...overrideProps(override, 'width')}
179
+ />
180
+ <NumberField
181
+ label="Height"
182
+ value={height}
183
+ onChange={(value) => setComponent({ height: value })}
184
+ {...overrideProps(override, 'height')}
185
+ />
186
+ </>
187
+ )}
188
+ <AutoFields
189
+ defaultProps={Collider.defaultProps}
190
+ component={component}
191
+ setComponent={setComponent}
192
+ only={['offsetX', 'offsetY']}
193
+ override={override}
194
+ />
195
+ </div>
196
+ ) : null}
197
+ <AutoFields
198
+ defaultProps={Collider.defaultProps}
199
+ component={component}
200
+ setComponent={setComponent}
201
+ exclude={['shape', 'width', 'height', 'radius', 'offsetX', 'offsetY']}
202
+ override={override}
203
+ />
204
+ </Panel>
205
+ );
206
+ }
207
+ }
208
+
209
+ // Re-exported for callers that need the rect / overlap test outside a running
210
+ // scene (e.g. editors/SelectionOverlay.jsx, which passes the merged preview
211
+ // actors plus the sprites map). See engine/collider.js for the single
212
+ // implementation.
213
+ export { getColliderRect, getColliderShape, intersects };
@@ -0,0 +1,29 @@
1
+ // Goal: a sensor zone that counts when a physics body enters it, and flashes
2
+ // green briefly. Demonstrates the physics collision callbacks + a Collider with
3
+ // `isTrigger: true`. Put it on an actor with a Layout and a trigger Collider.
4
+ export class Goal {
5
+ static behaviorName = 'Goal';
6
+
7
+ static defaultProps = {};
8
+
9
+ constructor(props) {
10
+ this.props = props;
11
+ }
12
+
13
+ onCollisionEnter(actor, scene, other) {
14
+ if (!other.components.RigidBody) return; // only score moving bodies
15
+ actor.runtime.scored = (actor.runtime.scored ?? 0) + 1;
16
+ actor.runtime.flashUntil = scene.time + 0.6;
17
+ }
18
+
19
+ draw(actor, scene, ctx) {
20
+ const layout = actor.components.Layout;
21
+ const lit = (actor.runtime.flashUntil ?? 0) > scene.time;
22
+ ctx.save();
23
+ ctx.strokeStyle = lit ? '#5ac54f' : 'rgba(255, 235, 87, 0.9)';
24
+ ctx.lineWidth = 4;
25
+ ctx.setLineDash([6, 4]);
26
+ ctx.strokeRect(layout.x + 2, layout.y + 2, layout.width - 4, layout.height - 4);
27
+ ctx.restore();
28
+ }
29
+ }
@@ -0,0 +1,53 @@
1
+ export class Layout {
2
+ static behaviorName = 'Layout';
3
+
4
+ static defaultProps = {
5
+ x: 0,
6
+ y: 0,
7
+ z: 0,
8
+ rotation: 0,
9
+ width: 50,
10
+ height: 50,
11
+ // Show the Layout box (the invisible frame the sprite fills and the collider
12
+ // derives from) as a dashed outline, in editor and play. Per-actor toggle.
13
+ debug: false,
14
+ };
15
+
16
+ // Position/rotation/z never inherit from a blueprint: they're always written
17
+ // as instance values at placement time (z gets an explicit top-of-stack value
18
+ // on spawn), and a blueprint edit to its own template x/y/z/rotation only
19
+ // affects newly placed instances, never moves ones already placed. Because
20
+ // they're always instance-local, they're also never surfaced as blueprint
21
+ // "overrides" in the inspector. width/height default to inherited (absent).
22
+ //
23
+ // Size is width/height in card units -- the same space you compose in. A
24
+ // user-facing scaleX/scaleY (relative to the sprite's native pixels) was
25
+ // tried and reverted: it only pays off under pixel-perfect (integer scales),
26
+ // and in today's card-units model it just adds a conversion + couples Layout
27
+ // to the Sprite. Revisit scale fields when pixel-perfect mode lands. See
28
+ // ~/castle/cauldron-rendering-scale-model.md.
29
+ static propertyMeta = {
30
+ x: { inherit: false },
31
+ y: { inherit: false },
32
+ z: { inherit: false },
33
+ rotation: { inherit: false },
34
+ };
35
+
36
+ constructor(props) {
37
+ this.props = props;
38
+ }
39
+
40
+ // Outline the Layout box when `debug` is on. Drawn in the actor's own rotated
41
+ // frame (so it rotates with the actor), with a distinct dashed lavender style
42
+ // so it reads separately from the solid collider debug and the selection box.
43
+ draw(actor, scene, ctx) {
44
+ if (!this.props.debug) return;
45
+ const { x, y, width, height } = this.props;
46
+ ctx.save();
47
+ ctx.strokeStyle = 'rgba(190, 178, 255, 0.75)';
48
+ ctx.setLineDash([4, 3]);
49
+ ctx.lineWidth = 1.5;
50
+ ctx.strokeRect(x + 0.5, y + 0.5, width - 1, height - 1);
51
+ ctx.restore();
52
+ }
53
+ }
@@ -0,0 +1,352 @@
1
+ import React from 'react';
2
+ import { frameCount, renderSpriteFrame } from '../engine/pxart';
3
+ import { renderSmoothSpriteFrame } from '../engine/pxartSmooth';
4
+ import { spriteDestRect } from '../engine/spriteGeometry';
5
+ import { Panel, SelectField } from '../engine/ui';
6
+ import { AutoFields, overrideProps } from '../engine/autoInspector';
7
+ import { parseTint, tintImageData } from './tint';
8
+
9
+ // Runtime behavior for the pixel-art Sprite format (.pxart). It looks up the
10
+ // parsed Sprite from the scene's sprite map, composites the current animation
11
+ // frame into a cached offscreen canvas (tinted), and blits it into the actor's
12
+ // Layout box with smoothing disabled — unless the FILE itself opts into a
13
+ // `cornerRadius` (a property of the .pxart asset, not a Sprite prop) greater
14
+ // than 0, in which case the cached canvas holds a corner-rounded vector
15
+ // render instead and gets blitted with smoothing on.
16
+ //
17
+ // Animation plays in `update` and is stored on `actor.runtime.spriteAnim` (the
18
+ // behavior instance is recreated every frame, but the actor object persists in
19
+ // the runtime). In edit-mode previews `update` never runs, so the actor holds
20
+ // frame 0.
21
+ const FALLBACK_FILE = 'drawings/cauldron.pxart';
22
+
23
+ export class Sprite {
24
+ static behaviorName = 'Sprite';
25
+
26
+ static defaultProps = {
27
+ file: FALLBACK_FILE,
28
+ tint: '#ffffffff',
29
+ playing: true,
30
+ tag: '',
31
+ mode: 'cover',
32
+ tileSize: 50,
33
+ };
34
+
35
+ constructor(props) {
36
+ this.props = props;
37
+ }
38
+
39
+ resolveSprite(scene) {
40
+ return scene.sprites?.[this.props.file] ?? scene.sprites?.[FALLBACK_FILE] ?? null;
41
+ }
42
+
43
+ update(actor, scene, dt) {
44
+ if (!actor.runtime || actor.runtime.collected) return;
45
+ const sprite = this.resolveSprite(scene);
46
+ if (!sprite) return;
47
+ const tag = resolveTag(sprite, this.props.tag);
48
+ const sequence = frameSequence(sprite, tag);
49
+ const state = ensureAnimState(actor, sprite, this.props, sequence);
50
+ if (!this.props.playing) {
51
+ resetAnimState(state, sequence);
52
+ state.frameIndex = 0;
53
+ return;
54
+ }
55
+ advanceAnim(state, sprite, sequence, tag, dt);
56
+ }
57
+
58
+ draw(actor, scene, ctx) {
59
+ if (actor.runtime?.collected) return;
60
+ const layout = actor.components.Layout;
61
+ if (!layout) return;
62
+ const sprite = this.resolveSprite(scene);
63
+ if (!sprite) return;
64
+ const total = frameCount(sprite);
65
+ const frameIndex = Math.min(Math.max(actor.runtime?.spriteAnim?.frameIndex ?? 0, 0), total - 1);
66
+ const canvas = getSpriteCanvas(sprite, frameIndex, this.props.tint);
67
+ const prevSmoothing = ctx.imageSmoothingEnabled;
68
+ // File-level `cornerRadius > 0` sprites are pre-rendered as supersampled,
69
+ // corner-rounded vector fills (see engine/pxartSmooth.js); blit those with
70
+ // smoothing on so downscaling to the Layout box stays antialiased. Plain
71
+ // pixel sprites (`cornerRadius === 0`) keep nearest-neighbor blitting.
72
+ const smoothing = sprite.cornerRadius > 0;
73
+
74
+ // Snap the destination rect to whole device pixels so tiles that abut
75
+ // exactly in card units (e.g. adjacent 100-unit-wide tiles) land on the
76
+ // same device-pixel edge instead of each getting its own antialiased
77
+ // partial-coverage edge, which otherwise leaves faint hairline seams
78
+ // between them. Rounding each of the four device-space coordinates
79
+ // independently (rather than rounding position+size) is what makes two
80
+ // neighbors sharing a card-unit edge compute and round to the identical
81
+ // device pixel. Skipped when the ctx is rotated/skewed (nonzero b or c,
82
+ // set by the runtime for `Layout.rotation`), since a rotated rect has no
83
+ // axis-aligned device edges to snap to.
84
+ const transform = ctx.getTransform();
85
+ const tiling = this.props.mode === 'tile';
86
+ // `cover` scales the art to fill the box preserving aspect ratio, so `dest`
87
+ // overflows the Layout box and the overflow must be clipped to the box.
88
+ const covering = this.props.mode === 'cover';
89
+ // Everything downstream (snapping, tiling's clip box) works off `dest`: the
90
+ // Layout box itself for every mode except `fit`/`cover`, which resize to the
91
+ // art's own aspect ratio (see `spriteDestRect`).
92
+ const dest = spriteDestRect(this.props.mode, layout, canvas);
93
+ if (transform.b === 0 && transform.c === 0) {
94
+ const x0 = dest.x * transform.a + transform.e;
95
+ const y0 = dest.y * transform.d + transform.f;
96
+ const x1 = (dest.x + dest.width) * transform.a + transform.e;
97
+ const y1 = (dest.y + dest.height) * transform.d + transform.f;
98
+ const rx0 = Math.round(x0);
99
+ const ry0 = Math.round(y0);
100
+ const rx1 = Math.round(x1);
101
+ const ry1 = Math.round(y1);
102
+ ctx.save();
103
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
104
+ ctx.imageSmoothingEnabled = smoothing;
105
+ if (tiling) {
106
+ drawTilesSnapped(ctx, canvas, this.props.tileSize, dest, transform, x0, y0, rx0, ry0, rx1, ry1);
107
+ } else {
108
+ // Clip cover's overflow to the snapped Layout box (undone by restore).
109
+ if (covering) {
110
+ const lx0 = Math.round(layout.x * transform.a + transform.e);
111
+ const ly0 = Math.round(layout.y * transform.d + transform.f);
112
+ const lx1 = Math.round((layout.x + layout.width) * transform.a + transform.e);
113
+ const ly1 = Math.round((layout.y + layout.height) * transform.d + transform.f);
114
+ ctx.beginPath();
115
+ ctx.rect(lx0, ly0, lx1 - lx0, ly1 - ly0);
116
+ ctx.clip();
117
+ }
118
+ ctx.drawImage(canvas, rx0, ry0, rx1 - rx0, ry1 - ry0);
119
+ }
120
+ ctx.restore();
121
+ } else {
122
+ ctx.imageSmoothingEnabled = smoothing;
123
+ if (tiling) {
124
+ drawTilesUnsnapped(ctx, canvas, this.props.tileSize, dest);
125
+ } else if (covering) {
126
+ // Rotated: clip cover's overflow to the Layout box in card units.
127
+ ctx.save();
128
+ ctx.beginPath();
129
+ ctx.rect(layout.x, layout.y, layout.width, layout.height);
130
+ ctx.clip();
131
+ ctx.drawImage(canvas, dest.x, dest.y, dest.width, dest.height);
132
+ ctx.restore();
133
+ } else {
134
+ ctx.drawImage(canvas, dest.x, dest.y, dest.width, dest.height);
135
+ }
136
+ ctx.imageSmoothingEnabled = prevSmoothing;
137
+ }
138
+ }
139
+
140
+ static Inspector({ component, setComponent, files, override }) {
141
+ const spriteFiles = getSpriteFiles(files);
142
+ return (
143
+ <Panel title="Sprite" overridden={override?.anyOverridden()}>
144
+ <SelectField
145
+ label="File"
146
+ value={component.file}
147
+ onChange={(file) => setComponent({ file })}
148
+ options={spriteFiles}
149
+ {...overrideProps(override, 'file')}
150
+ />
151
+ <SelectField
152
+ label="Mode"
153
+ value={component.mode}
154
+ onChange={(mode) => setComponent({ mode })}
155
+ options={['stretch', 'fit', 'cover', 'tile']}
156
+ {...overrideProps(override, 'mode')}
157
+ />
158
+ <AutoFields
159
+ defaultProps={Sprite.defaultProps}
160
+ component={component}
161
+ setComponent={setComponent}
162
+ only={['playing', 'tint', 'tileSize']}
163
+ override={override}
164
+ />
165
+ </Panel>
166
+ );
167
+ }
168
+ }
169
+
170
+ // Guard against non-finite or non-positive tileSize props (e.g. cleared
171
+ // inspector fields), falling back to one 50-unit grid cell.
172
+ function validTileSize(value) {
173
+ return Number.isFinite(value) && value > 0 ? value : 50;
174
+ }
175
+
176
+ // Repeat the sprite in device space, clipped to the snapped box. Tile edges
177
+ // are rounded independently from the box's unrounded device origin (x0/y0)
178
+ // using the same per-edge-rounding discipline as the box snap in `draw`, so
179
+ // neighboring tiles within this actor land on identical device pixels with
180
+ // no hairline seams.
181
+ function drawTilesSnapped(ctx, canvas, tileSize, layout, transform, x0, y0, rx0, ry0, rx1, ry1) {
182
+ const cellHeight = validTileSize(tileSize);
183
+ const cellWidth = cellHeight * (canvas.width / canvas.height);
184
+ const cellDeviceWidth = cellWidth * transform.a;
185
+ const cellDeviceHeight = cellHeight * transform.d;
186
+ const cols = Math.ceil(layout.width / cellWidth);
187
+ const rows = Math.ceil(layout.height / cellHeight);
188
+ ctx.save();
189
+ ctx.beginPath();
190
+ ctx.rect(rx0, ry0, rx1 - rx0, ry1 - ry0);
191
+ ctx.clip();
192
+ for (let row = 0; row < rows; row++) {
193
+ const edgeY0 = Math.round(y0 + row * cellDeviceHeight);
194
+ const edgeY1 = Math.round(y0 + (row + 1) * cellDeviceHeight);
195
+ for (let col = 0; col < cols; col++) {
196
+ const edgeX0 = Math.round(x0 + col * cellDeviceWidth);
197
+ const edgeX1 = Math.round(x0 + (col + 1) * cellDeviceWidth);
198
+ ctx.drawImage(canvas, edgeX0, edgeY0, edgeX1 - edgeX0, edgeY1 - edgeY0);
199
+ }
200
+ }
201
+ ctx.restore();
202
+ }
203
+
204
+ // Rotated actors have no axis-aligned device edges to snap to, so tile
205
+ // unsnapped in card units, matching the unsnapped stretch fallback in `draw`.
206
+ function drawTilesUnsnapped(ctx, canvas, tileSize, layout) {
207
+ const cellHeight = validTileSize(tileSize);
208
+ const cellWidth = cellHeight * (canvas.width / canvas.height);
209
+ const cols = Math.ceil(layout.width / cellWidth);
210
+ const rows = Math.ceil(layout.height / cellHeight);
211
+ ctx.save();
212
+ ctx.beginPath();
213
+ ctx.rect(layout.x, layout.y, layout.width, layout.height);
214
+ ctx.clip();
215
+ for (let row = 0; row < rows; row++) {
216
+ const ty = layout.y + row * cellHeight;
217
+ for (let col = 0; col < cols; col++) {
218
+ const tx = layout.x + col * cellWidth;
219
+ ctx.drawImage(canvas, tx, ty, cellWidth, cellHeight);
220
+ }
221
+ }
222
+ ctx.restore();
223
+ }
224
+
225
+ // Cache of native-resolution offscreen canvases keyed by sprite, then by
226
+ // `${frameIndex}|${tint}`. A sprite edit produces a fresh Sprite object, so a
227
+ // new object naturally misses the WeakMap and rebuilds.
228
+ const spriteCanvasCache = new WeakMap();
229
+
230
+ function getSpriteCanvas(sprite, frameIndex, tint) {
231
+ const key = `${frameIndex}|${tint ?? ''}`;
232
+ let byKey = spriteCanvasCache.get(sprite);
233
+ if (!byKey) {
234
+ byKey = new Map();
235
+ spriteCanvasCache.set(sprite, byKey);
236
+ }
237
+ const cached = byKey.get(key);
238
+ if (cached) return cached;
239
+
240
+ const canvas = document.createElement('canvas');
241
+ if (sprite.cornerRadius > 0) {
242
+ renderSmoothSpriteFrame(sprite, frameIndex, canvas, { cornerRadius: sprite.cornerRadius });
243
+ } else {
244
+ renderSpriteFrame(sprite, frameIndex, canvas);
245
+ }
246
+ const tintRgba = parseTint(tint);
247
+ if (tintRgba) {
248
+ const octx = canvas.getContext('2d');
249
+ if (octx && canvas.width > 0 && canvas.height > 0) {
250
+ const image = octx.getImageData(0, 0, canvas.width, canvas.height);
251
+ tintImageData(image.data, tintRgba);
252
+ octx.putImageData(image, 0, 0);
253
+ }
254
+ }
255
+ byKey.set(key, canvas);
256
+ return canvas;
257
+ }
258
+
259
+ // Resolve the active tag: the `tag` prop when it names an existing tag,
260
+ // otherwise the sprite's defaultTag, otherwise null (whole-timeline loop).
261
+ function resolveTag(sprite, tagProp) {
262
+ const name = tagProp || sprite.defaultTag;
263
+ if (!name) return null;
264
+ return sprite.tags.find((candidate) => candidate.name === name) ?? null;
265
+ }
266
+
267
+ // The ordered list of frame indices to step through, honoring the tag range and
268
+ // direction. No tag => the whole timeline, forward.
269
+ function frameSequence(sprite, tag) {
270
+ const total = Math.max(frameCount(sprite), 1);
271
+ let from = 0;
272
+ let to = total - 1;
273
+ let direction = 'forward';
274
+ if (tag) {
275
+ from = clampIndex(tag.from, total);
276
+ to = clampIndex(tag.to, total);
277
+ if (from > to) [from, to] = [to, from];
278
+ direction = tag.direction;
279
+ }
280
+ if (from === to) return [from];
281
+ const forward = [];
282
+ for (let i = from; i <= to; i++) forward.push(i);
283
+ if (direction === 'reverse') return forward.slice().reverse();
284
+ if (direction === 'pingpong') {
285
+ const back = [];
286
+ for (let i = to - 1; i > from; i--) back.push(i);
287
+ return [...forward, ...back];
288
+ }
289
+ return forward;
290
+ }
291
+
292
+ function clampIndex(value, total) {
293
+ if (!Number.isInteger(value)) return 0;
294
+ return Math.min(Math.max(value, 0), total - 1);
295
+ }
296
+
297
+ function animKey(props) {
298
+ return `${props.file}|${props.tag ?? ''}`;
299
+ }
300
+
301
+ // Reset playback state when the sprite identity or active tag changes.
302
+ function ensureAnimState(actor, sprite, props, sequence) {
303
+ const key = animKey(props);
304
+ let state = actor.runtime.spriteAnim;
305
+ if (!state || state.sprite !== sprite || state.key !== key) {
306
+ state = { sprite, key, pos: 0, elapsedMs: 0, loops: 0, done: false, frameIndex: sequence[0] };
307
+ actor.runtime.spriteAnim = state;
308
+ }
309
+ return state;
310
+ }
311
+
312
+ function resetAnimState(state, sequence) {
313
+ state.pos = 0;
314
+ state.elapsedMs = 0;
315
+ state.loops = 0;
316
+ state.done = false;
317
+ state.frameIndex = sequence[0];
318
+ }
319
+
320
+ // Advance the playhead by `dt` seconds, stepping frames by their durationMs and
321
+ // honoring `repeat` (0 = loop forever; otherwise hold the last frame when done).
322
+ function advanceAnim(state, sprite, sequence, tag, dt) {
323
+ if (state.done) {
324
+ state.frameIndex = sequence[state.pos];
325
+ return;
326
+ }
327
+ const repeat = tag ? tag.repeat : 0;
328
+ state.elapsedMs += dt * 1000;
329
+ let guard = 0;
330
+ while (guard++ < 1000) {
331
+ const frame = sequence[state.pos];
332
+ const duration = sprite.frames[frame]?.durationMs ?? sprite.defaultDurationMs;
333
+ if (!(duration > 0) || state.elapsedMs < duration) break;
334
+ state.elapsedMs -= duration;
335
+ state.pos += 1;
336
+ if (state.pos >= sequence.length) {
337
+ state.loops += 1;
338
+ if (repeat !== 0 && state.loops >= repeat) {
339
+ state.pos = sequence.length - 1;
340
+ state.done = true;
341
+ state.elapsedMs = 0;
342
+ break;
343
+ }
344
+ state.pos = 0;
345
+ }
346
+ }
347
+ state.frameIndex = sequence[state.pos];
348
+ }
349
+
350
+ function getSpriteFiles(files) {
351
+ return Object.keys(files).filter((path) => path.endsWith('.pxart'));
352
+ }