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,686 @@
|
|
|
1
|
+
import { initialFiles, parseJsonFile } from './files';
|
|
2
|
+
import { getBlueprintTemplate, mergeComponents } from './blueprint';
|
|
3
|
+
import { installPhysics } from '../physics';
|
|
4
|
+
import { getColliderRect, intersects, spriteIsEmpty } from './collider';
|
|
5
|
+
|
|
6
|
+
const CARD_WIDTH = 500;
|
|
7
|
+
const CARD_HEIGHT = 700;
|
|
8
|
+
const DEFAULT_VIEWPORT = {
|
|
9
|
+
width: CARD_WIDTH,
|
|
10
|
+
height: CARD_HEIGHT,
|
|
11
|
+
originX: 0,
|
|
12
|
+
originY: 0,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const cardSize = { width: CARD_WIDTH, height: CARD_HEIGHT };
|
|
16
|
+
|
|
17
|
+
// Resolve a scene-file name to its key in the loaded file map. Accepts
|
|
18
|
+
// 'main.scene', 'scenes/main.scene', './scenes/main.scene', or 'main' -- the
|
|
19
|
+
// `.scene` extension and a leading `scenes/` are both optional.
|
|
20
|
+
function resolveSceneFileKey(name) {
|
|
21
|
+
const key = String(name).replace(/^\.?\//, '');
|
|
22
|
+
const candidates = [key];
|
|
23
|
+
if (!key.endsWith('.scene')) candidates.push(`${key}.scene`);
|
|
24
|
+
for (const candidate of [...candidates]) {
|
|
25
|
+
if (!candidate.startsWith('scenes/')) candidates.push(`scenes/${candidate}`);
|
|
26
|
+
}
|
|
27
|
+
return candidates.find((candidate) => initialFiles[candidate] !== undefined) ?? key;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class SceneRuntime {
|
|
31
|
+
// `files` is the live deck files map (path -> text), used to resolve every
|
|
32
|
+
// actor's `blueprint` ref against the CURRENT blueprint file content -- not
|
|
33
|
+
// a load-time snapshot -- so a blueprint edit propagates to every scene that
|
|
34
|
+
// places it, in both the editor and play mode / `PlayOnly`.
|
|
35
|
+
constructor(sceneData, behaviors, sprites, files) {
|
|
36
|
+
this.behaviors = new Map(behaviors.map((Behavior) => [Behavior.behaviorName, Behavior]));
|
|
37
|
+
this.sprites = sprites ?? {};
|
|
38
|
+
this.files = files ?? {};
|
|
39
|
+
this.time = 0;
|
|
40
|
+
this.keys = new Set();
|
|
41
|
+
this.pointer = { x: 0, y: 0, down: false };
|
|
42
|
+
this.data = { actors: [] };
|
|
43
|
+
this.actors = new Map();
|
|
44
|
+
this.camera = undefined;
|
|
45
|
+
this.status = undefined;
|
|
46
|
+
// Registered systems run once per frame after all behavior `update`s (see
|
|
47
|
+
// `update`). A system is a plain object with an optional
|
|
48
|
+
// `afterBehaviors(scene, dt)` hook; this is the seam the physics module
|
|
49
|
+
// plugs into so it stays decoupled from the core runtime and portable to
|
|
50
|
+
// other kits.
|
|
51
|
+
this.systems = [];
|
|
52
|
+
this.load(sceneData);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Register a per-frame system (e.g. the physics simulation). Systems step in
|
|
56
|
+
// registration order, after behaviors, every `update`. Returns the system.
|
|
57
|
+
registerSystem(system) {
|
|
58
|
+
this.systems.push(system);
|
|
59
|
+
return system;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
load(sceneData) {
|
|
63
|
+
this.data = structuredClone(sceneData);
|
|
64
|
+
this.actors = new Map();
|
|
65
|
+
for (const actor of this.data.actors ?? []) {
|
|
66
|
+
actor.runtime = {};
|
|
67
|
+
if (actor.blueprint) {
|
|
68
|
+
const template = getBlueprintTemplate(this.files, actor.blueprint);
|
|
69
|
+
actor.components = mergeComponents(template?.components, actor.components);
|
|
70
|
+
}
|
|
71
|
+
this.actors.set(actor.id, actor);
|
|
72
|
+
for (const [behaviorName, Behavior] of this.behaviors) {
|
|
73
|
+
const component = actor.components[behaviorName];
|
|
74
|
+
if (component) {
|
|
75
|
+
actor.components[behaviorName] = {
|
|
76
|
+
...Behavior.defaultProps,
|
|
77
|
+
...component,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Read and parse a scene file by name, returning its scene data. Use this
|
|
85
|
+
// instead of `import x from './scenes/foo.scene'` -- scene files are data, not
|
|
86
|
+
// JS modules, so importing them is unsupported.
|
|
87
|
+
readFromFile(name) {
|
|
88
|
+
const key = resolveSceneFileKey(name);
|
|
89
|
+
const text = initialFiles[key];
|
|
90
|
+
if (text === undefined) {
|
|
91
|
+
throw new Error(`scene file not found: ${name}`);
|
|
92
|
+
}
|
|
93
|
+
const { value, error } = parseJsonFile(key, text);
|
|
94
|
+
if (error) throw new Error(error);
|
|
95
|
+
return value;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Transition the running scene to the scene stored in the named file (read it
|
|
99
|
+
// fresh, then load it). The building block for "go to scene" / restart.
|
|
100
|
+
loadFromFile(name) {
|
|
101
|
+
this.load(this.readFromFile(name));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
clone() {
|
|
105
|
+
// Route through makeScene so the clone gets the same registered systems
|
|
106
|
+
// (physics) as a freshly-made runtime.
|
|
107
|
+
return makeScene(this.serialize(), [...this.behaviors.values()], this.sprites, this.files);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
serialize() {
|
|
111
|
+
return structuredClone(this.data);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
getActor(actorId) {
|
|
115
|
+
return this.actors.get(actorId);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
getActors() {
|
|
119
|
+
return [...this.actors.values()].sort((a, b) => {
|
|
120
|
+
const az = getLayout(a)?.z ?? 0;
|
|
121
|
+
const bz = getLayout(b)?.z ?? 0;
|
|
122
|
+
return az - bz;
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
getComponent(actor, behaviorName) {
|
|
127
|
+
return actor?.components?.[behaviorName] ?? null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
actorWith(behaviorName) {
|
|
131
|
+
for (const actor of this.actors.values()) {
|
|
132
|
+
if (actor.components?.[behaviorName]) return actor;
|
|
133
|
+
}
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
actorsWith(behaviorName) {
|
|
138
|
+
const out = [];
|
|
139
|
+
for (const actor of this.getActors()) {
|
|
140
|
+
if (actor.components?.[behaviorName]) out.push(actor);
|
|
141
|
+
}
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
colliderRect(actorOrId) {
|
|
146
|
+
const actor = typeof actorOrId === 'string' ? this.getActor(actorOrId) : actorOrId;
|
|
147
|
+
return getColliderRect(actor, this.sprites);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
overlaps(first, second) {
|
|
151
|
+
const a = this.colliderRect(first);
|
|
152
|
+
const b = this.colliderRect(second);
|
|
153
|
+
return Boolean(a && b && intersects(a, b));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Raw spawn: `actor.components` is used as-is (already-resolved values),
|
|
157
|
+
// just filled out with each behavior's defaultProps. Internal use -- prefer
|
|
158
|
+
// `spawnFromBlueprint` so a runtime-spawned actor is a blueprint instance
|
|
159
|
+
// like every other actor.
|
|
160
|
+
spawnActor(actor) {
|
|
161
|
+
const id = actor.id ?? mintActorId(new Set(this.actors.keys()));
|
|
162
|
+
const components = {};
|
|
163
|
+
for (const [name, props] of Object.entries(actor.components ?? {})) {
|
|
164
|
+
const Behavior = this.behaviors.get(name);
|
|
165
|
+
components[name] = Behavior ? { ...Behavior.defaultProps, ...props } : { ...props };
|
|
166
|
+
}
|
|
167
|
+
const next = { ...actor, id, components, runtime: {} };
|
|
168
|
+
this.data.actors.push(next);
|
|
169
|
+
this.actors.set(id, next);
|
|
170
|
+
return next;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// The blessed spawn path: resolve `blueprintPath` from the live `files`,
|
|
174
|
+
// merge with `overrides.components` (sparse, same shape as a placed
|
|
175
|
+
// instance's file overrides), and spawn the result. `overrides.id`, when
|
|
176
|
+
// given, is used as the new actor's id.
|
|
177
|
+
spawnFromBlueprint(blueprintPath, overrides = {}) {
|
|
178
|
+
const template = getBlueprintTemplate(this.files, blueprintPath);
|
|
179
|
+
const components = mergeComponents(template?.components, overrides.components);
|
|
180
|
+
return this.spawnActor({ id: overrides.id, blueprint: blueprintPath, components });
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
despawnActor(actorId) {
|
|
184
|
+
const actor = this.actors.get(actorId);
|
|
185
|
+
if (!actor) return false;
|
|
186
|
+
this.actors.delete(actorId);
|
|
187
|
+
const idx = this.data.actors.findIndex((a) => a.id === actorId);
|
|
188
|
+
if (idx !== -1) this.data.actors.splice(idx, 1);
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Translate a screen-space pointer event into the scene's world-space
|
|
193
|
+
// `pointer`. The runtime owns this mapping (it knows the camera) so every
|
|
194
|
+
// input path -- the standalone ScenePlayer and the editor's play mode --
|
|
195
|
+
// agree on it; behaviors read `scene.pointer` in world coordinates. Pass
|
|
196
|
+
// `down` to also update the press state.
|
|
197
|
+
setPointerFromScreen(canvas, clientX, clientY, down) {
|
|
198
|
+
const point = screenToCard(canvas, clientX, clientY);
|
|
199
|
+
this.pointer.x = point.x + (this.camera?.x ?? 0);
|
|
200
|
+
this.pointer.y = point.y + (this.camera?.y ?? 0);
|
|
201
|
+
if (down !== undefined) this.pointer.down = down;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
update(dt) {
|
|
205
|
+
this.time += dt;
|
|
206
|
+
for (const actor of this.getActors()) {
|
|
207
|
+
this.forEachBehavior(actor, (instance) => instance.update?.(actor, this, dt));
|
|
208
|
+
}
|
|
209
|
+
// Behaviors have expressed their intent (velocities, forces) for this
|
|
210
|
+
// frame; now let registered systems advance (physics integrates, writes
|
|
211
|
+
// results back to Layout, and dispatches collision callbacks).
|
|
212
|
+
for (const system of this.systems) {
|
|
213
|
+
system.afterBehaviors?.(this, dt);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
forEachBehavior(actor, callback) {
|
|
218
|
+
for (const [behaviorName, props] of Object.entries(actor.components)) {
|
|
219
|
+
if (!props) continue;
|
|
220
|
+
const Behavior = this.behaviors.get(behaviorName);
|
|
221
|
+
if (!Behavior) continue;
|
|
222
|
+
callback(new Behavior(props));
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
draw(ctx, options = {}) {
|
|
227
|
+
const viewport = options.viewport ?? DEFAULT_VIEWPORT;
|
|
228
|
+
ctx.clearRect(0, 0, viewport.width, viewport.height);
|
|
229
|
+
const camera = options.useCamera && this.camera ? this.camera : { x: 0, y: 0 };
|
|
230
|
+
// When a blueprint is selected in the editor, its instances stay at full
|
|
231
|
+
// opacity and everything else -- the background and every other actor --
|
|
232
|
+
// dims, so the selection stands out.
|
|
233
|
+
const dimBlueprintPath = options.dimBlueprintPath ?? null;
|
|
234
|
+
if (options.showCropOutline) {
|
|
235
|
+
ctx.fillStyle = this.data.background ?? '#0f2a1d';
|
|
236
|
+
ctx.fillRect(0, 0, viewport.width, viewport.height);
|
|
237
|
+
if (dimBlueprintPath) {
|
|
238
|
+
ctx.fillStyle = 'rgba(0, 0, 0, 0.55)';
|
|
239
|
+
ctx.fillRect(0, 0, viewport.width, viewport.height);
|
|
240
|
+
}
|
|
241
|
+
} else {
|
|
242
|
+
ctx.fillStyle = this.data.background ?? '#0f2a1d';
|
|
243
|
+
ctx.fillRect(0, 0, CARD_WIDTH, CARD_HEIGHT);
|
|
244
|
+
if (dimBlueprintPath) {
|
|
245
|
+
ctx.fillStyle = 'rgba(0, 0, 0, 0.55)';
|
|
246
|
+
ctx.fillRect(0, 0, CARD_WIDTH, CARD_HEIGHT);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
ctx.save();
|
|
251
|
+
if (options.useCamera && camera) {
|
|
252
|
+
ctx.translate(
|
|
253
|
+
Math.round(viewport.originX - (camera.x ?? 0)),
|
|
254
|
+
Math.round(viewport.originY - (camera.y ?? 0))
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
if (options.showGrid) drawDotGrid(ctx, options.gridSize, viewport, camera);
|
|
258
|
+
for (const actor of this.getActors()) {
|
|
259
|
+
// Per-actor transform: Layout's `rotation` (degrees) rotates every draw
|
|
260
|
+
// behavior of the actor about its center.
|
|
261
|
+
ctx.save();
|
|
262
|
+
if (dimBlueprintPath && actor.blueprint !== dimBlueprintPath) {
|
|
263
|
+
ctx.globalAlpha = 0.2;
|
|
264
|
+
}
|
|
265
|
+
applyLayoutRotation(ctx, getLayout(actor));
|
|
266
|
+
if (options.editPlaceholders && shouldDrawEditPlaceholder(actor, this.sprites)) {
|
|
267
|
+
drawEditPlaceholder(ctx, actor);
|
|
268
|
+
}
|
|
269
|
+
this.forEachBehavior(actor, (instance) => instance.draw?.(actor, this, ctx, options));
|
|
270
|
+
ctx.restore();
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (options.selectedActorIds) {
|
|
274
|
+
for (const id of options.selectedActorIds) {
|
|
275
|
+
const actor = this.getActor(id);
|
|
276
|
+
if (actor) drawSelection(ctx, actor);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
if (options.marquee) drawMarquee(ctx, options.marquee);
|
|
280
|
+
if (options.showCropOutline) drawCropOutline(ctx);
|
|
281
|
+
ctx.restore();
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
actorAt(x, y) {
|
|
285
|
+
const actors = this.getActors().slice().reverse();
|
|
286
|
+
for (const actor of actors) {
|
|
287
|
+
const layout = getLayout(actor);
|
|
288
|
+
if (!layout) continue;
|
|
289
|
+
if (
|
|
290
|
+
x >= layout.x &&
|
|
291
|
+
x <= layout.x + layout.width &&
|
|
292
|
+
y >= layout.y &&
|
|
293
|
+
y <= layout.y + layout.height
|
|
294
|
+
) {
|
|
295
|
+
return actor;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
actorIdsInRect(rect) {
|
|
302
|
+
const minX = rect.x;
|
|
303
|
+
const minY = rect.y;
|
|
304
|
+
const maxX = rect.x + rect.width;
|
|
305
|
+
const maxY = rect.y + rect.height;
|
|
306
|
+
const ids = [];
|
|
307
|
+
for (const actor of this.getActors()) {
|
|
308
|
+
const layout = getLayout(actor);
|
|
309
|
+
if (!layout) continue;
|
|
310
|
+
if (
|
|
311
|
+
layout.x + layout.width >= minX &&
|
|
312
|
+
layout.x <= maxX &&
|
|
313
|
+
layout.y + layout.height >= minY &&
|
|
314
|
+
layout.y <= maxY
|
|
315
|
+
) {
|
|
316
|
+
ids.push(actor.id);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return ids;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export function makeScene(sceneData, behaviors, sprites, files) {
|
|
324
|
+
const runtime = new SceneRuntime(sceneData, behaviors, sprites, files);
|
|
325
|
+
// Physics is a self-contained module registered as a runtime system. The
|
|
326
|
+
// matter engine is created lazily on the first simulation step, so draw-only
|
|
327
|
+
// editor previews (which never call update) stay free.
|
|
328
|
+
installPhysics(runtime);
|
|
329
|
+
return runtime;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export function setActorComponent(sceneData, actorId, behaviorName, nextProps) {
|
|
333
|
+
const next = structuredClone(sceneData);
|
|
334
|
+
const actor = next.actors.find((candidate) => candidate.id === actorId);
|
|
335
|
+
if (!actor) return sceneData;
|
|
336
|
+
actor.components[behaviorName] = {
|
|
337
|
+
...(actor.components[behaviorName] ?? {}),
|
|
338
|
+
...nextProps,
|
|
339
|
+
};
|
|
340
|
+
return next;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
export function removeActorComponent(sceneData, actorId, behaviorName) {
|
|
344
|
+
const next = structuredClone(sceneData);
|
|
345
|
+
const actor = next.actors.find((candidate) => candidate.id === actorId);
|
|
346
|
+
if (!actor || !(behaviorName in actor.components)) return sceneData;
|
|
347
|
+
delete actor.components[behaviorName];
|
|
348
|
+
return next;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export function moveActors(sceneData, actorIds, dx, dy) {
|
|
352
|
+
if (actorIds.length === 0) return sceneData;
|
|
353
|
+
const next = structuredClone(sceneData);
|
|
354
|
+
let changed = false;
|
|
355
|
+
for (const id of actorIds) {
|
|
356
|
+
const actor = next.actors.find((candidate) => candidate.id === id);
|
|
357
|
+
const layout = actor ? actor.components.Layout : null;
|
|
358
|
+
if (!actor || !layout) continue;
|
|
359
|
+
actor.components.Layout = {
|
|
360
|
+
...layout,
|
|
361
|
+
x: Math.round(layout.x + dx),
|
|
362
|
+
y: Math.round(layout.y + dy),
|
|
363
|
+
};
|
|
364
|
+
changed = true;
|
|
365
|
+
}
|
|
366
|
+
return changed ? next : sceneData;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
export function removeActors(sceneData, actorIds) {
|
|
370
|
+
if (actorIds.length === 0) return sceneData;
|
|
371
|
+
const toRemove = new Set(actorIds);
|
|
372
|
+
const next = structuredClone(sceneData);
|
|
373
|
+
next.actors = next.actors.filter((actor) => !toRemove.has(actor.id));
|
|
374
|
+
return next.actors.length === sceneData.actors.length ? sceneData : next;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export function duplicateActors(sceneData, actorIds) {
|
|
378
|
+
if (actorIds.length === 0) return { sceneData, newIds: [] };
|
|
379
|
+
const next = structuredClone(sceneData);
|
|
380
|
+
const existingIds = new Set(next.actors.map((actor) => actor.id));
|
|
381
|
+
const offset = duplicateOffset(sceneData);
|
|
382
|
+
const newIds = [];
|
|
383
|
+
for (const id of actorIds) {
|
|
384
|
+
const source = next.actors.find((candidate) => candidate.id === id);
|
|
385
|
+
if (!source) continue;
|
|
386
|
+
const copy = structuredClone(source);
|
|
387
|
+
copy.id = dedupeActorId(existingIds, stripDupSuffix(source.id));
|
|
388
|
+
existingIds.add(copy.id);
|
|
389
|
+
const layout = copy.components.Layout;
|
|
390
|
+
if (layout) {
|
|
391
|
+
copy.components.Layout = {
|
|
392
|
+
...layout,
|
|
393
|
+
x: Math.round(layout.x + offset),
|
|
394
|
+
y: Math.round(layout.y + offset),
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
next.actors.push(copy);
|
|
398
|
+
newIds.push(copy.id);
|
|
399
|
+
}
|
|
400
|
+
return { sceneData: next, newIds };
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function duplicateOffset(sceneData) {
|
|
404
|
+
const gridSize = sceneData.editor?.gridSize;
|
|
405
|
+
return Number.isFinite(gridSize) && gridSize > 0 ? gridSize : 50;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
export function arrangeActors(sceneData, actorIds, action) {
|
|
409
|
+
if (actorIds.length === 0) return sceneData;
|
|
410
|
+
const selected = new Set(actorIds);
|
|
411
|
+
const ordered = sceneData.actors
|
|
412
|
+
.map((actor, index) => ({ actor, index, layout: getLayout(actor) }))
|
|
413
|
+
.filter((entry) => entry.layout)
|
|
414
|
+
.sort((a, b) => (a.layout.z ?? 0) - (b.layout.z ?? 0) || a.index - b.index);
|
|
415
|
+
if (!ordered.some((entry) => selected.has(entry.actor.id))) return sceneData;
|
|
416
|
+
const arranged = arrangeOrderedActors(ordered, selected, action);
|
|
417
|
+
if (arranged === ordered) return sceneData;
|
|
418
|
+
return applyActorOrder(sceneData, arranged);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function arrangeOrderedActors(ordered, selected, action) {
|
|
422
|
+
if (action === 'front') return moveSelectionToEdge(ordered, selected, 'front');
|
|
423
|
+
if (action === 'back') return moveSelectionToEdge(ordered, selected, 'back');
|
|
424
|
+
if (action === 'forward') return moveSelectionOneStep(ordered, selected, 1);
|
|
425
|
+
if (action === 'backward') return moveSelectionOneStep(ordered, selected, -1);
|
|
426
|
+
return ordered;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function moveSelectionToEdge(ordered, selected, edge) {
|
|
430
|
+
const picked = ordered.filter((entry) => selected.has(entry.actor.id));
|
|
431
|
+
const rest = ordered.filter((entry) => !selected.has(entry.actor.id));
|
|
432
|
+
const next = edge === 'front' ? [...rest, ...picked] : [...picked, ...rest];
|
|
433
|
+
return sameActorOrder(ordered, next) ? ordered : next;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function moveSelectionOneStep(ordered, selected, direction) {
|
|
437
|
+
const next = [...ordered];
|
|
438
|
+
if (direction > 0) {
|
|
439
|
+
for (let index = next.length - 2; index >= 0; index--) {
|
|
440
|
+
if (!selected.has(next[index].actor.id) || selected.has(next[index + 1].actor.id)) continue;
|
|
441
|
+
[next[index], next[index + 1]] = [next[index + 1], next[index]];
|
|
442
|
+
}
|
|
443
|
+
} else {
|
|
444
|
+
for (let index = 1; index < next.length; index++) {
|
|
445
|
+
if (!selected.has(next[index].actor.id) || selected.has(next[index - 1].actor.id)) continue;
|
|
446
|
+
[next[index], next[index - 1]] = [next[index - 1], next[index]];
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
return sameActorOrder(ordered, next) ? ordered : next;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function sameActorOrder(a, b) {
|
|
453
|
+
return a.length === b.length && a.every((entry, index) => entry.actor.id === b[index].actor.id);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function applyActorOrder(sceneData, ordered) {
|
|
457
|
+
const zById = new Map(ordered.map((entry, index) => [entry.actor.id, index]));
|
|
458
|
+
const next = structuredClone(sceneData);
|
|
459
|
+
let changed = false;
|
|
460
|
+
for (const actor of next.actors) {
|
|
461
|
+
const z = zById.get(actor.id);
|
|
462
|
+
const layout = getLayout(actor);
|
|
463
|
+
if (z === undefined || !layout || layout.z === z) continue;
|
|
464
|
+
actor.components.Layout = { ...layout, z };
|
|
465
|
+
changed = true;
|
|
466
|
+
}
|
|
467
|
+
return changed ? next : sceneData;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// Axis-aligned layout box of a single actor in world (card) units, or null.
|
|
471
|
+
// Ignores Layout.rotation -- v1 selection bounds are axis-aligned (the canvas
|
|
472
|
+
// `drawSelection` still draws the rotated outline for per-actor feedback).
|
|
473
|
+
export function getLayoutRect(actor) {
|
|
474
|
+
const layout = getLayout(actor);
|
|
475
|
+
if (!layout) return null;
|
|
476
|
+
return { x: layout.x, y: layout.y, width: layout.width, height: layout.height };
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Union axis-aligned bounding box of the given actors in world units, or null
|
|
480
|
+
// when none of them have a Layout. Used to anchor the on-canvas selection
|
|
481
|
+
// toolbar to the whole multi-selection.
|
|
482
|
+
export function getSelectionBounds(sceneData, actorIds) {
|
|
483
|
+
if (!sceneData || !actorIds || actorIds.length === 0) return null;
|
|
484
|
+
const wanted = new Set(actorIds);
|
|
485
|
+
let minX = Infinity;
|
|
486
|
+
let minY = Infinity;
|
|
487
|
+
let maxX = -Infinity;
|
|
488
|
+
let maxY = -Infinity;
|
|
489
|
+
let found = false;
|
|
490
|
+
for (const actor of sceneData.actors) {
|
|
491
|
+
if (!wanted.has(actor.id)) continue;
|
|
492
|
+
const rect = getLayoutRect(actor);
|
|
493
|
+
if (!rect) continue;
|
|
494
|
+
found = true;
|
|
495
|
+
minX = Math.min(minX, rect.x);
|
|
496
|
+
minY = Math.min(minY, rect.y);
|
|
497
|
+
maxX = Math.max(maxX, rect.x + rect.width);
|
|
498
|
+
maxY = Math.max(maxY, rect.y + rect.height);
|
|
499
|
+
}
|
|
500
|
+
if (!found) return null;
|
|
501
|
+
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
export function mintActorId(existing) {
|
|
505
|
+
for (let attempt = 0; attempt < 64; attempt++) {
|
|
506
|
+
const candidate = Math.floor(Math.random() * 0xffffffff)
|
|
507
|
+
.toString(16)
|
|
508
|
+
.padStart(8, '0');
|
|
509
|
+
if (!existing.has(candidate)) return candidate;
|
|
510
|
+
}
|
|
511
|
+
return `${Date.now().toString(16)}${Math.floor(Math.random() * 0xffff).toString(16)}`;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// Slug-suffix dedup: return `base`, else `base-2`, `base-3`, ... -- the first
|
|
515
|
+
// not already in `existing`. Callers pass an already-formed base (a blueprint
|
|
516
|
+
// name's slug for fresh placements, an existing id for clones) so newly created
|
|
517
|
+
// actors read as `cauldron` / `cauldron-2` instead of opaque hex. Falls back to
|
|
518
|
+
// a random hex id in the pathological case (100k straight collisions) so the
|
|
519
|
+
// caller always gets a unique id.
|
|
520
|
+
export function dedupeActorId(existing, base) {
|
|
521
|
+
const safe = typeof base === 'string' && base.length ? base : 'actor';
|
|
522
|
+
if (!existing.has(safe)) return safe;
|
|
523
|
+
for (let attempt = 2; attempt < 100000; attempt += 1) {
|
|
524
|
+
const candidate = `${safe}-${attempt}`;
|
|
525
|
+
if (!existing.has(candidate)) return candidate;
|
|
526
|
+
}
|
|
527
|
+
return mintActorId(existing);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// Strip a trailing `-<n>` dedup suffix so a clone of `brick-3` bases off
|
|
531
|
+
// `brick` (yielding the next free `brick-N`) instead of nesting `brick-3-2`.
|
|
532
|
+
function stripDupSuffix(id) {
|
|
533
|
+
return typeof id === 'string' ? id.replace(/-\d+$/, '') : id;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
export function screenToCard(canvas, clientX, clientY) {
|
|
537
|
+
const rect = canvas.getBoundingClientRect();
|
|
538
|
+
const viewport = readCanvasViewport(canvas);
|
|
539
|
+
return {
|
|
540
|
+
x: ((clientX - rect.left) / rect.width) * viewport.width - viewport.originX,
|
|
541
|
+
y: ((clientY - rect.top) / rect.height) * viewport.height - viewport.originY,
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
export function configureSceneCanvas(canvas, ctx = canvas.getContext('2d'), viewport = DEFAULT_VIEWPORT) {
|
|
546
|
+
if (!ctx) return null;
|
|
547
|
+
const rect = canvas.getBoundingClientRect();
|
|
548
|
+
const dpr = window.devicePixelRatio || 1;
|
|
549
|
+
const displayWidth = rect.width || CARD_WIDTH;
|
|
550
|
+
const displayHeight = rect.height || CARD_HEIGHT;
|
|
551
|
+
const pixelWidth = Math.max(1, Math.round(displayWidth * dpr));
|
|
552
|
+
const pixelHeight = Math.max(1, Math.round(displayHeight * dpr));
|
|
553
|
+
if (canvas.width !== pixelWidth) canvas.width = pixelWidth;
|
|
554
|
+
if (canvas.height !== pixelHeight) canvas.height = pixelHeight;
|
|
555
|
+
canvas.dataset.viewportWidth = String(viewport.width);
|
|
556
|
+
canvas.dataset.viewportHeight = String(viewport.height);
|
|
557
|
+
canvas.dataset.viewportOriginX = String(viewport.originX);
|
|
558
|
+
canvas.dataset.viewportOriginY = String(viewport.originY);
|
|
559
|
+
ctx.setTransform(pixelWidth / viewport.width, 0, 0, pixelHeight / viewport.height, 0, 0);
|
|
560
|
+
ctx.imageSmoothingEnabled = true;
|
|
561
|
+
return ctx;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function readCanvasViewport(canvas) {
|
|
565
|
+
return {
|
|
566
|
+
width: Number(canvas.dataset.viewportWidth) || CARD_WIDTH,
|
|
567
|
+
height: Number(canvas.dataset.viewportHeight) || CARD_HEIGHT,
|
|
568
|
+
originX: Number(canvas.dataset.viewportOriginX) || 0,
|
|
569
|
+
originY: Number(canvas.dataset.viewportOriginY) || 0,
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function drawDotGrid(ctx, gridSize = 25, viewport = DEFAULT_VIEWPORT, camera = { x: 0, y: 0 }) {
|
|
574
|
+
const size = Number.isFinite(gridSize) && gridSize > 0 ? gridSize : 25;
|
|
575
|
+
const minX = (camera.x ?? 0) - viewport.originX;
|
|
576
|
+
const minY = (camera.y ?? 0) - viewport.originY;
|
|
577
|
+
const maxX = minX + viewport.width;
|
|
578
|
+
const maxY = minY + viewport.height;
|
|
579
|
+
const startX = Math.floor(minX / size) * size;
|
|
580
|
+
const startY = Math.floor(minY / size) * size;
|
|
581
|
+
ctx.save();
|
|
582
|
+
ctx.fillStyle = 'rgba(255, 255, 255, 0.18)';
|
|
583
|
+
for (let x = startX; x <= maxX; x += size) {
|
|
584
|
+
for (let y = startY; y <= maxY; y += size) {
|
|
585
|
+
ctx.beginPath();
|
|
586
|
+
ctx.arc(x, y, 1.35, 0, Math.PI * 2);
|
|
587
|
+
ctx.fill();
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
ctx.restore();
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// Per-actor edit bounding box visibility: show it only when the actor lacks a
|
|
594
|
+
// real sprite to look at (no Sprite behavior, or a Sprite with no .pxart file).
|
|
595
|
+
// This gray box is purely the "sprite-less actor" indicator -- selection is
|
|
596
|
+
// represented separately by the DOM SelectionOverlay's solid white box, so a
|
|
597
|
+
// Sprite-backed actor never shows this box, selected or not.
|
|
598
|
+
function shouldDrawEditPlaceholder(actor, sprites) {
|
|
599
|
+
const sprite = actor?.components?.Sprite;
|
|
600
|
+
if (!sprite || !sprite.file) return true;
|
|
601
|
+
const resolved = sprites?.[sprite.file];
|
|
602
|
+
// An unresolved file falls back to Sprite.jsx's own placeholder art, so it's
|
|
603
|
+
// not invisible -- only draw the editor placeholder when the file resolves
|
|
604
|
+
// to fully-transparent (empty) art.
|
|
605
|
+
if (!resolved) return false;
|
|
606
|
+
return spriteIsEmpty(resolved);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function drawEditPlaceholder(ctx, actor) {
|
|
610
|
+
const layout = getLayout(actor);
|
|
611
|
+
if (!layout) return;
|
|
612
|
+
ctx.save();
|
|
613
|
+
ctx.fillStyle = 'rgba(255, 255, 255, 0.04)';
|
|
614
|
+
ctx.fillRect(layout.x, layout.y, layout.width, layout.height);
|
|
615
|
+
ctx.strokeStyle = 'rgba(255, 255, 255, 0.25)';
|
|
616
|
+
ctx.lineWidth = 1;
|
|
617
|
+
ctx.strokeRect(layout.x + 0.5, layout.y + 0.5, layout.width - 1, layout.height - 1);
|
|
618
|
+
ctx.restore();
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function drawMarquee(ctx, rect) {
|
|
622
|
+
if (rect.width === 0 || rect.height === 0) return;
|
|
623
|
+
ctx.save();
|
|
624
|
+
ctx.fillStyle = 'rgba(255, 255, 255, 0.12)';
|
|
625
|
+
ctx.fillRect(rect.x, rect.y, rect.width, rect.height);
|
|
626
|
+
ctx.strokeStyle = 'rgba(255, 255, 255, 0.4)';
|
|
627
|
+
ctx.lineWidth = 1;
|
|
628
|
+
ctx.setLineDash([4, 3]);
|
|
629
|
+
ctx.strokeRect(rect.x + 0.5, rect.y + 0.5, rect.width - 1, rect.height - 1);
|
|
630
|
+
ctx.restore();
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function drawCropOutline(ctx) {
|
|
634
|
+
ctx.save();
|
|
635
|
+
ctx.strokeStyle = 'rgba(255, 255, 255, 0.4)';
|
|
636
|
+
ctx.lineWidth = 2;
|
|
637
|
+
ctx.setLineDash([]);
|
|
638
|
+
const transform = ctx.getTransform();
|
|
639
|
+
const dpr = window.devicePixelRatio || 1;
|
|
640
|
+
const cssRadius = 12;
|
|
641
|
+
const radius = transform.a ? (cssRadius * dpr) / transform.a : cssRadius;
|
|
642
|
+
roundedRect(ctx, 1, 1, CARD_WIDTH - 2, CARD_HEIGHT - 2, radius);
|
|
643
|
+
ctx.stroke();
|
|
644
|
+
ctx.restore();
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function roundedRect(ctx, x, y, width, height, radius) {
|
|
648
|
+
const r = Math.min(radius, width / 2, height / 2);
|
|
649
|
+
ctx.beginPath();
|
|
650
|
+
ctx.moveTo(x + r, y);
|
|
651
|
+
ctx.lineTo(x + width - r, y);
|
|
652
|
+
ctx.quadraticCurveTo(x + width, y, x + width, y + r);
|
|
653
|
+
ctx.lineTo(x + width, y + height - r);
|
|
654
|
+
ctx.quadraticCurveTo(x + width, y + height, x + width - r, y + height);
|
|
655
|
+
ctx.lineTo(x + r, y + height);
|
|
656
|
+
ctx.quadraticCurveTo(x, y + height, x, y + height - r);
|
|
657
|
+
ctx.lineTo(x, y + r);
|
|
658
|
+
ctx.quadraticCurveTo(x, y, x + r, y);
|
|
659
|
+
ctx.closePath();
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function drawSelection(ctx, actor) {
|
|
663
|
+
const layout = getLayout(actor);
|
|
664
|
+
if (!layout) return;
|
|
665
|
+
ctx.save();
|
|
666
|
+
applyLayoutRotation(ctx, layout);
|
|
667
|
+
ctx.strokeStyle = '#fff';
|
|
668
|
+
ctx.lineWidth = 2;
|
|
669
|
+
ctx.setLineDash([6, 4]);
|
|
670
|
+
ctx.strokeRect(layout.x + 1, layout.y + 1, layout.width - 2, layout.height - 2);
|
|
671
|
+
ctx.restore();
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function applyLayoutRotation(ctx, layout) {
|
|
675
|
+
const rotation = layout?.rotation ?? 0;
|
|
676
|
+
if (!rotation || !layout) return;
|
|
677
|
+
const cx = layout.x + layout.width / 2;
|
|
678
|
+
const cy = layout.y + layout.height / 2;
|
|
679
|
+
ctx.translate(cx, cy);
|
|
680
|
+
ctx.rotate((rotation * Math.PI) / 180);
|
|
681
|
+
ctx.translate(-cx, -cy);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function getLayout(actor) {
|
|
685
|
+
return actor?.components?.Layout;
|
|
686
|
+
}
|