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.
- package/dist/ide.js +35 -13
- package/dist/shell/assets/{index-BOgm5T3W.js → index-BJLaUTJE.js} +21 -21
- package/dist/shell/index.html +1 -1
- package/kits/basic-2d/CLAUDE.md +3 -3
- package/kits/basic-2d/behaviors/Collider.jsx +172 -26
- package/kits/basic-2d/behaviors/Layout.jsx +24 -0
- package/kits/basic-2d/editors/PlayOnly.jsx +11 -9
- package/kits/basic-2d/editors/SceneEditor.jsx +49 -16
- package/kits/basic-2d/editors/SelectionOverlay.jsx +21 -11
- package/kits/basic-2d/editors/behaviorRegistry.js +9 -3
- package/kits/basic-2d/engine/behaviorExtensions.js +28 -0
- package/kits/basic-2d/engine/collider.js +60 -6
- package/kits/basic-2d/engine/scene.js +28 -2
- package/kits/basic-2d/engine/systemRegistry.js +12 -0
- package/kits/basic-2d/engine/ui.jsx +2 -0
- package/kits/basic-2d/main.jsx +3 -2
- package/kits/physics-2d/.prettierrc +8 -0
- package/kits/physics-2d/CLAUDE.md +329 -0
- package/kits/physics-2d/behaviors/Camera.jsx +43 -0
- package/kits/physics-2d/behaviors/Collider.jsx +213 -0
- package/kits/physics-2d/behaviors/Goal.jsx +29 -0
- package/kits/physics-2d/behaviors/Layout.jsx +53 -0
- package/kits/physics-2d/behaviors/Sprite.jsx +352 -0
- package/kits/physics-2d/behaviors/tint.js +47 -0
- package/kits/physics-2d/blueprints/ball.scene +14 -0
- package/kits/physics-2d/blueprints/block.scene +12 -0
- package/kits/physics-2d/blueprints/cauldron.scene +18 -0
- package/kits/physics-2d/blueprints/crate.scene +14 -0
- package/kits/physics-2d/blueprints/goal.scene +12 -0
- package/kits/physics-2d/castle.json +13 -0
- package/kits/physics-2d/docs/pxart-format.md +377 -0
- package/kits/physics-2d/drawings/block.pxart +25 -0
- package/kits/physics-2d/drawings/cauldron.pxart +113 -0
- package/kits/physics-2d/editors/BlueprintLibrary.jsx +247 -0
- package/kits/physics-2d/editors/ErrorBoundary.jsx +59 -0
- package/kits/physics-2d/editors/PlayOnly.jsx +31 -0
- package/kits/physics-2d/editors/PxArtEditor.jsx +954 -0
- package/kits/physics-2d/editors/SceneEditor.jsx +1696 -0
- package/kits/physics-2d/editors/SelectionOverlay.jsx +909 -0
- package/kits/physics-2d/editors/SingleEditor.jsx +122 -0
- package/kits/physics-2d/editors/behaviorRegistry.js +30 -0
- package/kits/physics-2d/editors/editorHistory.js +157 -0
- package/kits/physics-2d/editors/inspectorSheet.js +13 -0
- package/kits/physics-2d/editors/pixelCanvas.js +11 -0
- package/kits/physics-2d/editors/pixelEditorChrome.jsx +74 -0
- package/kits/physics-2d/editors/pixelGeometry.js +140 -0
- package/kits/physics-2d/editors/pixelInspector.jsx +633 -0
- package/kits/physics-2d/editors/pxArtEditorModel.js +732 -0
- package/kits/physics-2d/editors/pxArtPlayback.js +92 -0
- package/kits/physics-2d/editors/pxArtTimeline.jsx +752 -0
- package/kits/physics-2d/editors/pxArtTimeline.module.css +506 -0
- package/kits/physics-2d/editors/pxArtTools.js +232 -0
- package/kits/physics-2d/editors/useArtboardFit.js +102 -0
- package/kits/physics-2d/engine/ScenePlayer.jsx +196 -0
- package/kits/physics-2d/engine/SceneUI.jsx +59 -0
- package/kits/physics-2d/engine/assets.js +15 -0
- package/kits/physics-2d/engine/autoInspector.jsx +70 -0
- package/kits/physics-2d/engine/behaviorExtensions.js +28 -0
- package/kits/physics-2d/engine/blueprint.js +521 -0
- package/kits/physics-2d/engine/collider.js +200 -0
- package/kits/physics-2d/engine/files.js +117 -0
- package/kits/physics-2d/engine/liveReload.js +88 -0
- package/kits/physics-2d/engine/pxart.js +1032 -0
- package/kits/physics-2d/engine/pxartSmooth.js +222 -0
- package/kits/physics-2d/engine/scene.js +685 -0
- package/kits/physics-2d/engine/spriteGeometry.js +32 -0
- package/kits/physics-2d/engine/systemRegistry.js +12 -0
- package/kits/physics-2d/engine/ui.jsx +688 -0
- package/kits/physics-2d/engine/ui.module.css +2287 -0
- package/kits/physics-2d/eslint.config.js +71 -0
- package/kits/physics-2d/index.html +24 -0
- package/kits/physics-2d/main.jsx +24 -0
- package/kits/physics-2d/package-lock.json +2706 -0
- package/kits/physics-2d/package.json +42 -0
- package/kits/physics-2d/physics/PhysicsSystem.js +290 -0
- package/kits/physics-2d/physics/behaviors/AnalogStick.jsx +101 -0
- package/kits/physics-2d/physics/behaviors/Draggable.jsx +79 -0
- package/kits/physics-2d/physics/behaviors/RigidBody.jsx +55 -0
- package/kits/physics-2d/physics/behaviors/Slingshot.jsx +118 -0
- package/kits/physics-2d/physics/controls.js +79 -0
- package/kits/physics-2d/physics/extensions/collider.js +15 -0
- package/kits/physics-2d/physics/index.js +26 -0
- package/kits/physics-2d/physics/matterBridge.js +126 -0
- package/kits/physics-2d/pnpm-lock.yaml +1761 -0
- package/kits/physics-2d/scenes/main.scene +12 -0
- package/kits/physics-2d/scenes/sandbox.scene +13 -0
- package/kits/physics-2d/scripts/draw.mjs +121 -0
- package/kits/physics-2d/systems/physics.js +8 -0
- package/kits/physics-2d/vite.config.js +1 -0
- 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
|
+
}
|