castle-web-cli 0.4.84 → 0.4.86

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