castle-web-cli 0.4.84 → 0.4.85
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/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 +199 -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 +1681 -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/blueprint.js +521 -0
- package/kits/physics-2d/engine/collider.js +196 -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 +686 -0
- package/kits/physics-2d/engine/spriteGeometry.js +32 -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/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/vite.config.js +1 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Tint helpers for the pixel-art Sprite behavior. A tint is a hex color
|
|
2
|
+
// (#rrggbb / #rrggbbaa) multiplied channel-wise into each source pixel. White
|
|
3
|
+
// (#ffffffff) means "no change".
|
|
4
|
+
|
|
5
|
+
// Parse a hex color into [r, g, b, a] channels (0-255). Returns null when the
|
|
6
|
+
// string is not a hex color so callers can fall back to the raw color.
|
|
7
|
+
export function parseHexColor(color) {
|
|
8
|
+
const hex = String(color).trim().replace(/^#/, '');
|
|
9
|
+
if (hex.length !== 6 && hex.length !== 8) return null;
|
|
10
|
+
if (!/^[0-9a-fA-F]+$/.test(hex)) return null;
|
|
11
|
+
const r = parseInt(hex.slice(0, 2), 16);
|
|
12
|
+
const g = parseInt(hex.slice(2, 4), 16);
|
|
13
|
+
const b = parseInt(hex.slice(4, 6), 16);
|
|
14
|
+
const a = hex.length === 8 ? parseInt(hex.slice(6, 8), 16) : 255;
|
|
15
|
+
return [r, g, b, a];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Returns the tint as [r, g, b, a] channels, or null when there is no tint or
|
|
19
|
+
// the tint is white (#ffffffff) -- both meaning "no change".
|
|
20
|
+
export function parseTint(tint) {
|
|
21
|
+
if (!tint) return null;
|
|
22
|
+
const rgba = parseHexColor(tint);
|
|
23
|
+
if (!rgba) return null;
|
|
24
|
+
if (rgba[0] === 255 && rgba[1] === 255 && rgba[2] === 255 && rgba[3] === 255) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
return rgba;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Multiply each channel of a pixel color (hex string) by the tint (0-255).
|
|
31
|
+
export function applyTint(color, tint) {
|
|
32
|
+
const rgba = parseHexColor(color);
|
|
33
|
+
if (!rgba) return color;
|
|
34
|
+
const out = rgba.map((channel, i) => Math.round((channel * tint[i]) / 255));
|
|
35
|
+
return '#' + out.map((channel) => channel.toString(16).padStart(2, '0')).join('');
|
|
36
|
+
}
|
|
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
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "Ball",
|
|
3
|
+
"actors": [
|
|
4
|
+
{
|
|
5
|
+
"components": {
|
|
6
|
+
"Layout": { "width": 44, "height": 44, "z": 2 },
|
|
7
|
+
"Sprite": { "file": "drawings/cauldron.pxart", "mode": "fit" },
|
|
8
|
+
"Collider": { "shape": "circle", "width": 40, "height": 40, "bounciness": 0.6, "friction": 0.05 },
|
|
9
|
+
"RigidBody": { "bodyType": "dynamic", "gravityScale": 1, "drag": 0.005 },
|
|
10
|
+
"Slingshot": { "speed": 0.1, "maxDrag": 200 }
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
]
|
|
14
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "Block",
|
|
3
|
+
"actors": [
|
|
4
|
+
{
|
|
5
|
+
"components": {
|
|
6
|
+
"Layout": { "width": 100, "height": 40, "z": 1 },
|
|
7
|
+
"Sprite": { "file": "drawings/block.pxart", "mode": "tile", "tileSize": 20 },
|
|
8
|
+
"Collider": { "bounciness": 0.4, "friction": 0.2 }
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
]
|
|
12
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "Crate",
|
|
3
|
+
"actors": [
|
|
4
|
+
{
|
|
5
|
+
"components": {
|
|
6
|
+
"Layout": { "width": 48, "height": 48, "z": 2 },
|
|
7
|
+
"Sprite": { "file": "drawings/block.pxart", "mode": "fit" },
|
|
8
|
+
"Collider": { "shape": "box", "width": 46, "height": 46, "bounciness": 0.2, "friction": 0.3 },
|
|
9
|
+
"RigidBody": { "bodyType": "dynamic", "gravityScale": 1, "drag": 0.02 },
|
|
10
|
+
"Draggable": {}
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
]
|
|
14
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"editor": {
|
|
3
|
+
"initialPanels": [
|
|
4
|
+
{
|
|
5
|
+
"width": 400,
|
|
6
|
+
"column": [{ "type": "playtest" }, { "type": "files" }]
|
|
7
|
+
},
|
|
8
|
+
{ "type": "editor", "file": "scenes/main.scene" }
|
|
9
|
+
],
|
|
10
|
+
"hiddenPaths": [],
|
|
11
|
+
"visiblePaths": ["drawings/**", "scenes/**", "blueprints/**", "behaviors/**"]
|
|
12
|
+
}
|
|
13
|
+
}
|