@umicat/phaser-sdk 1.0.0

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 (62) hide show
  1. package/SDK-GUIDE.md +1726 -0
  2. package/dist/core/Transport.d.ts +28 -0
  3. package/dist/core/Transport.js +7 -0
  4. package/dist/core/Umicat.d.ts +45 -0
  5. package/dist/core/Umicat.js +60 -0
  6. package/dist/core/UmicatGame.d.ts +43 -0
  7. package/dist/core/UmicatGame.js +64 -0
  8. package/dist/core/UmicatScene.d.ts +19 -0
  9. package/dist/core/UmicatScene.js +38 -0
  10. package/dist/core/transports/LocalStorageTransport.d.ts +22 -0
  11. package/dist/core/transports/LocalStorageTransport.js +78 -0
  12. package/dist/core/transports/PostMessageTransport.d.ts +28 -0
  13. package/dist/core/transports/PostMessageTransport.js +105 -0
  14. package/dist/editor/EditorBridge.d.ts +114 -0
  15. package/dist/editor/EditorBridge.js +2608 -0
  16. package/dist/editor/EditorOverlayScene.d.ts +333 -0
  17. package/dist/editor/EditorOverlayScene.js +1896 -0
  18. package/dist/editor/EditorState.d.ts +251 -0
  19. package/dist/editor/EditorState.js +197 -0
  20. package/dist/gamedata/GameDataModule.d.ts +45 -0
  21. package/dist/gamedata/GameDataModule.js +59 -0
  22. package/dist/index.d.ts +43 -0
  23. package/dist/index.js +43 -0
  24. package/dist/orientation.d.ts +5 -0
  25. package/dist/orientation.js +4 -0
  26. package/dist/protocol.d.ts +807 -0
  27. package/dist/protocol.js +3 -0
  28. package/dist/realtime/RealtimeModule.d.ts +93 -0
  29. package/dist/realtime/RealtimeModule.js +115 -0
  30. package/dist/realtime/UmicatRoom.d.ts +197 -0
  31. package/dist/realtime/UmicatRoom.js +353 -0
  32. package/dist/recording/RecordingManager.d.ts +11 -0
  33. package/dist/recording/RecordingManager.js +59 -0
  34. package/dist/saves/SavesModule.d.ts +23 -0
  35. package/dist/saves/SavesModule.js +37 -0
  36. package/dist/scene/EditorMode.d.ts +17 -0
  37. package/dist/scene/EditorMode.js +22 -0
  38. package/dist/scene/EntityRegistry.d.ts +39 -0
  39. package/dist/scene/EntityRegistry.js +103 -0
  40. package/dist/scene/GameConfig.d.ts +60 -0
  41. package/dist/scene/GameConfig.js +50 -0
  42. package/dist/scene/HudRuntime.d.ts +131 -0
  43. package/dist/scene/HudRuntime.js +1224 -0
  44. package/dist/scene/Prefabs.d.ts +92 -0
  45. package/dist/scene/Prefabs.js +175 -0
  46. package/dist/scene/Rules.d.ts +73 -0
  47. package/dist/scene/Rules.js +164 -0
  48. package/dist/scene/SceneLoader.d.ts +118 -0
  49. package/dist/scene/SceneLoader.js +615 -0
  50. package/dist/scene/Waves.d.ts +85 -0
  51. package/dist/scene/Waves.js +365 -0
  52. package/dist/scene/autotile.d.ts +103 -0
  53. package/dist/scene/autotile.js +321 -0
  54. package/dist/scene/renderScripts.d.ts +53 -0
  55. package/dist/scene/renderScripts.js +67 -0
  56. package/dist/scene/spawnEntity.d.ts +201 -0
  57. package/dist/scene/spawnEntity.js +1326 -0
  58. package/dist/scene/types.d.ts +1166 -0
  59. package/dist/scene/types.js +34 -0
  60. package/dist/screenshot/ScreenshotManager.d.ts +14 -0
  61. package/dist/screenshot/ScreenshotManager.js +33 -0
  62. package/package.json +35 -0
@@ -0,0 +1,2608 @@
1
+ // 0.2.76 — no-op version bump to validate session-server's new
2
+ // "chore: update @umicat/phaser-sdk to <version>" commit message in
3
+ // the History panel. Refresh the browser after this lands and the
4
+ // commit should show the version inline. Remove this comment in the
5
+ // next functional patch.
6
+ import Phaser from 'phaser';
7
+ import { getEntityRegistry } from '../scene/EntityRegistry.js';
8
+ import { applyAssetHitbox, applyTilesetAnimations, applyTilesetTileMetadata, applyTrackedHitArea, parseColor, resetTilesetAnimationsToRoot, spawnEntity } from '../scene/spawnEntity.js';
9
+ import { applyAutotile, findTerrain, getAutotileKind, invalidateAutotileCells } from '../scene/autotile.js';
10
+ import { resolveRenderScript } from '../scene/renderScripts.js';
11
+ import { getManifest } from '../scene/SceneLoader.js';
12
+ import { getRules, patchRule } from '../scene/Rules.js';
13
+ import { EditorOverlayScene, EDITOR_OVERLAY_KEY } from './EditorOverlayScene.js';
14
+ import { getEditorState, setEditorActive, setSelection, getSelection, getEditorMode, setEditorMode, setDebugOverlayState, setTilemapToolState, } from './EditorState.js';
15
+ import { applyHudPatch, createHudEntityInScene, deleteHudEntityFromScene, findHudRegistry, findHudSceneFile, UMICAT_HUD_SCENE_KEY, } from '../scene/HudRuntime.js';
16
+ /**
17
+ * EditorBridge wires the host (home-ui) to the iframe's Phaser game during
18
+ * Edit mode. Manages:
19
+ *
20
+ * - Entering / exiting edit mode (pauses non-Boot scenes; launches the overlay)
21
+ * - Applying entity patches from the host (Inspector / Undo edits)
22
+ * - Tracking selection (host pushes; overlay reads)
23
+ * - Pan/zoom of the editor camera
24
+ * - Posting pickEntity + dragEnd back to the host
25
+ *
26
+ * Wired by `setupEditorModeListener` in EditorMode.ts.
27
+ */
28
+ const BOOT_SCENE_KEYS = new Set(['BootScene', 'Boot']);
29
+ const EDITOR_LISTENER_FLAG = '__unboxyEditorBridgeListener';
30
+ export function setupEditorBridge(game) {
31
+ const flagged = game;
32
+ if (flagged[EDITOR_LISTENER_FLAG])
33
+ return;
34
+ flagged[EDITOR_LISTENER_FLAG] = true;
35
+ // Register the overlay scene class so it can be launched when needed.
36
+ // (Adding a class to game.scene.add() registers it without starting it.)
37
+ game.scene.add(EDITOR_OVERLAY_KEY, EditorOverlayScene, false);
38
+ window.addEventListener('message', (event) => {
39
+ const data = event.data;
40
+ if (!data || typeof data !== 'object' || typeof data.type !== 'string')
41
+ return;
42
+ if (!data.type.startsWith('umicat:editor:'))
43
+ return;
44
+ handleMessage(game, data);
45
+ });
46
+ }
47
+ function handleMessage(game, msg) {
48
+ switch (msg.type) {
49
+ case 'umicat:editor:enter':
50
+ enterEdit(game);
51
+ break;
52
+ case 'umicat:editor:exit':
53
+ exitEdit(game);
54
+ break;
55
+ case 'umicat:editor:getScene':
56
+ postSceneSnapshot(game);
57
+ break;
58
+ case 'umicat:editor:applyEdit':
59
+ void applyEdit(game, msg.entityId, msg.patch, msg.manifestAsset);
60
+ break;
61
+ case 'umicat:editor:setSelection':
62
+ setSelection(game, msg.entityIds[0] ?? null);
63
+ postSelectionRect(game);
64
+ break;
65
+ case 'umicat:editor:panZoom':
66
+ // applyEditorPanZoom now posts selectionRect itself, so no need
67
+ // to chain it here. RAF-coalesced anyway.
68
+ applyPanZoomToWorld(game, msg);
69
+ break;
70
+ case 'umicat:editor:createEntity':
71
+ void createEntity(game, msg.entity, msg.manifestAsset);
72
+ break;
73
+ case 'umicat:editor:deleteEntity':
74
+ deleteEntity(game, msg.entityId);
75
+ postSelectionRect(game);
76
+ break;
77
+ case 'umicat:editor:setEditMode':
78
+ // Slice 5 — World/HUD toggle. Mode change is purely state; the host
79
+ // already has both scene snapshots from enterEdit and just switches
80
+ // which one its UI renders. Selection may carry over between modes,
81
+ // but selectionRect re-posts so the ✨ button moves to the active
82
+ // mode's selected entity (or hides if none).
83
+ setEditorMode(game, msg.mode);
84
+ setSelection(game, null);
85
+ postSelectionRect(game);
86
+ // P1 infinite canvas — HUD scene visibility tied to mode: hidden
87
+ // in world edit (so it doesn't float wrong over pan/zoom view),
88
+ // shown in HUD edit (since that's the editing surface).
89
+ setHudVisibility(game, msg.mode === 'hud');
90
+ break;
91
+ case 'umicat:editor:assetUpdate':
92
+ // Slice 8 — Asset-level live update for hitbox / depthAnchor edits.
93
+ void handleAssetUpdate(game, msg.asset);
94
+ break;
95
+ case 'umicat:editor:setDebugOverlay':
96
+ // Slice 8 — "Show hitboxes" debug overlay toggle.
97
+ setDebugOverlay(game, { showHitboxes: msg.showHitboxes });
98
+ break;
99
+ case 'umicat:editor:editPrefab':
100
+ // Slice 11 Phase B.5 / editor P0.2 — prefab live-edit.
101
+ handleEditPrefab(game, msg.prefabId, msg.patch);
102
+ break;
103
+ case 'umicat:editor:patchRule':
104
+ // Slice 11 Phase B.5 / editor P0.3 — rule live-edit.
105
+ handlePatchRule(game, msg.path, msg.value);
106
+ break;
107
+ case 'umicat:editor:setTilemapTool':
108
+ // Slice 6 Phase B — tilemap painter active state push.
109
+ handleSetTilemapTool(game, msg);
110
+ break;
111
+ case 'umicat:editor:editTilemap':
112
+ // Slice 6 Phase B — host-driven tilemap mutation (agent writes,
113
+ // undo/redo replay, addLayer with new tileset). Live painter
114
+ // strokes go SDK→host via `tilemapEdited` instead — this path is
115
+ // for everything else. handleEditTilemap is async (awaits asset
116
+ // load when manifestAsset present); fire-and-forget the promise.
117
+ void handleEditTilemap(game, msg.entityId, msg.ops, msg.manifestAsset);
118
+ break;
119
+ }
120
+ }
121
+ // --- Enter / exit ---------------------------------------------------------
122
+ function enterEdit(game) {
123
+ // Brute-force idempotent enter (2026-05-17): edit→play→edit was still
124
+ // breaking after the conditional-cleanup fix in 0.2.79 — the cleanup
125
+ // only ran when `state.active` was already true. But the bug also
126
+ // manifests when state is false but Phaser's scale.scaleMode /
127
+ // gameSize / camera viewports got out of sync due to the previous
128
+ // edit/play cycle. Solution: ALWAYS run uninstall + restore first,
129
+ // unconditionally — they're idempotent no-ops when there's no stale
130
+ // state, and they reset Phaser to a clean baseline when there IS
131
+ // stale state. Net cost: a few cheap getter/setter calls per enter.
132
+ uninstallVoidFill(game);
133
+ uninstallEditorCameras(game);
134
+ restoreCanvasAfterEditor(game);
135
+ setEditorActive(game, true);
136
+ pauseActiveNonEditor(game);
137
+ // P1 infinite canvas — expand the Phaser canvas to fill its container
138
+ // (host's iframe). Without this, the editor surface stays locked to
139
+ // the game's intrinsic aspect ratio (720×1280 portrait, etc.) with
140
+ // letterbox bars around it that the user can't pan into — defeats the
141
+ // "whole iframe = world map" mental model. Switches scale mode to
142
+ // RESIZE; canvas grows; editor cam viewport tracks the new canvas size.
143
+ // Game's logical world stays at its declared size; editor just has
144
+ // more "screen area" to show the world + void around it.
145
+ expandCanvasForEditor(game);
146
+ // P1 infinite canvas (correct architecture, 2026-05-17): editor view
147
+ // is a SEPARATE camera over the world, NOT the game's runtime camera.
148
+ // Matches Godot 2D editor's "editor viewport vs Camera2D" split.
149
+ installEditorCameras(game);
150
+ // Add the "editor void" grey fill into the world scene at very low
151
+ // depth so it renders BELOW entities (drag-to-place outside world
152
+ // bounds now shows the sprite instead of being covered by overlay
153
+ // grey). Runs after install so we know which world scene + which
154
+ // editor cam are active.
155
+ installVoidFill(game);
156
+ // Slice 6 Phase B — show tilemap grid sketches (hidden by default in
157
+ // Play mode). `setTilemapGridsVisible` walks every tilemap container
158
+ // in the registry and toggles its grid child. Called again on exitEdit
159
+ // with `false`.
160
+ setTilemapGridsVisible(game, true);
161
+ // Slice 6 Phase F — reset animated tiles to their root indices. Scenes
162
+ // are paused via `setActive(false)` in edit mode → animation UPDATE
163
+ // handlers don't fire → cells freeze on whatever frame was current.
164
+ // Reset so the editor shows the static authored frame (matches
165
+ // layer.data). exitEdit doesn't need a paired re-arm — the next UPDATE
166
+ // tick after scene resume re-runs `applyTilesetAnimations` cleanup-
167
+ // and-rebuild via the post-paint metadata re-application path.
168
+ resetAllTilesetAnimations(game);
169
+ // World edit mode hides HUD scene — HUD widgets are canvas-relative
170
+ // (anchor-positioned to edges) and would visually float wrong when the
171
+ // editor pans/zooms the world view. HUD edit mode shows it normally
172
+ // and treats HUD as the editing surface. Toggle in setEditorMode handler.
173
+ setHudVisibility(game, getEditorMode(game) === 'hud');
174
+ // Launch the overlay AFTER pausing world scenes so it sits on top in render
175
+ // order (Phaser renders scenes in the order they were started).
176
+ const overlayInit = buildOverlayInit(game);
177
+ if (!game.scene.isActive(EDITOR_OVERLAY_KEY)) {
178
+ game.scene.run(EDITOR_OVERLAY_KEY, overlayInit);
179
+ }
180
+ // Send the initial scene snapshot so home-ui can populate Hierarchy /
181
+ // Inspector without a separate getScene round-trip.
182
+ postSceneSnapshot(game);
183
+ // Race-window catch: when home-ui sends `enter` right after iframe load
184
+ // (which is exactly the auto-flush rebuild path), BootScene may still be
185
+ // in preload / GameScene may not have started, so the synchronous pause
186
+ // above had nothing to pause and the snapshot found no scene file in
187
+ // cache yet. Re-attempt for ~3 seconds. Each attempt is cheap and stops
188
+ // automatically once the user exits edit mode.
189
+ let attempts = 0;
190
+ const reattempt = () => {
191
+ if (!getEditorState(game).active)
192
+ return;
193
+ pauseActiveNonEditor(game);
194
+ if (!hasPostedSnapshot(game))
195
+ postSceneSnapshot(game);
196
+ attempts += 1;
197
+ if (attempts < 30)
198
+ setTimeout(reattempt, 100);
199
+ };
200
+ setTimeout(reattempt, 100);
201
+ }
202
+ function pauseActiveNonEditor(game) {
203
+ // P1 infinite canvas (2026-05-17) — use `setActive(false)` instead of
204
+ // `pause()` so scenes STOP updating (game logic frozen) but KEEP
205
+ // rendering. `pause()` halts both — which makes the editor camera blind
206
+ // to entity positions because the world scene's renderer is also paused.
207
+ // We need render to continue so editor cam pan/zoom can show the entities
208
+ // at correctly-transformed positions.
209
+ //
210
+ // Pair this with `physics.pause()` + `tweens.pauseAll()` + `time.paused`
211
+ // because setActive(false) on its own doesn't halt those — the Arcade
212
+ // body would keep stepping if untouched, entities animate during edit,
213
+ // etc.
214
+ for (const scene of game.scene.getScenes(true)) {
215
+ const key = scene.scene.key;
216
+ if (BOOT_SCENE_KEYS.has(key))
217
+ continue;
218
+ if (key === EDITOR_OVERLAY_KEY)
219
+ continue;
220
+ if (!scene.scene.isActive())
221
+ continue;
222
+ scene.scene.setActive(false);
223
+ if (scene.physics)
224
+ scene.physics.pause();
225
+ scene.tweens.pauseAll();
226
+ scene.time.paused = true;
227
+ }
228
+ }
229
+ /**
230
+ * Inverse of `pauseActiveNonEditor` — resumes update + physics + tweens +
231
+ * timers on every scene we paused. Called in `exitEdit`.
232
+ */
233
+ function resumeActiveNonEditor(game) {
234
+ for (const scene of game.scene.getScenes(false)) {
235
+ const key = scene.scene.key;
236
+ if (BOOT_SCENE_KEYS.has(key))
237
+ continue;
238
+ if (key === EDITOR_OVERLAY_KEY)
239
+ continue;
240
+ if (scene.scene.isActive())
241
+ continue;
242
+ scene.scene.setActive(true);
243
+ if (scene.physics)
244
+ scene.physics.resume();
245
+ scene.tweens.resumeAll();
246
+ scene.time.paused = false;
247
+ }
248
+ }
249
+ function exitEdit(game) {
250
+ if (!getEditorState(game).active)
251
+ return;
252
+ setEditorActive(game, false);
253
+ setSelection(game, null);
254
+ // Allow next enter to re-post the snapshot (scene file may have changed).
255
+ delete game[SNAPSHOT_POSTED_FLAG];
256
+ if (game.scene.isActive(EDITOR_OVERLAY_KEY)) {
257
+ game.scene.stop(EDITOR_OVERLAY_KEY);
258
+ }
259
+ // P1 infinite canvas — tear down editor cameras + void fill + restore
260
+ // game cam visibility + restore canvas scale + restore HUD scene
261
+ // visibility BEFORE resuming scenes so the first resumed frame
262
+ // renders through the game's real cameras at the game's intended
263
+ // canvas size.
264
+ uninstallVoidFill(game);
265
+ setTilemapGridsVisible(game, false);
266
+ uninstallEditorCameras(game);
267
+ restoreCanvasAfterEditor(game);
268
+ setHudVisibility(game, true);
269
+ resumeActiveNonEditor(game);
270
+ }
271
+ /**
272
+ * Slice 6 Phase B — toggle the tilemap entities' grid sketch visibility.
273
+ * The grid is an editor-only visualization aid (helps users see tile
274
+ * boundaries on empty / sparsely-painted tilemaps); it bleeds into Play
275
+ * mode if not toggled off. Walks every tilemap container in the world
276
+ * scene's registry and finds the tagged grid Graphics child.
277
+ */
278
+ function setTilemapGridsVisible(game, visible) {
279
+ const scene = findWorldScene(game);
280
+ if (!scene)
281
+ return;
282
+ const registry = getEntityRegistry(scene);
283
+ if (!registry)
284
+ return;
285
+ for (const go of registry.all()) {
286
+ if (go.getData('entityKind') !== 'tilemap')
287
+ continue;
288
+ const container = go;
289
+ for (const child of container.list) {
290
+ if (child.getData?.('unboxyTilemapGrid')) {
291
+ child.setVisible(visible);
292
+ }
293
+ }
294
+ }
295
+ }
296
+ /**
297
+ * Phase F — reset every animated tile to its root index across all
298
+ * tilemap entities in the active world scene. Called by `enterEdit` so
299
+ * the editor view always shows the authored (static) frame instead of
300
+ * whatever was currently visible when the user toggled into edit mode.
301
+ *
302
+ * Doesn't uninstall the per-frame UPDATE handler — scene pause prevents
303
+ * it from firing, and exitEdit lets it resume naturally on the next
304
+ * tick (cells re-swap from elapsed-time math).
305
+ */
306
+ function resetAllTilesetAnimations(game) {
307
+ const scene = findWorldScene(game);
308
+ if (!scene)
309
+ return;
310
+ const registry = getEntityRegistry(scene);
311
+ if (!registry)
312
+ return;
313
+ for (const go of registry.all()) {
314
+ if (go.getData('entityKind') !== 'tilemap')
315
+ continue;
316
+ resetTilesetAnimationsToRoot(go);
317
+ }
318
+ }
319
+ function buildOverlayInit(game) {
320
+ const sceneFile = readActiveSceneFile(game);
321
+ // Fallback worldBounds for pure scene-as-code games (no scene file).
322
+ // Use the snapshot's intrinsic game dims so the white outline +
323
+ // selection math still work (outline at (0, 0, gameW, gameH)).
324
+ const snap = game[CANVAS_SCALE_SNAPSHOT_KEY];
325
+ const wbW = sceneFile?.world.width ?? snap?.gameWidth;
326
+ const wbH = sceneFile?.world.height ?? snap?.gameHeight;
327
+ return {
328
+ worldBounds: wbW != null && wbH != null
329
+ ? { x: 0, y: 0, width: wbW, height: wbH }
330
+ : undefined,
331
+ hitTest: (worldX, worldY) => hitTest(game, worldX, worldY),
332
+ hitTestAll: (worldX, worldY) => hitTestAll(game, worldX, worldY),
333
+ postPick: (entityId, modifiers, prefabId) => postToHost({
334
+ type: 'umicat:editor:pickEntity',
335
+ entityId,
336
+ modifiers,
337
+ prefabId,
338
+ }),
339
+ postDragEnd: (entityId, before, after) => postToHost({
340
+ type: 'umicat:editor:dragEnd',
341
+ entityId,
342
+ before,
343
+ after,
344
+ }),
345
+ postShortcut: (action) => postToHost({ type: 'umicat:editor:shortcut', action }),
346
+ // P1 infinite canvas — route in-canvas pan/zoom through the same
347
+ // function host-driven `umicat:editor:panZoom` uses, so cap + mirror
348
+ // logic stays in one place.
349
+ applyPanZoom: (msg) => applyPanZoomToWorld(game, msg),
350
+ };
351
+ }
352
+ // --- Snapshot -------------------------------------------------------------
353
+ const SNAPSHOT_POSTED_FLAG = '__unboxyEditorSnapshotPostedFor';
354
+ function postSceneSnapshot(game) {
355
+ const manifest = readActiveManifest(game);
356
+ const sceneFile = readActiveSceneFile(game);
357
+ if (sceneFile) {
358
+ postToHost({
359
+ type: 'umicat:editor:sceneLoaded',
360
+ sceneId: sceneFile.id,
361
+ mode: 'world',
362
+ sceneFile,
363
+ manifest,
364
+ });
365
+ game[SNAPSHOT_POSTED_FLAG] = sceneFile.id;
366
+ }
367
+ // Also post the HUD scene if attached + loaded. The host stashes both
368
+ // snapshots so the World/HUD toggle is purely a UI switch — no fresh
369
+ // SDK round-trip needed when the user flips it.
370
+ const hudFile = findHudSceneFile(game);
371
+ if (hudFile) {
372
+ postToHost({
373
+ type: 'umicat:editor:sceneLoaded',
374
+ sceneId: hudFile.id,
375
+ mode: 'hud',
376
+ sceneFile: hudFile,
377
+ manifest,
378
+ });
379
+ }
380
+ // P0.3 — also post the rules snapshot so the Rules panel can render
381
+ // without a separate fetch. Rules live on `scene.cache.json` (one tree
382
+ // per game, not per-scene), populated by `preloadRules` in BootScene.
383
+ // Empty `{}` is the valid "game has no rules.json" state.
384
+ postRulesSnapshot(game);
385
+ // P1 infinite canvas — initial camera state so host renders the zoom
386
+ // indicator at the right value from the moment Edit opens.
387
+ postCameraState(game);
388
+ }
389
+ /**
390
+ * Post the rules snapshot once, finding any non-Boot scene with a cached
391
+ * rules tree. The host stashes this as its baseline for the Rules form.
392
+ *
393
+ * P0.3 — `umicat:editor:rulesLoaded`. Tolerates a game with no rules at
394
+ * all (posts `{}`).
395
+ */
396
+ function postRulesSnapshot(game) {
397
+ const scene = findWorldScene(game);
398
+ if (!scene)
399
+ return;
400
+ const rules = getRules(scene);
401
+ postToHost({ type: 'umicat:editor:rulesLoaded', rules });
402
+ }
403
+ // --- Selection rect (slice 4) ---------------------------------------------
404
+ //
405
+ // Emit the selected entity's DOM screen rect to the host so it can anchor
406
+ // the ✨ button + popover next to the entity. Coalesce multiple calls
407
+ // inside one RAF tick — Inspector spam (per-keystroke transform edits)
408
+ // would otherwise spam the postMessage channel.
409
+ const RECT_RAF_FLAG = '__unboxyEditorSelectionRectRafScheduled';
410
+ function postSelectionRect(game) {
411
+ const bag = game;
412
+ if (bag[RECT_RAF_FLAG])
413
+ return;
414
+ bag[RECT_RAF_FLAG] = true;
415
+ // requestAnimationFrame runs after Phaser's render → bounds reflect the
416
+ // freshly-applied transform. Guarantees one emit per frame max.
417
+ if (typeof requestAnimationFrame === 'function') {
418
+ requestAnimationFrame(() => {
419
+ bag[RECT_RAF_FLAG] = false;
420
+ doPostSelectionRect(game);
421
+ });
422
+ }
423
+ else {
424
+ bag[RECT_RAF_FLAG] = false;
425
+ doPostSelectionRect(game);
426
+ }
427
+ }
428
+ function doPostSelectionRect(game) {
429
+ const id = getSelection(game);
430
+ if (!id) {
431
+ postToHost({ type: 'umicat:editor:selectionRect', entityId: null, rect: null });
432
+ return;
433
+ }
434
+ const registry = findRegistry(game);
435
+ const go = registry?.byId(id);
436
+ if (!go) {
437
+ postToHost({ type: 'umicat:editor:selectionRect', entityId: id, rect: null });
438
+ return;
439
+ }
440
+ const rect = computeScreenRect(game, go);
441
+ postToHost({ type: 'umicat:editor:selectionRect', entityId: id, rect });
442
+ }
443
+ /**
444
+ * Compute the entity's DOM screen rect — viewport pixels relative to the
445
+ * iframe's top-left. The host adds the iframe's bounding rect to translate
446
+ * into page coords.
447
+ *
448
+ * Path: world coords → camera transform → canvas pixels → iframe pixels.
449
+ * Phaser's Scale.FIT may letterbox/pillarbox the canvas inside the iframe,
450
+ * so we factor in the canvas's parent offset too.
451
+ */
452
+ function computeScreenRect(game, go) {
453
+ // Pull entity-local bounds — same logic the editor uses for hit-test.
454
+ const hitW = go.getData('editorHitWidth');
455
+ const hitH = go.getData('editorHitHeight');
456
+ const positioned = go;
457
+ let entRect;
458
+ const liveBounds = readLiveTrackedBounds(go);
459
+ if (liveBounds) {
460
+ entRect = {
461
+ x: positioned.x + liveBounds.x,
462
+ y: positioned.y + liveBounds.y,
463
+ width: liveBounds.width,
464
+ height: liveBounds.height,
465
+ };
466
+ }
467
+ else if (typeof hitW === 'number' && typeof hitH === 'number') {
468
+ const offX = go.getData('editorHitOffsetX');
469
+ const offY = go.getData('editorHitOffsetY');
470
+ if (typeof offX === 'number' && typeof offY === 'number') {
471
+ entRect = {
472
+ x: positioned.x + offX,
473
+ y: positioned.y + offY,
474
+ width: hitW,
475
+ height: hitH,
476
+ };
477
+ }
478
+ else {
479
+ entRect = {
480
+ x: positioned.x - hitW / 2,
481
+ y: positioned.y - hitH / 2,
482
+ width: hitW,
483
+ height: hitH,
484
+ };
485
+ }
486
+ }
487
+ else {
488
+ const withBounds = go;
489
+ if (typeof withBounds.getBounds !== 'function')
490
+ return null;
491
+ const r = withBounds.getBounds();
492
+ entRect = { x: r.x, y: r.y, width: r.width, height: r.height };
493
+ }
494
+ // HUD mode — entity coords are already in canvas-pixel space (the HUD
495
+ // scene has identity camera). Skip the world→canvas camera transform.
496
+ let canvasX, canvasY, canvasW, canvasH;
497
+ if (getEditorMode(game) === 'hud') {
498
+ canvasX = entRect.x;
499
+ canvasY = entRect.y;
500
+ canvasW = entRect.width;
501
+ canvasH = entRect.height;
502
+ }
503
+ else {
504
+ // World mode — convert via the EDITOR camera (P1 infinite canvas).
505
+ // `cameras.main` is hidden during edit and its scroll/zoom never
506
+ // change while the user pans/zooms the editor — using it here pins
507
+ // the popover anchor to (0,0)/zoom=1 regardless of editor view,
508
+ // which is why the AI sparkle button only landed correctly at 100%
509
+ // / scroll(0,0). Editor cam IS what the user is looking through;
510
+ // its scroll/zoom track pan + pinch in real time, so popover
511
+ // follows the entity correctly. Falls back to cameras.main on the
512
+ // narrow boot-race window before editor cam is installed.
513
+ const cam = getEditorCamera(game) ?? (() => {
514
+ for (const scene of game.scene.getScenes(false)) {
515
+ const key = scene.scene.key;
516
+ if (BOOT_SCENE_KEYS.has(key))
517
+ continue;
518
+ if (key === EDITOR_OVERLAY_KEY)
519
+ continue;
520
+ if (key === UMICAT_HUD_SCENE_KEY)
521
+ continue;
522
+ if (getEntityRegistry(scene))
523
+ return scene.cameras.main;
524
+ }
525
+ return null;
526
+ })();
527
+ if (!cam)
528
+ return null;
529
+ // Include cam.x/cam.y — Phaser cam math is
530
+ // canvas_pixel = (world - scroll) * zoom + viewport_offset
531
+ // The previous formula dropped `viewport_offset`, which is fine
532
+ // when setViewport(0, 0, ...) but breaks if any code path shifts
533
+ // the viewport. Defense-in-depth: read what's actually there.
534
+ canvasX = (entRect.x - cam.scrollX) * cam.zoom + cam.x;
535
+ canvasY = (entRect.y - cam.scrollY) * cam.zoom + cam.y;
536
+ canvasW = entRect.width * cam.zoom;
537
+ canvasH = entRect.height * cam.zoom;
538
+ }
539
+ // Convert logical canvas pixels → CSS pixels using the canvas's actual
540
+ // displayed size (avoids depending on which direction Phaser's displayScale
541
+ // goes — we just measure it). Canvas may be letterboxed/pillarboxed inside
542
+ // the iframe under Scale.FIT; getBoundingClientRect gives both the size
543
+ // and the offset.
544
+ const canvas = game.canvas;
545
+ if (!canvas || typeof canvas.getBoundingClientRect !== 'function')
546
+ return null;
547
+ const cssRect = canvas.getBoundingClientRect();
548
+ const logicalW = game.scale.width;
549
+ const logicalH = game.scale.height;
550
+ if (!logicalW || !logicalH)
551
+ return null;
552
+ const sx = cssRect.width / logicalW;
553
+ const sy = cssRect.height / logicalH;
554
+ return {
555
+ x: cssRect.left + canvasX * sx,
556
+ y: cssRect.top + canvasY * sy,
557
+ width: canvasW * sx,
558
+ height: canvasH * sy,
559
+ };
560
+ }
561
+ function readActiveManifest(game) {
562
+ for (const scene of game.scene.getScenes(false)) {
563
+ const cache = scene.cache.json;
564
+ const m = cache.entries?.entries?.['umicat:manifest'];
565
+ if (m)
566
+ return m;
567
+ }
568
+ return undefined;
569
+ }
570
+ function hasPostedSnapshot(game) {
571
+ return !!game[SNAPSHOT_POSTED_FLAG];
572
+ }
573
+ function readActiveSceneFile(game) {
574
+ // The active world scene cached its scene file in Phaser's JSON cache via
575
+ // SceneLoader. Pull it from there. We pick the first non-Boot scene we
576
+ // find with a JSON entry matching `umicat:scene:<id>`.
577
+ for (const scene of game.scene.getScenes(false)) {
578
+ const key = scene.scene.key;
579
+ if (BOOT_SCENE_KEYS.has(key))
580
+ continue;
581
+ if (key === EDITOR_OVERLAY_KEY)
582
+ continue;
583
+ // Scan cache for any 'umicat:scene:*' entry. There's typically one.
584
+ const cache = scene.cache.json;
585
+ const entries = cache.entries?.entries ?? {};
586
+ for (const cacheKey of Object.keys(entries)) {
587
+ if (cacheKey.startsWith('umicat:scene:')) {
588
+ const candidate = entries[cacheKey];
589
+ if (isWorldScene(candidate))
590
+ return candidate;
591
+ }
592
+ }
593
+ }
594
+ return null;
595
+ }
596
+ function isWorldScene(v) {
597
+ return !!v && typeof v === 'object' && v.type === 'world';
598
+ }
599
+ // --- Hit test -------------------------------------------------------------
600
+ function hitTest(game, worldX, worldY) {
601
+ const registry = findRegistry(game);
602
+ if (!registry)
603
+ return null;
604
+ let topmost = null;
605
+ let topmostDepth = -Infinity;
606
+ for (const go of registry.all()) {
607
+ const r = entityHitRect(go);
608
+ if (!r)
609
+ continue;
610
+ if (worldX < r.x || worldX > r.x + r.width)
611
+ continue;
612
+ if (worldY < r.y || worldY > r.y + r.height)
613
+ continue;
614
+ const withDepth = go;
615
+ const depth = typeof withDepth.depth === 'number' ? withDepth.depth : 0;
616
+ if (depth >= topmostDepth) {
617
+ topmostDepth = depth;
618
+ topmost = go;
619
+ }
620
+ }
621
+ return topmost;
622
+ }
623
+ /**
624
+ * FB.9a — return ALL entities at the given world coords, sorted by depth
625
+ * DESC (topmost first). Used by EditorOverlayScene's Alt+click handler
626
+ * to cycle through overlapping entities. When no Alt held, the regular
627
+ * hitTest (returning just topmost) handles normal selection.
628
+ */
629
+ export function hitTestAll(game, worldX, worldY) {
630
+ const registry = findRegistry(game);
631
+ if (!registry)
632
+ return [];
633
+ const hits = [];
634
+ for (const go of registry.all()) {
635
+ const r = entityHitRect(go);
636
+ if (!r)
637
+ continue;
638
+ if (worldX < r.x || worldX > r.x + r.width)
639
+ continue;
640
+ if (worldY < r.y || worldY > r.y + r.height)
641
+ continue;
642
+ const withDepth = go;
643
+ const depth = typeof withDepth.depth === 'number' ? withDepth.depth : 0;
644
+ hits.push({ go, depth });
645
+ }
646
+ hits.sort((a, b) => b.depth - a.depth);
647
+ return hits.map((h) => h.go);
648
+ }
649
+ /**
650
+ * Compute a hit-test rectangle for a spawned entity in world coords.
651
+ *
652
+ * 1. If the entity has `editorHitWidth` / `editorHitHeight` set in data
653
+ * (code-rendered entities — Graphics has no intrinsic bounds), use
654
+ * those centered on the entity's x/y.
655
+ * 2. Otherwise fall back to Phaser's `getBounds()` (works for Sprite,
656
+ * Rectangle, Arc, Container — anything with a Size component).
657
+ *
658
+ * Returns null if neither path yields a usable rect.
659
+ */
660
+ function entityHitRect(go) {
661
+ // Live tracker query (0.2.97) — for code-rendered entities, the
662
+ // Graphics tracker's getter is stashed on data and reflects the
663
+ // LATEST draw extent (after every redraw the game tick triggered).
664
+ // Without this lookup, hit-test used stale data set once at spawn
665
+ // — fine for static visuals but wrong for procedural ones (snake
666
+ // growing, tilemap painting, dynamic UI). The getter is cheap
667
+ // (just reads 4 numbers), so calling it per hit-test is fine.
668
+ const positioned = go;
669
+ const liveBounds = readLiveTrackedBounds(go);
670
+ if (liveBounds) {
671
+ return {
672
+ x: positioned.x + liveBounds.x,
673
+ y: positioned.y + liveBounds.y,
674
+ width: liveBounds.width,
675
+ height: liveBounds.height,
676
+ };
677
+ }
678
+ const hitW = go.getData('editorHitWidth');
679
+ const hitH = go.getData('editorHitHeight');
680
+ if (typeof hitW === 'number' && typeof hitH === 'number') {
681
+ // editorHitOffsetX/Y (0.2.96) — relative top-left of the drawn
682
+ // area when offset is known (set by `applyTrackedHitArea` at
683
+ // spawn). Falls back to "centered on transform" for legacy
684
+ // entities (sprite/primitive/hud widgets that don't run through
685
+ // the tracker).
686
+ const offX = go.getData('editorHitOffsetX');
687
+ const offY = go.getData('editorHitOffsetY');
688
+ if (typeof offX === 'number' && typeof offY === 'number') {
689
+ return {
690
+ x: positioned.x + offX,
691
+ y: positioned.y + offY,
692
+ width: hitW,
693
+ height: hitH,
694
+ };
695
+ }
696
+ return {
697
+ x: positioned.x - hitW / 2,
698
+ y: positioned.y - hitH / 2,
699
+ width: hitW,
700
+ height: hitH,
701
+ };
702
+ }
703
+ const withBounds = go;
704
+ if (typeof withBounds.getBounds !== 'function')
705
+ return null;
706
+ const r = withBounds.getBounds();
707
+ if (r.width === 0 && r.height === 0)
708
+ return null;
709
+ return { x: r.x, y: r.y, width: r.width, height: r.height };
710
+ }
711
+ /**
712
+ * Read live bounds from the Graphics tracker getter stashed on the GO's
713
+ * data manager by `createCodeRendered` (0.2.96). Returns null when the
714
+ * entity isn't code-rendered (no getter stashed) or when nothing has
715
+ * been drawn since the last `clear()`. Used by hit-test + selection
716
+ * rect + popover anchor so they all see the most recent draw extent.
717
+ */
718
+ function readLiveTrackedBounds(go) {
719
+ const getter = go.getData('__unboxyGraphicsBoundsGetter');
720
+ if (typeof getter !== 'function')
721
+ return null;
722
+ return getter();
723
+ }
724
+ /**
725
+ * Find the entity registry for the current editor mode. World mode picks the
726
+ * first non-Boot non-Editor scene with a registry; HUD mode picks the
727
+ * HUD scene's registry. Slice 5+.
728
+ */
729
+ function findRegistry(game) {
730
+ if (getEditorMode(game) === 'hud')
731
+ return findHudRegistry(game);
732
+ for (const scene of game.scene.getScenes(false)) {
733
+ const key = scene.scene.key;
734
+ if (key === UMICAT_HUD_SCENE_KEY)
735
+ continue;
736
+ const reg = getEntityRegistry(scene);
737
+ if (reg)
738
+ return reg;
739
+ }
740
+ return undefined;
741
+ }
742
+ // --- applyEdit ------------------------------------------------------------
743
+ async function applyEdit(game, entityId, patch, manifestAsset) {
744
+ // Lazy-load + manifest-upsert path. When the host's Inspector picks a Bg
745
+ // image asset that the iframe never preloaded (e.g. a per-game upload that
746
+ // was never dragged into the scene) OR an asset whose `kind` just flipped
747
+ // (e.g. region-atlas — slice 10 — flipping from 'image' to 'atlas'), the
748
+ // host pipelines the new manifest entry along with the patch so the
749
+ // resulting re-spawn sees a consistent runtime state. Without this, the
750
+ // re-spawn reads the stale cached manifest and falls back to the
751
+ // colored-rect placeholder.
752
+ if (manifestAsset) {
753
+ const targetScene = getEditorMode(game) === 'hud'
754
+ ? game.scene.getScene(UMICAT_HUD_SCENE_KEY)
755
+ : findWorldScene(game);
756
+ if (targetScene) {
757
+ try {
758
+ upsertCachedManifestAsset(targetScene, manifestAsset);
759
+ await ensureAssetLoaded(targetScene, manifestAsset);
760
+ }
761
+ catch (e) {
762
+ console.warn('[umicat/editor] applyEdit asset upsert/load failed:', e);
763
+ // Fall through — applyHudPatch will render its placeholder.
764
+ }
765
+ }
766
+ }
767
+ // HUD mode — patches modify anchor / HUD field values. World-style transform
768
+ // patches don't apply to HUD widgets, so we dispatch through a HUD-specific
769
+ // helper. SDK 0.3.0: render fields live at the patch root, so we forward
770
+ // them under `fields` (the HUD helper's contract).
771
+ if (getEditorMode(game) === 'hud') {
772
+ applyHudPatch(game, entityId, {
773
+ anchor: patch.anchor,
774
+ layer: patch.layer,
775
+ z: patch.z,
776
+ fields: extractRenderFields(patch),
777
+ role: patch.role,
778
+ properties: patch.properties,
779
+ });
780
+ if (getSelection(game) === entityId)
781
+ postSelectionRect(game);
782
+ return;
783
+ }
784
+ const registry = findRegistry(game);
785
+ if (!registry)
786
+ return;
787
+ const go = registry.byId(entityId);
788
+ if (!go)
789
+ return;
790
+ applyTransformPatch(go, patch.transform);
791
+ applyVisualPatch(go, patch);
792
+ applyCodeRenderedParamsPatch(game, go, patch.params);
793
+ if (patch.role !== undefined) {
794
+ if (patch.role === null)
795
+ go.setData('entityRole', undefined);
796
+ else
797
+ go.setData('entityRole', patch.role);
798
+ }
799
+ if (patch.properties !== undefined) {
800
+ go.setData('entityProperties', patch.properties);
801
+ }
802
+ // If this edit moved the selected entity, the host's ✨ button + popover
803
+ // anchor needs the new rect. Cheap RAF-coalesced.
804
+ if (getSelection(game) === entityId)
805
+ postSelectionRect(game);
806
+ }
807
+ // --- Slice 8: asset-level live update ------------------------------------
808
+ /**
809
+ * Handle an `umicat:editor:assetUpdate` postMessage — used by the Hitbox
810
+ * Editor to push freshly-saved hitbox / depthAnchor metadata into the
811
+ * running iframe without a scene reload.
812
+ *
813
+ * Flow:
814
+ * 1. Upsert the asset into the iframe's cached manifest (so future spawns
815
+ * see the new metadata).
816
+ * 2. Lazy-load texture if it isn't cached yet (defensive — Hitbox Editor
817
+ * is typically opened on already-loaded assets, but the same code path
818
+ * is reused if a user edits hitbox on an asset that was never in this
819
+ * scene's manifest).
820
+ * 3. Walk the entity registry, find every sprite with `entityAssetId`
821
+ * matching the updated asset's id, and re-apply `setOrigin` (from new
822
+ * depthAnchor) + `applyAssetHitbox` (which is idempotent and re-installs
823
+ * the per-frame listener cleanly).
824
+ *
825
+ * HUD-mode is a no-op — HUD widgets don't carry world hitboxes / depthAnchors.
826
+ * The Hitbox Editor is launched only on world-scene assets.
827
+ */
828
+ async function handleAssetUpdate(game, asset) {
829
+ if (getEditorMode(game) === 'hud')
830
+ return;
831
+ const scene = findWorldScene(game);
832
+ if (!scene)
833
+ return;
834
+ try {
835
+ upsertCachedManifestAsset(scene, asset);
836
+ await ensureAssetLoaded(scene, asset);
837
+ }
838
+ catch (e) {
839
+ console.warn('[umicat/editor] assetUpdate upsert/load failed:', e);
840
+ return;
841
+ }
842
+ const registry = findRegistry(game);
843
+ if (!registry)
844
+ return;
845
+ for (const go of registry.all()) {
846
+ if (go.getData('entityAssetId') !== asset.id)
847
+ continue;
848
+ const sprite = go;
849
+ // Re-apply origin from new depthAnchor (no-op if anchor unset; spawn-time
850
+ // default origin of 0.5/0.5 stays). Cleared anchor reverts to center.
851
+ if (asset.depthAnchor) {
852
+ const frameW = sprite.width || 1;
853
+ const frameH = sprite.height || 1;
854
+ sprite.setOrigin?.(asset.depthAnchor.x / frameW, asset.depthAnchor.y / frameH);
855
+ }
856
+ else if (typeof sprite.setOrigin === 'function') {
857
+ sprite.setOrigin(0.5, 0.5);
858
+ }
859
+ // Re-apply hitbox. Idempotent — tears down the prior per-frame listener
860
+ // before installing the new one. Silent no-op when sprite has no body.
861
+ applyAssetHitbox(sprite, asset);
862
+ }
863
+ // Slice 6 Phase C — re-apply per-tile metadata + auto-collision on every
864
+ // tilemap layer whose layer.tilesetIds[0] matches this asset. Without
865
+ // this, a Tile Metadata Editor save only takes effect after a workspace
866
+ // rebuild / scene reload. Walks every tilemap entity, finds matching
867
+ // layers (via the GO's `entityKind === 'tilemap'` tag + the layer's
868
+ // `tilemapEntityId` data), then re-runs the same metadata wire that
869
+ // initial scene load uses.
870
+ for (const go of registry.all()) {
871
+ if (go.getData('entityKind') !== 'tilemap')
872
+ continue;
873
+ const container = go;
874
+ const layers = container.getData('tilemapLayers') ?? [];
875
+ for (const layer of layers) {
876
+ // Look up which tileset this layer was created against — stashed on
877
+ // the layer's data manager. Cheap match — `layer.tileset[0]` is the
878
+ // Phaser Tileset object, not the asset id, so we route through the
879
+ // host-stashed id.
880
+ const layerTilesetId = layer.getData('tilemapTilesetId');
881
+ if (!layerTilesetId || layerTilesetId !== asset.id)
882
+ continue;
883
+ const tilesetPhaser = layer.tileset[0];
884
+ if (!tilesetPhaser)
885
+ continue;
886
+ applyTilesetTileMetadata(tilesetPhaser, layer, asset);
887
+ // Slice 6 Phase D — autotile rule-map edits invalidate the cached
888
+ // vertex grid so the next paint reverse-derives against the new
889
+ // ruleMap. Without this, the AutotileEditorModal's save shows in
890
+ // the palette but new paint clicks still cascade against the
891
+ // pre-save ruleMap (vertex grid is keyed by terrain id but holds
892
+ // bits derived from old indices).
893
+ invalidateAutotileCells(layer);
894
+ // Slice 6 Phase F — re-arm tile animations against the new
895
+ // animations metadata. Tears down the previous UPDATE handler +
896
+ // re-scans cells; new animations defined via the Animation Editor
897
+ // start animating live without a scene reload.
898
+ applyTilesetAnimations(layer, asset);
899
+ }
900
+ }
901
+ }
902
+ // --- Slice 8: debug overlay toggle ---------------------------------------
903
+ /**
904
+ * Forward the "Show hitboxes" toggle into the editor overlay scene. The
905
+ * overlay reads this flag each frame from the editor state attached to the
906
+ * Phaser game (see EditorState.setDebugOverlay).
907
+ */
908
+ function setDebugOverlay(game, state) {
909
+ setDebugOverlayState(game, state);
910
+ // Mirror the toggle into Phaser's Arcade physics debug so the same
911
+ // "Show hitboxes" control works in BOTH modes:
912
+ // - Edit mode: EditorOverlayScene reads `debugOverlay.showHitboxes`
913
+ // each frame and draws authored hitbox metadata (slice 8 asset
914
+ // hitboxes + slice 6 tile collision rects).
915
+ // - Play mode: Phaser draws the actual Arcade body rects via the
916
+ // world's debugGraphic. Useful for verifying that `body.setSize` /
917
+ // `addTilemapCollider` / `setCollideWorldBounds` are wired
918
+ // correctly at runtime.
919
+ // The two layers can show simultaneously when the user toggles ON
920
+ // during edit and then clicks Play — both layers visualize "what
921
+ // counts as solid", just from different sources (authored data vs.
922
+ // engine state). They don't conflict.
923
+ for (const scene of game.scene.getScenes(false)) {
924
+ const key = scene.scene.key;
925
+ if (key === EDITOR_OVERLAY_KEY)
926
+ continue;
927
+ if (key === UMICAT_HUD_SCENE_KEY)
928
+ continue;
929
+ if (key === 'BootScene' || key === 'Boot')
930
+ continue;
931
+ const world = scene
932
+ .physics?.world;
933
+ if (!world)
934
+ continue;
935
+ world.drawDebug = state.showHitboxes;
936
+ if (state.showHitboxes) {
937
+ // createDebugGraphic is idempotent-ish — Phaser stores it on
938
+ // world.debugGraphic; calling twice creates a second graphic.
939
+ // Guard so a re-fire doesn't stack.
940
+ if (!world.debugGraphic) {
941
+ world.createDebugGraphic();
942
+ }
943
+ }
944
+ else if (world.debugGraphic) {
945
+ world.debugGraphic.clear();
946
+ }
947
+ }
948
+ }
949
+ /**
950
+ * Re-render a code-rendered entity's visual when its params change.
951
+ * Slice 3.5 — Inspector params editor produces these patches.
952
+ *
953
+ * No-op for non-code-rendered entities (no renderScriptPath in data) so
954
+ * sprite/primitive patches that incidentally include `visual.params` (the
955
+ * type allows it) flow through harmlessly.
956
+ */
957
+ function applyCodeRenderedParamsPatch(game, go, newParams) {
958
+ if (newParams === undefined)
959
+ return;
960
+ const scriptPath = go.getData('renderScriptPath');
961
+ if (!scriptPath)
962
+ return; // not a code-rendered entity
963
+ const render = resolveRenderScript(game, scriptPath);
964
+ if (!render)
965
+ return;
966
+ const g = go;
967
+ render(g, newParams);
968
+ // Re-read tracker bounds after re-render — params changes can grow
969
+ // or shrink the drawn area. Falls back to existing hit dims when
970
+ // tracker has no data (script didn't call any tracked methods).
971
+ const fallbackW = go.getData('editorHitWidth') ?? 64;
972
+ const fallbackH = go.getData('editorHitHeight') ?? 64;
973
+ applyTrackedHitArea(g, fallbackW, fallbackH);
974
+ go.setData('renderScriptParams', newParams);
975
+ }
976
+ function applyTransformPatch(go, t) {
977
+ if (!t)
978
+ return;
979
+ const target = go;
980
+ if (typeof t.x === 'number')
981
+ target.x = t.x;
982
+ if (typeof t.y === 'number')
983
+ target.y = t.y;
984
+ if (typeof t.rotation === 'number')
985
+ target.rotation = t.rotation;
986
+ if (typeof t.scaleX === 'number')
987
+ target.scaleX = t.scaleX;
988
+ if (typeof t.scaleY === 'number')
989
+ target.scaleY = t.scaleY;
990
+ if (typeof t.depth === 'number' && target.setDepth)
991
+ target.setDepth(t.depth);
992
+ }
993
+ /**
994
+ * Pull every "render field" off a flat `EditorEntityPatch` for forwarding
995
+ * to `applyHudPatch.fields`. Anything not on this allowlist (transform /
996
+ * anchor / layer / z / role / properties) flows through HUD's own slots.
997
+ */
998
+ function extractRenderFields(patch) {
999
+ const fields = {};
1000
+ const keys = [
1001
+ 'tint', 'alpha', 'flipX', 'flipY', 'frame',
1002
+ 'width', 'height', 'radius', 'fillColor', 'strokeColor', 'strokeWidth',
1003
+ 'params', 'source', 'fontFamily', 'fontSize', 'color', 'align',
1004
+ 'assetId', 'iconAssetId', 'backgroundAssetId', 'backgroundFrame', 'backgroundRegion',
1005
+ 'label', 'shape', 'pressedFillColor', 'textColor',
1006
+ 'value', 'max', 'backgroundColor', 'backgroundAlpha',
1007
+ 'borderColor', 'borderWidth', 'borderRadius',
1008
+ ];
1009
+ let any = false;
1010
+ for (const k of keys) {
1011
+ const v = patch[k];
1012
+ if (v !== undefined) {
1013
+ fields[k] = v;
1014
+ any = true;
1015
+ }
1016
+ }
1017
+ return any ? fields : undefined;
1018
+ }
1019
+ /**
1020
+ * Apply the flat render fields from an `EditorEntityPatch` to a world
1021
+ * entity's GameObject. SDK 0.3.0 — fields live at the patch root, not
1022
+ * nested under `visual`.
1023
+ */
1024
+ function applyVisualPatch(go, v) {
1025
+ if (!v)
1026
+ return;
1027
+ const target = go;
1028
+ if (v.tint !== undefined) {
1029
+ if (v.tint === null)
1030
+ target.clearTint?.();
1031
+ else
1032
+ target.setTint?.(parseColor(v.tint));
1033
+ }
1034
+ if (typeof v.alpha === 'number')
1035
+ target.setAlpha?.(v.alpha);
1036
+ if (typeof v.flipX === 'boolean')
1037
+ target.setFlipX?.(v.flipX);
1038
+ if (typeof v.flipY === 'boolean')
1039
+ target.setFlipY?.(v.flipY);
1040
+ if (v.frame !== undefined)
1041
+ target.setFrame?.(v.frame);
1042
+ if (typeof v.width === 'number' && typeof v.height === 'number') {
1043
+ target.setSize?.(v.width, v.height);
1044
+ }
1045
+ if (typeof v.radius === 'number') {
1046
+ // Phaser's Arc doesn't expose setRadius reliably; mutate + redraw.
1047
+ if ('radius' in target)
1048
+ target.radius = v.radius;
1049
+ }
1050
+ if (v.fillColor !== undefined) {
1051
+ target.setFillStyle?.(parseColor(v.fillColor));
1052
+ }
1053
+ if (v.strokeColor !== undefined && typeof v.strokeWidth === 'number') {
1054
+ if (v.strokeColor === null) {
1055
+ // Phaser doesn't expose a clearStroke; setting width=0 + black is the
1056
+ // closest approximation.
1057
+ target.setStrokeStyle?.(0, 0);
1058
+ }
1059
+ else {
1060
+ target.setStrokeStyle?.(v.strokeWidth, parseColor(v.strokeColor));
1061
+ }
1062
+ }
1063
+ }
1064
+ // --- Create / delete ------------------------------------------------------
1065
+ /**
1066
+ * Spawn a new entity into the active world scene. Slice 3.
1067
+ *
1068
+ * If the entity references an asset whose texture isn't yet in the Phaser
1069
+ * cache, we lazy-load it before spawn — same pattern as SceneLoader's
1070
+ * preloadSceneAssets, but on a single asset and at edit time. Without this
1071
+ * the host would have to coordinate a build before showing a dropped
1072
+ * sprite, which defeats the point of drag-to-place.
1073
+ */
1074
+ async function createEntity(game, entity, manifestAsset) {
1075
+ // HUD mode dispatches to the HUD-runtime spawner (slice 5). The host
1076
+ // sends HudEntity records (not WorldEntity), so we type-erase.
1077
+ if (getEditorMode(game) === 'hud') {
1078
+ // Lazy-load asset if the HUD widget needs one and it's not in cache yet.
1079
+ const hud = game.scene.getScene(UMICAT_HUD_SCENE_KEY);
1080
+ if (manifestAsset && hud && !hud.textures.exists(manifestAsset.textureKey)) {
1081
+ await loadAssetIntoScene(hud, manifestAsset);
1082
+ }
1083
+ createHudEntityInScene(game, entity);
1084
+ return;
1085
+ }
1086
+ const scene = findWorldScene(game);
1087
+ if (!scene) {
1088
+ console.warn('[umicat/editor] createEntity: no world scene to spawn into');
1089
+ return;
1090
+ }
1091
+ const registry = getEntityRegistry(scene);
1092
+ if (!registry) {
1093
+ console.warn('[umicat/editor] createEntity: world scene has no entity registry');
1094
+ return;
1095
+ }
1096
+ // Always upsert the manifestAsset into the scene's cached manifest so
1097
+ // resolveAsset / re-spawns / subsequent layer adds find a consistent
1098
+ // entry. Mirrors the addLayer + assetUpdate paths.
1099
+ if (manifestAsset) {
1100
+ upsertCachedManifestAsset(scene, manifestAsset);
1101
+ }
1102
+ // Collect every asset whose texture this entity needs lazy-loaded.
1103
+ // - sprite: one asset (entity.assetId, or manifestAsset).
1104
+ // - tilemap: one per layer's tileset.
1105
+ // - other kinds (rect / circle / code-rendered / trigger): no asset.
1106
+ const assetsToLoad = [];
1107
+ const sceneRef = scene; // local non-nullable for closures below
1108
+ function queueIfMissing(a) {
1109
+ if (!a)
1110
+ return;
1111
+ if (sceneRef.textures.exists(a.textureKey))
1112
+ return;
1113
+ if (assetsToLoad.find((x) => x.id === a.id))
1114
+ return;
1115
+ assetsToLoad.push(a);
1116
+ }
1117
+ function resolveById(id) {
1118
+ if (manifestAsset && manifestAsset.id === id)
1119
+ return manifestAsset;
1120
+ try {
1121
+ const manifest = getManifest(sceneRef);
1122
+ return manifest.assets.find((a) => a.id === id);
1123
+ }
1124
+ catch {
1125
+ return undefined;
1126
+ }
1127
+ }
1128
+ if (entity.kind === 'sprite') {
1129
+ let a = manifestAsset;
1130
+ if (!a) {
1131
+ const assetId = entity.assetId;
1132
+ if (assetId)
1133
+ a = resolveById(assetId);
1134
+ }
1135
+ queueIfMissing(a);
1136
+ }
1137
+ else if (entity.kind === 'tilemap') {
1138
+ const tilemap = entity;
1139
+ for (const layer of tilemap.layers ?? []) {
1140
+ for (const tid of layer.tilesetIds ?? []) {
1141
+ queueIfMissing(resolveById(tid));
1142
+ }
1143
+ }
1144
+ }
1145
+ for (const a of assetsToLoad) {
1146
+ await loadAssetIntoScene(scene, a);
1147
+ }
1148
+ const ctx = {
1149
+ scene,
1150
+ registry,
1151
+ resolveAsset: (id) => {
1152
+ if (manifestAsset && manifestAsset.id === id)
1153
+ return manifestAsset;
1154
+ // Fallback: look up in the manifest cache. Covers re-drag of an
1155
+ // already-in-manifest asset where the host omitted manifestAsset.
1156
+ try {
1157
+ const manifest = getManifest(scene);
1158
+ const found = manifest.assets.find((a) => a.id === id);
1159
+ if (found)
1160
+ return found;
1161
+ }
1162
+ catch {
1163
+ /* fall through to throw */
1164
+ }
1165
+ throw new Error(`[umicat/editor] createEntity: asset '${id}' not found in manifest`);
1166
+ },
1167
+ resolveRenderScript: undefined,
1168
+ };
1169
+ try {
1170
+ spawnEntity(ctx, entity);
1171
+ }
1172
+ catch (e) {
1173
+ console.warn('[umicat/editor] spawnEntity failed:', e);
1174
+ }
1175
+ // Tilemap grids are created hidden (Play mode default); enterEdit walks
1176
+ // existing tilemaps once and toggles them visible. New tilemaps dropped
1177
+ // AFTER enterEdit miss that walk and stay invisible — an empty tilemap
1178
+ // with hidden grid renders nothing, so the user sees nothing on the
1179
+ // canvas + thinks the drop failed. Re-run the visibility walk after
1180
+ // every editor-driven spawn so newly-dropped tilemaps show their bounds.
1181
+ if (entity.kind === 'tilemap') {
1182
+ setTilemapGridsVisible(game, true);
1183
+ }
1184
+ }
1185
+ /**
1186
+ * Merge an asset record into the scene's cached manifest (the one
1187
+ * {@code getManifest(scene)} returns). Used by applyEdit's pipelined-asset
1188
+ * path so subsequent re-spawns see the updated entry (kind / atlasPath /
1189
+ * atlasFormat / ninePatch / etc.) without a full scene reload.
1190
+ *
1191
+ * <p>The manifest object lives in `scene.cache.json` and is shared by
1192
+ * reference — mutating its `assets[]` is sufficient. We replace in place
1193
+ * when an entry with the same id exists (so a stale {@code kind: 'image'}
1194
+ * gets fully overwritten by a fresh {@code kind: 'atlas'} record).
1195
+ */
1196
+ function upsertCachedManifestAsset(scene, asset) {
1197
+ let manifest;
1198
+ try {
1199
+ manifest = getManifest(scene);
1200
+ }
1201
+ catch {
1202
+ return; // manifest not in cache — nothing to merge against
1203
+ }
1204
+ if (!manifest.assets)
1205
+ manifest.assets = [];
1206
+ const idx = manifest.assets.findIndex((a) => a.id === asset.id);
1207
+ if (idx >= 0)
1208
+ manifest.assets[idx] = asset;
1209
+ else
1210
+ manifest.assets.push(asset);
1211
+ }
1212
+ /**
1213
+ * Idempotent asset-loaded check: returns immediately if the texture is in
1214
+ * the cache. For atlas-format assets, ALSO ensures the per-frame ninePatch
1215
+ * sidecar JSON is loaded so the SDK's region-9-slice path can read it back.
1216
+ */
1217
+ function ensureAssetLoaded(scene, asset) {
1218
+ if (asset.kind === 'audio')
1219
+ return Promise.resolve();
1220
+ const needsTexture = !scene.textures.exists(asset.textureKey);
1221
+ const sidecarKey = `umicat:atlas-ninepatch:${asset.textureKey}`;
1222
+ const needsSidecar = asset.kind === 'atlas' &&
1223
+ asset.atlasFormat === 'json' &&
1224
+ !!asset.atlasPath &&
1225
+ !scene.cache.json.exists(sidecarKey);
1226
+ if (!needsTexture && !needsSidecar)
1227
+ return Promise.resolve();
1228
+ return loadAssetIntoScene(scene, asset);
1229
+ }
1230
+ /**
1231
+ * Pick the URL to load asset bytes from. Drag-to-place uses the CDN
1232
+ * URL directly because the asset bytes aren't yet in the workspace's
1233
+ * `public/uploaded/` (they only get synced on SAVE by session-server's
1234
+ * `syncWorkspaceAssetsFromManifest`). Without this, the iframe's
1235
+ * lazy-load 404s for fresh drops, the texture never lands in
1236
+ * `scene.textures`, and `add.sprite(...)` falls back to Phaser's empty
1237
+ * `__DEFAULT` texture — visible only as the editor's selection rect
1238
+ * outline with no sprite content inside. `loadUrl` is an off-record
1239
+ * field set by `EditorBridge.createEntity`'s `manifestAsset` (home-ui
1240
+ * passes `AssetSummary.url`); not part of the persisted manifest
1241
+ * schema (which keeps `path: uploaded/<filename>` for build-time
1242
+ * correctness — session-server syncs bytes there on save).
1243
+ */
1244
+ function pickLoadUrl(asset) {
1245
+ const loadUrl = asset.loadUrl;
1246
+ return loadUrl || asset.path;
1247
+ }
1248
+ function loadAssetIntoScene(scene, asset) {
1249
+ return new Promise((resolve, reject) => {
1250
+ if (scene.textures.exists(asset.textureKey)) {
1251
+ resolve();
1252
+ return;
1253
+ }
1254
+ const onComplete = () => {
1255
+ cleanup();
1256
+ resolve();
1257
+ };
1258
+ const onError = (file) => {
1259
+ if (file.key !== asset.textureKey)
1260
+ return;
1261
+ cleanup();
1262
+ reject(new Error(`failed to load asset ${asset.id}: ${file.url}`));
1263
+ };
1264
+ const cleanup = () => {
1265
+ scene.load.off(Phaser.Loader.Events.FILE_COMPLETE, perFileComplete);
1266
+ scene.load.off(Phaser.Loader.Events.FILE_LOAD_ERROR, onError);
1267
+ scene.load.off(Phaser.Loader.Events.COMPLETE, onComplete);
1268
+ };
1269
+ const perFileComplete = (key) => {
1270
+ if (key === asset.textureKey) {
1271
+ cleanup();
1272
+ resolve();
1273
+ }
1274
+ };
1275
+ scene.load.on(Phaser.Loader.Events.FILE_COMPLETE, perFileComplete);
1276
+ scene.load.on(Phaser.Loader.Events.FILE_LOAD_ERROR, onError);
1277
+ scene.load.on(Phaser.Loader.Events.COMPLETE, onComplete);
1278
+ const loadFrom = pickLoadUrl(asset);
1279
+ switch (asset.kind) {
1280
+ case 'image':
1281
+ scene.load.image(asset.textureKey, loadFrom);
1282
+ break;
1283
+ case 'spritesheet':
1284
+ if (asset.spriteSheetConfig) {
1285
+ scene.load.spritesheet(asset.textureKey, loadFrom, asset.spriteSheetConfig);
1286
+ }
1287
+ else {
1288
+ scene.load.image(asset.textureKey, loadFrom);
1289
+ }
1290
+ break;
1291
+ case 'atlas':
1292
+ if (asset.atlasPath && asset.atlasFormat === 'xml') {
1293
+ scene.load.atlasXML(asset.textureKey, loadFrom, asset.atlasPath);
1294
+ }
1295
+ else if (asset.atlasPath) {
1296
+ scene.load.atlas(asset.textureKey, loadFrom, asset.atlasPath);
1297
+ // Also fetch the raw JSON into the side-cache for per-region
1298
+ // ninePatch lookups (slice 10). Phaser's atlas parser drops
1299
+ // unknown per-frame fields, so we keep the original JSON around.
1300
+ const sidecarKey = `umicat:atlas-ninepatch:${asset.textureKey}`;
1301
+ if (!scene.cache.json.exists(sidecarKey)) {
1302
+ scene.load.json(sidecarKey, asset.atlasPath);
1303
+ }
1304
+ }
1305
+ break;
1306
+ case 'audio':
1307
+ scene.load.audio(asset.textureKey, loadFrom);
1308
+ break;
1309
+ default:
1310
+ cleanup();
1311
+ reject(new Error(`unsupported asset kind for editor lazy-load: ${asset.kind}`));
1312
+ return;
1313
+ }
1314
+ if (!scene.load.isLoading())
1315
+ scene.load.start();
1316
+ });
1317
+ }
1318
+ function deleteEntity(game, entityId) {
1319
+ // HUD mode — dispatch to the HUD-scene helper, which destroys + unregisters
1320
+ // + drops the entity from the cached scene file.
1321
+ if (getEditorMode(game) === 'hud') {
1322
+ deleteHudEntityFromScene(game, entityId);
1323
+ if (getSelection(game) === entityId)
1324
+ setSelection(game, null);
1325
+ return;
1326
+ }
1327
+ const scene = findWorldScene(game);
1328
+ if (!scene)
1329
+ return;
1330
+ const registry = getEntityRegistry(scene);
1331
+ if (!registry)
1332
+ return;
1333
+ const go = registry.byId(entityId);
1334
+ if (!go)
1335
+ return;
1336
+ // Slice 6 Phase B — tilemap entities have associated TilemapLayer
1337
+ // GameObjects in scene ROOT (not Container children, due to the Phaser
1338
+ // Container/TilemapLayer transform quirk). Destroying the Container
1339
+ // alone orphans those layers — they stay visible in the canvas until
1340
+ // the next scene reload. Walk the container's stashed layer refs and
1341
+ // destroy each before destroying the container itself. Also destroys
1342
+ // the sub-tile static body group (slice 6 Phase C follow-up) since
1343
+ // those bodies are scene-root members too.
1344
+ if (go.getData('entityKind') === 'tilemap') {
1345
+ const container = go;
1346
+ const layers = container.getData('tilemapLayers') ?? [];
1347
+ for (const layer of layers) {
1348
+ const subGroup = layer.getData('unboxySubTileStaticGroup');
1349
+ if (subGroup) {
1350
+ subGroup.clear(true, true);
1351
+ subGroup.destroy(true);
1352
+ }
1353
+ layer.destroy();
1354
+ }
1355
+ container.setData('tilemapLayers', []);
1356
+ }
1357
+ go.destroy();
1358
+ registry.unregister(entityId);
1359
+ if (getSelection(game) === entityId)
1360
+ setSelection(game, null);
1361
+ }
1362
+ function findWorldScene(game) {
1363
+ // Prefer a scene with an EntityRegistry (scene-as-data game) — this
1364
+ // is the canonical "world scene" with metadata the editor can fully
1365
+ // interact with (Inspector, drag-to-place, prefab edits, etc.).
1366
+ // But fall back to ANY non-Boot/HUD/Overlay scene for pure
1367
+ // scene-as-code games. Without the fallback, the editor cam never
1368
+ // installs for code-only games (Snake, breakout, etc.) and pan/zoom
1369
+ // is silently broken — see game 3167e9ee (2026-05-17). Downstream
1370
+ // callers that NEED a registry (createEntity, deleteEntity, prefab
1371
+ // patches) check `getEntityRegistry(scene)` themselves and soft-fail
1372
+ // when it's missing, so relaxing this fallback is safe.
1373
+ let firstNonRegistry;
1374
+ for (const scene of game.scene.getScenes(false)) {
1375
+ const key = scene.scene.key;
1376
+ if (BOOT_SCENE_KEYS.has(key))
1377
+ continue;
1378
+ if (key === UMICAT_HUD_SCENE_KEY)
1379
+ continue;
1380
+ if (key === EDITOR_OVERLAY_KEY)
1381
+ continue;
1382
+ if (getEntityRegistry(scene))
1383
+ return scene;
1384
+ if (!firstNonRegistry)
1385
+ firstNonRegistry = scene;
1386
+ }
1387
+ return firstNonRegistry;
1388
+ }
1389
+ // --- Pan / zoom (P1 infinite canvas) -------------------------------------
1390
+ const ZOOM_MIN = 0.25;
1391
+ const ZOOM_MAX = 4;
1392
+ /**
1393
+ * Cap zoom uniformly across host-driven + SDK-input-driven paths so the
1394
+ * limits stay consistent everywhere.
1395
+ */
1396
+ function clampZoom(z) {
1397
+ return Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, z));
1398
+ }
1399
+ /**
1400
+ * Apply pan/zoom to the world scene's camera + mirror to the editor
1401
+ * overlay scene's camera so the overlay graphics (selection rect, world-
1402
+ * bounds rect, hitbox debug) draw in the right world position.
1403
+ *
1404
+ * Scoped to world + overlay only — HUD has an identity camera by design
1405
+ * (widgets are anchor-positioned to canvas edges), so panning/zooming it
1406
+ * would visually rip HUD widgets off their anchors.
1407
+ *
1408
+ * Exported for `EditorOverlayScene` so the in-canvas wheel + middle-click
1409
+ * + space+drag handlers route through the same mutation function as the
1410
+ * host-driven `umicat:editor:panZoom` message.
1411
+ */
1412
+ /**
1413
+ * Apply pan/zoom to the editor camera (NOT the game's runtime camera).
1414
+ *
1415
+ * P1 infinite canvas (2026-05-17). Mutates `editorCam` on the world scene
1416
+ * + the overlay scene's mirror. `cameras.main` (the game's runtime cam)
1417
+ * is never touched in edit mode — the game's intended camera position is
1418
+ * preserved exactly as the game code left it, and surfaces in the editor
1419
+ * as a dashed "Camera" rect (drawn by `EditorOverlayScene.update`). This
1420
+ * matches Godot 2D editor's "editor viewport vs Camera2D" split.
1421
+ *
1422
+ * Exported for `EditorOverlayScene` so the in-canvas wheel + middle-click
1423
+ * + space+drag handlers route through the same mutation function as the
1424
+ * host-driven `umicat:editor:panZoom` message. Renamed from the misleading
1425
+ * `applyPanZoomToWorld` (which sounded like "pan the world camera").
1426
+ */
1427
+ export function applyEditorPanZoom(game, msg) {
1428
+ const editorCam = getEditorCamera(game);
1429
+ const overlayScene = game.scene.getScene(EDITOR_OVERLAY_KEY);
1430
+ const cams = [];
1431
+ if (editorCam)
1432
+ cams.push(editorCam);
1433
+ if (overlayScene)
1434
+ cams.push(overlayScene.cameras.main);
1435
+ for (const cam of cams) {
1436
+ if (msg.relative) {
1437
+ if (typeof msg.scrollX === 'number')
1438
+ cam.scrollX += msg.scrollX;
1439
+ if (typeof msg.scrollY === 'number')
1440
+ cam.scrollY += msg.scrollY;
1441
+ }
1442
+ else {
1443
+ if (typeof msg.scrollX === 'number')
1444
+ cam.scrollX = msg.scrollX;
1445
+ if (typeof msg.scrollY === 'number')
1446
+ cam.scrollY = msg.scrollY;
1447
+ }
1448
+ if (typeof msg.zoom === 'number') {
1449
+ cam.setZoom(clampZoom(msg.zoom));
1450
+ }
1451
+ }
1452
+ // Keep HUD scene's cam viewport tracking the camera-viewport rect
1453
+ // on the editor canvas — pan/zoom moves the rect, HUD follows.
1454
+ syncHudCameraToEditorCam(game);
1455
+ postCameraState(game);
1456
+ // Re-anchor the host's popover/sparkle button on every pan/zoom so
1457
+ // it tracks the selected entity. Previously only the postMessage
1458
+ // panZoom path posted the rect; the SDK-internal wheel/middle-drag
1459
+ // path bypassed it and the popover lagged behind. Baking the call
1460
+ // in here covers both paths uniformly. RAF-coalesced inside, so
1461
+ // many wheel events per gesture still emit one rect per frame.
1462
+ postSelectionRect(game);
1463
+ }
1464
+ /**
1465
+ * Back-compat alias for callers (e.g. older host messages, internal call
1466
+ * sites) referencing the old name. Keep until we're sure nothing still
1467
+ * relies on it.
1468
+ */
1469
+ export const applyPanZoomToWorld = applyEditorPanZoom;
1470
+ /**
1471
+ * Post the editor camera's current state to the host so it can render the
1472
+ * zoom indicator + reset button, and decide which Hierarchy entries are
1473
+ * off-screen. Falls back to the game's cameras.main when editorCam isn't
1474
+ * installed yet (race during enterEdit).
1475
+ */
1476
+ export function postCameraState(game) {
1477
+ const worldScene = findWorldScene(game);
1478
+ if (!worldScene)
1479
+ return;
1480
+ const cam = getEditorCamera(game) ?? worldScene.cameras.main;
1481
+ const canvasW = game.scale.width;
1482
+ const canvasH = game.scale.height;
1483
+ // Visible world rect = (scrollX, scrollY) → (scrollX + canvasW/zoom, ...).
1484
+ // Convenience for the host so it doesn't have to re-derive zoom math.
1485
+ const viewportWorld = {
1486
+ x: cam.scrollX,
1487
+ y: cam.scrollY,
1488
+ width: canvasW / cam.zoom,
1489
+ height: canvasH / cam.zoom,
1490
+ };
1491
+ postToHost({
1492
+ type: 'umicat:editor:cameraState',
1493
+ zoom: cam.zoom,
1494
+ scrollX: cam.scrollX,
1495
+ scrollY: cam.scrollY,
1496
+ viewportWorld,
1497
+ });
1498
+ }
1499
+ // --- Canvas expand / restore (P1 infinite canvas: whole iframe = editor) -
1500
+ const CANVAS_SCALE_SNAPSHOT_KEY = '__unboxyEditorScaleSnapshot';
1501
+ /**
1502
+ * Switch Phaser to RESIZE scale mode so the canvas fills its parent (the
1503
+ * iframe body). Pairs with a home-ui CSS rule that drops the iframe's
1504
+ * aspect-ratio constraint in edit mode — together the iframe is the
1505
+ * entire editor surface, matching the Figma/Godot mental model the user
1506
+ * pushed back on 2026-05-17.
1507
+ *
1508
+ * The game's world coordinate system stays at its declared size (e.g.
1509
+ * 720×1280) — `cameras.main` is invisible in edit mode and never reads
1510
+ * the new canvas dims; only the editor camera (installed AFTER this
1511
+ * call) uses the new size to set its viewport. On exit, scale mode is
1512
+ * restored to the original (typically `FIT`) and the canvas snaps back
1513
+ * to game intrinsic size.
1514
+ *
1515
+ * Idempotent: re-entering edit while a snapshot exists is a no-op (the
1516
+ * race-window re-attempt loop in `enterEdit` calls back into the
1517
+ * install chain every 100ms for ~3s).
1518
+ */
1519
+ function expandCanvasForEditor(game) {
1520
+ const bag = game;
1521
+ // Idempotent expand (2026-05-17): if a stale snapshot exists from a
1522
+ // prior cycle that didn't fully restore, restore first to a clean
1523
+ // baseline, then take a fresh snapshot. Without this, second enter
1524
+ // skipped the entire expand path on stale state.
1525
+ if (bag[CANVAS_SCALE_SNAPSHOT_KEY]) {
1526
+ restoreCanvasAfterEditor(game);
1527
+ }
1528
+ bag[CANVAS_SCALE_SNAPSHOT_KEY] = {
1529
+ scaleMode: game.scale.scaleMode,
1530
+ autoCenter: game.scale.autoCenter,
1531
+ gameWidth: game.scale.gameSize.width,
1532
+ gameHeight: game.scale.gameSize.height,
1533
+ };
1534
+ // Force html/body to fill the viewport with grey bg. Inline styles win
1535
+ // over the template's `<style>` (which sets `body { display: flex;
1536
+ // align-items: center; background: #000 }` — that flex-centers the
1537
+ // canvas at its intrinsic size and shows a black background around
1538
+ // it, exactly what the user pushed back on). Grey body bg also acts
1539
+ // as the editor "void" — if any pixel inside the iframe isn't covered
1540
+ // by the SDK overlay's outside-fill graphics (e.g. fast pan, low
1541
+ // zoom + huge canvas), it falls through to grey instead of black.
1542
+ if (typeof document !== 'undefined') {
1543
+ const html = document.documentElement;
1544
+ const body = document.body;
1545
+ if (html) {
1546
+ html.style.setProperty('width', '100%', 'important');
1547
+ html.style.setProperty('height', '100%', 'important');
1548
+ html.style.setProperty('margin', '0', 'important');
1549
+ html.style.setProperty('padding', '0', 'important');
1550
+ html.style.setProperty('overflow', 'hidden', 'important');
1551
+ }
1552
+ if (body) {
1553
+ body.style.setProperty('width', '100%', 'important');
1554
+ body.style.setProperty('height', '100%', 'important');
1555
+ body.style.setProperty('margin', '0', 'important');
1556
+ body.style.setProperty('padding', '0', 'important');
1557
+ body.style.setProperty('display', 'block', 'important');
1558
+ body.style.setProperty('background', '#2a2a2e', 'important');
1559
+ body.style.setProperty('overflow', 'hidden', 'important');
1560
+ // Force a layout flush so the body's new dims are committed before
1561
+ // we read them via getBoundingClientRect below.
1562
+ void body.offsetHeight;
1563
+ }
1564
+ }
1565
+ // Switch to NONE (explicit canvas size) + NO_CENTER (no autoCenter
1566
+ // margins) so we have full control. RESIZE alone was fighting both
1567
+ // the template's body flex and the original `autoCenter: CENTER_BOTH`
1568
+ // — net effect was the canvas would settle at game intrinsic size
1569
+ // shifted/centered inside body, regardless of what scale.refresh()
1570
+ // computed. NONE + setGameSize sidesteps all of that.
1571
+ game.scale.autoCenter = Phaser.Scale.NO_CENTER;
1572
+ game.scale.scaleMode = Phaser.Scale.NONE;
1573
+ resizeCanvasToIframe(game);
1574
+ // Re-measure on next frame too — React's iframe inline-style update
1575
+ // and the iframe's internal layout might not be settled at the moment
1576
+ // `enterEdit` fires. A second pass catches late layout. Reads the
1577
+ // snapshot to confirm we're still in editor mode (user could have
1578
+ // toggled out before the rAF tick).
1579
+ if (typeof requestAnimationFrame !== 'undefined') {
1580
+ requestAnimationFrame(() => {
1581
+ if (!bag[CANVAS_SCALE_SNAPSHOT_KEY])
1582
+ return;
1583
+ resizeCanvasToIframe(game);
1584
+ });
1585
+ }
1586
+ }
1587
+ /**
1588
+ * Read iframe viewport via `window.innerWidth/innerHeight` (most direct
1589
+ * available measurement inside an iframe — equals the iframe ELEMENT's
1590
+ * inner content area, settled by the browser regardless of body layout).
1591
+ * Falls back to body rect when window dims are 0 (some sandboxes).
1592
+ */
1593
+ function resizeCanvasToIframe(game) {
1594
+ if (typeof window === 'undefined')
1595
+ return;
1596
+ // Use `window.innerWidth/Height` as the iframe viewport size, then
1597
+ // FORCE the canvas's display CSS to exactly that in pixels via
1598
+ // setProperty. This guarantees `cssRect.width === game.scale.width`
1599
+ // regardless of what body styling, flex constraints, or CSS
1600
+ // transforms might otherwise cause canvas's `width: 100%` to
1601
+ // resolve to. Empirically, body's resolved width can be smaller
1602
+ // than `window.innerWidth` due to template's `display: flex` /
1603
+ // container queries / etc., even after we set `body.width: 100%`
1604
+ // inline — when that happens `100%` on canvas = 100% of (shrunk)
1605
+ // body, and we get the mismatch the user saw at 53% zoom (sparkle
1606
+ // landed in top-left of the iframe instead of on the entity).
1607
+ const w = window.innerWidth;
1608
+ const h = window.innerHeight;
1609
+ if (w <= 0 || h <= 0)
1610
+ return;
1611
+ game.scale.setGameSize(w, h);
1612
+ game.scale.refresh();
1613
+ if (game.canvas) {
1614
+ // Explicit pixel display sizing — beats both `width: 100%`
1615
+ // resolution AND any CSS-transform-induced scaling. Phaser's
1616
+ // own `style.width` write happens in `refresh()` above, but
1617
+ // without `!important` so any stylesheet rule with `!important`
1618
+ // could win. We re-set with `!important` here.
1619
+ game.canvas.style.setProperty('position', 'absolute', 'important');
1620
+ game.canvas.style.setProperty('top', '0', 'important');
1621
+ game.canvas.style.setProperty('left', '0', 'important');
1622
+ game.canvas.style.setProperty('width', w + 'px', 'important');
1623
+ game.canvas.style.setProperty('height', h + 'px', 'important');
1624
+ game.canvas.style.setProperty('margin', '0', 'important');
1625
+ }
1626
+ // Sync editor + overlay camera viewports to the new canvas dims so
1627
+ // pan/zoom + outside-fill render across the entire surface. The
1628
+ // installEditorCameras call earlier in `enterEdit` may have set the
1629
+ // viewport based on a stale first measurement; this rAF-deferred
1630
+ // path catches the corrected size.
1631
+ const editorCam = getEditorCamera(game);
1632
+ if (editorCam && typeof editorCam.setViewport === 'function') {
1633
+ editorCam.setViewport(0, 0, w, h);
1634
+ }
1635
+ const overlayScene = game.scene.getScene(EDITOR_OVERLAY_KEY);
1636
+ // Overlay scene's `cameras.main` is undefined during the boot window
1637
+ // between scene.run() and scene.create() — Phaser hasn't constructed
1638
+ // the camera manager yet. Race-safe guard: only call setViewport if
1639
+ // both cameras and cameras.main exist.
1640
+ if (overlayScene && overlayScene.cameras && overlayScene.cameras.main) {
1641
+ overlayScene.cameras.main.setViewport(0, 0, w, h);
1642
+ }
1643
+ // Re-sync HUD cam viewport to the camera-viewport rect on the new
1644
+ // canvas dims (Phaser auto-resized HUD cam to the expanded canvas;
1645
+ // we want it positioned inside the cam viewport rect instead).
1646
+ syncHudCameraToEditorCam(game);
1647
+ }
1648
+ /**
1649
+ * Inverse of `expandCanvasForEditor`. Restores the original scale mode
1650
+ * and game size; Phaser resizes the canvas back to the game's intrinsic
1651
+ * dimensions (with whatever FIT letterboxing the host CSS now allows).
1652
+ */
1653
+ function restoreCanvasAfterEditor(game) {
1654
+ const bag = game;
1655
+ const snap = bag[CANVAS_SCALE_SNAPSHOT_KEY];
1656
+ if (!snap)
1657
+ return;
1658
+ // Clear the inline html/body styles we added so Play mode reverts to
1659
+ // the template's original styling (flex-centered canvas, black bg).
1660
+ if (typeof document !== 'undefined') {
1661
+ const html = document.documentElement;
1662
+ const body = document.body;
1663
+ if (html) {
1664
+ ['width', 'height', 'margin', 'padding', 'overflow'].forEach((p) => html.style.removeProperty(p));
1665
+ }
1666
+ if (body) {
1667
+ ['width', 'height', 'margin', 'padding', 'display', 'background', 'overflow'].forEach((p) => body.style.removeProperty(p));
1668
+ }
1669
+ }
1670
+ // Clear the inline canvas styles we forced on enter. Phaser's
1671
+ // scaleMode restore (FIT etc.) will re-write style.width/height to
1672
+ // the appropriate values for its own layout.
1673
+ if (game.canvas) {
1674
+ ['position', 'top', 'left', 'width', 'height', 'margin'].forEach((p) => game.canvas.style.removeProperty(p));
1675
+ }
1676
+ game.scale.autoCenter = snap.autoCenter;
1677
+ game.scale.scaleMode = snap.scaleMode;
1678
+ game.scale.setGameSize(snap.gameWidth, snap.gameHeight);
1679
+ game.scale.refresh();
1680
+ delete bag[CANVAS_SCALE_SNAPSHOT_KEY];
1681
+ }
1682
+ // --- Editor camera install / uninstall (P1 infinite canvas) -------------
1683
+ const EDITOR_CAM_KEY = '__unboxyEditorCam';
1684
+ const GAME_CAM_HIDDEN_KEY = '__unboxyEditorGameCamWasVisible';
1685
+ const VOID_FILL_KEY = '__unboxyEditorVoidFill';
1686
+ // Editor "void" grey — matches what EditorOverlayScene used to draw via
1687
+ // `OUTSIDE_FILL_COLOR`. Moved into world scene Graphics at depth=-1e9 so
1688
+ // entities dropped outside world bounds render on top of the fill (was
1689
+ // behind it when drawn in overlay scene, hiding the sprite).
1690
+ const VOID_FILL_COLOR = 0x3a3a45;
1691
+ const VOID_FILL_ALPHA = 1;
1692
+ const VOID_FILL_DEPTH = -1e9;
1693
+ const VOID_FILL_EXTENT = 1000000;
1694
+ /**
1695
+ * Install a SEPARATE editor camera on the world scene + hide the game's
1696
+ * runtime camera so the editor view doesn't double-render. Initial state
1697
+ * copied from cameras.main so the user opens edit mode looking at "what
1698
+ * the player would see right now."
1699
+ *
1700
+ * Idempotent — calling twice is a no-op (the editor cam is keyed onto the
1701
+ * game object). Editor cam reference is also exposed via `getEditorCamera`
1702
+ * for `applyEditorPanZoom` + the overlay scene's mirror.
1703
+ */
1704
+ function installEditorCameras(game) {
1705
+ const worldScene = findWorldScene(game);
1706
+ if (!worldScene)
1707
+ return;
1708
+ // Idempotent install (2026-05-17): if a stale editor cam reference
1709
+ // exists (e.g. an exit cycle didn't fully clear it), remove it
1710
+ // before installing a fresh one. The previous early-return left the
1711
+ // game cam visible (since `gameCam.setVisible(false)` below was
1712
+ // skipped), producing the "game renders top-left, no editor
1713
+ // decorations" bug on second enter.
1714
+ const existing = game[EDITOR_CAM_KEY];
1715
+ if (existing) {
1716
+ try {
1717
+ worldScene.cameras.remove(existing);
1718
+ }
1719
+ catch { /* stale cam already gone */ }
1720
+ delete game[EDITOR_CAM_KEY];
1721
+ }
1722
+ const gameCam = worldScene.cameras.main;
1723
+ // Remember whether the game cam was visible so we can restore it exactly.
1724
+ game[GAME_CAM_HIDDEN_KEY] = gameCam.visible;
1725
+ gameCam.setVisible(false);
1726
+ const canvasW = game.scale.width;
1727
+ const canvasH = game.scale.height;
1728
+ const editorCam = worldScene.cameras.add(0, 0, canvasW, canvasH);
1729
+ // Editor cam's bg shows through to canvas behind it — we want the editor
1730
+ // overlay's outside-fill graphics to be visible, so make the camera bg
1731
+ // transparent.
1732
+ editorCam.setBackgroundColor(0);
1733
+ // Phase D fix — snap to integer pixels so tilemap NEAREST sampling
1734
+ // doesn't catch transparent neighbor-tile pixels at subpixel offsets.
1735
+ // See spawnEntity.ts renderLayerInto for the matching Play-mode setting.
1736
+ editorCam.setRoundPixels(true);
1737
+ // Initial editor view: match the game's display size in Play mode —
1738
+ // i.e., fit the WHOLE world inside the canvas with NO margin. Play
1739
+ // mode uses Phaser FIT scaling against an iframe that's sized to the
1740
+ // game's AR, so the game renders at maximum size that fits the
1741
+ // iframe. In edit mode the canvas is now wider/taller than the game
1742
+ // (it fills the iframe regardless of AR), so we need to compute the
1743
+ // same fit zoom ourselves: `min(canvasW/worldW, canvasH/worldH)`.
1744
+ // The extra canvas area beyond the game becomes the editor void.
1745
+ //
1746
+ // History: 0.2.82 defaulted to zoom=1 which made the game look BIGGER
1747
+ // than in Play mode (a 1280-tall world at zoom 1 clipped vertically
1748
+ // in a 760-tall canvas) — user pushed back ("还不如你的53%"). The 53%
1749
+ // came from 0.2.81's fit*0.9 logic; same intent, but the 0.9 margin
1750
+ // made it visibly smaller than Play. Dropping the margin matches Play
1751
+ // exactly — game looks the same as it did before clicking Edit.
1752
+ const sceneFile = readActiveSceneFile(game);
1753
+ const snap = game[CANVAS_SCALE_SNAPSHOT_KEY];
1754
+ const worldW = sceneFile?.world.width ?? snap?.gameWidth ?? canvasW;
1755
+ const worldH = sceneFile?.world.height ?? snap?.gameHeight ?? canvasH;
1756
+ const editorZoom = Math.min(canvasW / worldW, canvasH / worldH);
1757
+ editorCam.setZoom(editorZoom);
1758
+ editorCam.scrollX = worldW / 2 - canvasW / (2 * editorZoom);
1759
+ editorCam.scrollY = worldH / 2 - canvasH / (2 * editorZoom);
1760
+ game[EDITOR_CAM_KEY] = editorCam;
1761
+ // Sync HUD scene's camera to render INSIDE the camera viewport rect
1762
+ // on the editor canvas (so HUD widgets visually track the camera
1763
+ // viewport rect during pan/zoom — matching what the player will see
1764
+ // at runtime).
1765
+ syncHudCameraToEditorCam(game);
1766
+ }
1767
+ /**
1768
+ * Tear down the editor camera + re-show the game's runtime camera. Inverse
1769
+ * of {@link installEditorCameras}. Called from `exitEdit` BEFORE resuming
1770
+ * scenes so the first resumed frame renders through cameras.main.
1771
+ */
1772
+ function uninstallEditorCameras(game) {
1773
+ const worldScene = findWorldScene(game);
1774
+ const editorCam = game[EDITOR_CAM_KEY];
1775
+ if (worldScene && editorCam) {
1776
+ worldScene.cameras.remove(editorCam);
1777
+ }
1778
+ if (worldScene) {
1779
+ const wasVisible = game[GAME_CAM_HIDDEN_KEY];
1780
+ worldScene.cameras.main.setVisible(wasVisible !== false);
1781
+ }
1782
+ delete game[EDITOR_CAM_KEY];
1783
+ delete game[GAME_CAM_HIDDEN_KEY];
1784
+ // Restore HUD camera to identity (Play-mode behavior) so HUD widgets
1785
+ // return to canvas-edge anchoring once Phaser resizes back to game
1786
+ // intrinsic canvas dims.
1787
+ restoreHudCamera(game);
1788
+ }
1789
+ /**
1790
+ * Install a grey "void" fill in the world scene at very low depth so
1791
+ * the area outside world bounds reads as editor surface (not the
1792
+ * game's solid black canvas bg). Drawn as a single Graphics with four
1793
+ * rects extending ±VOID_FILL_EXTENT (1e6) around world bounds; camera
1794
+ * clips offscreen for free. Lives in the world scene (not overlay) at
1795
+ * depth `-1e9` so entities at default depth render ON TOP of it —
1796
+ * fixes the 2026-05-17 bug where dropped entities outside world bounds
1797
+ * were visually covered by the overlay-scene grey fill.
1798
+ */
1799
+ function installVoidFill(game) {
1800
+ const worldScene = findWorldScene(game);
1801
+ if (!worldScene)
1802
+ return;
1803
+ // Idempotent: tear down stale fill before installing fresh.
1804
+ uninstallVoidFill(game);
1805
+ // Prefer scene-file dims (scene-as-data games declare world bounds
1806
+ // explicitly). Fall back to the snapshot's intrinsic game size for
1807
+ // pure scene-as-code games (no scene file in cache) — see game
1808
+ // 3167e9ee (Snake) which surfaced this 2026-05-17. Without the
1809
+ // fallback the void fill never installed and the editor showed the
1810
+ // game's solid bg color (black for Snake) instead of editor grey.
1811
+ const sceneFile = readActiveSceneFile(game);
1812
+ const snap = game[CANVAS_SCALE_SNAPSHOT_KEY];
1813
+ const wbX = 0;
1814
+ const wbY = 0;
1815
+ const wbR = sceneFile?.world.width ?? snap?.gameWidth ?? game.scale.width;
1816
+ const wbB = sceneFile?.world.height ?? snap?.gameHeight ?? game.scale.height;
1817
+ const B = VOID_FILL_EXTENT;
1818
+ const g = worldScene.add.graphics();
1819
+ g.setDepth(VOID_FILL_DEPTH);
1820
+ g.fillStyle(VOID_FILL_COLOR, VOID_FILL_ALPHA);
1821
+ g.fillRect(-B, -B, 2 * B, B + wbY); // top strip
1822
+ g.fillRect(-B, wbB, 2 * B, B - wbB); // bottom strip
1823
+ g.fillRect(-B, wbY, B + wbX, wbB - wbY); // left strip
1824
+ g.fillRect(wbR, wbY, B - wbR, wbB - wbY); // right strip
1825
+ // Hide from the game cam (which is invisible anyway, but be explicit
1826
+ // — if a future code path un-hides game cam during edit, the void
1827
+ // fill shouldn't show through there).
1828
+ g.setData('__unboxyVoidFill', true);
1829
+ game[VOID_FILL_KEY] = g;
1830
+ }
1831
+ function uninstallVoidFill(game) {
1832
+ const bag = game;
1833
+ const g = bag[VOID_FILL_KEY];
1834
+ if (g) {
1835
+ try {
1836
+ g.destroy();
1837
+ }
1838
+ catch { /* already gone */ }
1839
+ delete bag[VOID_FILL_KEY];
1840
+ }
1841
+ }
1842
+ /**
1843
+ * Sync the HUD scene's main camera to render INSIDE the camera viewport
1844
+ * rect on the editor canvas, scaled by the editor cam's zoom. Without
1845
+ * this, HUD widgets stay anchored to the EXPANDED canvas corners — far
1846
+ * from where the player will actually see them at runtime — and don't
1847
+ * track when the user pans/zooms the editor view. With this, HUD
1848
+ * widgets visually stick to the blue camera-viewport rect, matching
1849
+ * what the player sees in Play mode.
1850
+ *
1851
+ * Pair with `resolveAnchor`'s snapshot-aware logic in HudRuntime — that
1852
+ * makes HUD widgets compute positions in GAME-intrinsic coords; this
1853
+ * function projects those coords onto the camera-viewport-rect area
1854
+ * of the editor canvas.
1855
+ *
1856
+ * Idempotent. Called from `installEditorCameras` + `applyEditorPanZoom`
1857
+ * + `resizeCanvasToIframe`'s rAF tick so HUD tracks all editor view
1858
+ * mutations.
1859
+ */
1860
+ function syncHudCameraToEditorCam(game) {
1861
+ const hudScene = game.scene.getScene(UMICAT_HUD_SCENE_KEY);
1862
+ if (!hudScene)
1863
+ return;
1864
+ const hudCam = hudScene.cameras.main;
1865
+ if (!hudCam)
1866
+ return;
1867
+ const editorCam = getEditorCamera(game);
1868
+ const worldScene = findWorldScene(game);
1869
+ if (!editorCam || !worldScene)
1870
+ return;
1871
+ // Use FULL canvas viewport with a SCROLL offset (not a positioned
1872
+ // viewport). The original approach (0.2.89) set HUD cam viewport to
1873
+ // the camera-viewport rect's canvas position + size — visually
1874
+ // equivalent for HUD widgets positioned inside the rect, but it
1875
+ // caused two issues at zoom levels where the camera-viewport rect
1876
+ // extends past the canvas edge:
1877
+ // 1. Negative viewport coords (Phaser clips them weirdly)
1878
+ // 2. Viewport-positioned clipping cut off widgets as the rect
1879
+ // moved during cursor-anchored zoom
1880
+ // Scroll-based achieves the same widget placement without the
1881
+ // clip artifacts (full canvas viewport → no clipping).
1882
+ //
1883
+ // Math: widget at HUD-intrinsic (X, Y) renders at
1884
+ // canvas X = viewport.x + (X - scroll.x) * zoom = (X - scroll.x) * zoom
1885
+ // We want it to equal camRectCanvasX + X * zoom (where the camera-
1886
+ // viewport rect renders on the editor canvas), so:
1887
+ // scroll.x = -camRectCanvasX / zoom = editorCam.scrollX - gameCam.scrollX
1888
+ const gameCam = worldScene.cameras.main;
1889
+ hudCam.setViewport(0, 0, game.scale.width, game.scale.height);
1890
+ hudCam.setZoom(editorCam.zoom);
1891
+ hudCam.setScroll(editorCam.scrollX - gameCam.scrollX, editorCam.scrollY - gameCam.scrollY);
1892
+ }
1893
+ /**
1894
+ * Restore the HUD scene's main camera to its default identity (full
1895
+ * canvas viewport at zoom 1, scroll 0) — the Play-mode state. Called
1896
+ * from `uninstallEditorCameras` and `restoreCanvasAfterEditor`.
1897
+ */
1898
+ function restoreHudCamera(game) {
1899
+ const hudScene = game.scene.getScene(UMICAT_HUD_SCENE_KEY);
1900
+ if (!hudScene)
1901
+ return;
1902
+ const hudCam = hudScene.cameras.main;
1903
+ if (!hudCam)
1904
+ return;
1905
+ hudCam.setViewport(0, 0, game.scale.width, game.scale.height);
1906
+ hudCam.setZoom(1);
1907
+ hudCam.setScroll(0, 0);
1908
+ }
1909
+ /**
1910
+ * Read-only access to the editor camera. Null when edit mode hasn't been
1911
+ * entered yet OR was just exited. Used by `applyEditorPanZoom`,
1912
+ * `postCameraState`, and the overlay scene's mirror in `create`.
1913
+ */
1914
+ export function getEditorCamera(game) {
1915
+ return (game[EDITOR_CAM_KEY] ?? null);
1916
+ }
1917
+ /**
1918
+ * Toggle HUD scene rendering. HUD widgets are canvas-relative (anchor-
1919
+ * positioned to canvas edges), so in world edit mode — where the user is
1920
+ * panning/zooming the world view — the HUD would float visually wrong
1921
+ * (HUD widgets would stay glued to canvas corners regardless of editor
1922
+ * pan). Hide HUD in world edit; show it in HUD edit (the editing surface).
1923
+ *
1924
+ * Phaser scene's `setVisible(false)` keeps the scene running but skips
1925
+ * rendering. Cheap toggle.
1926
+ */
1927
+ function setHudVisibility(game, visible) {
1928
+ const hudScene = game.scene.getScene(UMICAT_HUD_SCENE_KEY);
1929
+ if (!hudScene)
1930
+ return;
1931
+ hudScene.scene.setVisible(visible);
1932
+ }
1933
+ // --- Slice 11 Phase B.5: prefab live-edit --------------------------------
1934
+ /**
1935
+ * Handle an `umicat:editor:editPrefab` postMessage — slice 11 B.5 / editor P0.2.
1936
+ *
1937
+ * Pipeline:
1938
+ * 1. Locate the prefab in the cached manifest. Deep-merge the patch into
1939
+ * the record so subsequent `spawnPrefab` calls pick up the new values.
1940
+ * 2. Walk every live instance via `EntityRegistry.byPrefabId(prefabId)`
1941
+ * (populated by `spawnPrefab` — instances tagged with `entityPrefabId`).
1942
+ * 3. Re-apply visual + physics + property changes to each instance. Visual
1943
+ * reuses the same paths `applyEdit` uses for world entities so behavior
1944
+ * is consistent (Inspector edits on a world entity and on a prefab feel
1945
+ * identical to the user). Physics mutates the existing Arcade body
1946
+ * in place — no `physics.add.existing` recreate, no GameObject reuse
1947
+ * churn.
1948
+ *
1949
+ * No-op when the manifest isn't cached yet (edit fired during boot race) or
1950
+ * the prefab id isn't declared (host should validate first, but we soft-fail).
1951
+ */
1952
+ function handleEditPrefab(game, prefabId, patch) {
1953
+ const manifest = readActiveManifest(game);
1954
+ if (!manifest || !Array.isArray(manifest.prefabs)) {
1955
+ console.warn(`[umicat/editor] editPrefab: no manifest cached, cannot apply patch for '${prefabId}'`);
1956
+ return;
1957
+ }
1958
+ const idx = manifest.prefabs.findIndex((p) => p.id === prefabId);
1959
+ if (idx < 0) {
1960
+ console.warn(`[umicat/editor] editPrefab: no prefab with id '${prefabId}' in manifest`);
1961
+ return;
1962
+ }
1963
+ // Merge patch into the cached record so subsequent spawns inherit it.
1964
+ manifest.prefabs[idx] = mergePrefabRecord(manifest.prefabs[idx], patch);
1965
+ // Walk live instances and re-apply each patched section. Lives in the
1966
+ // world-scene registry (prefabs only spawn into world scenes — HUD has
1967
+ // its own runtime).
1968
+ const registry = findWorldScene(game) && getEntityRegistry(findWorldScene(game));
1969
+ if (!registry)
1970
+ return;
1971
+ for (const go of registry.byPrefabId(prefabId)) {
1972
+ applyPrefabPatchToInstance(game, go, patch);
1973
+ }
1974
+ }
1975
+ function mergePrefabRecord(prev, patch) {
1976
+ // SDK 0.3.0: render fields live at the patch root (assetId / tint / width /
1977
+ // fillColor / params / etc.), same flat shape as the prefab record itself.
1978
+ // Copy each render-field key that's present in the patch into the merged
1979
+ // record. Non-render slots (physics / properties / role) stay in their
1980
+ // own slots.
1981
+ const merged = { ...prev };
1982
+ const RENDER_KEYS = [
1983
+ 'assetId', 'frame', 'tint', 'alpha', 'flipX', 'flipY',
1984
+ 'width', 'height', 'radius', 'fillColor', 'strokeColor', 'strokeWidth',
1985
+ 'params',
1986
+ ];
1987
+ for (const k of RENDER_KEYS) {
1988
+ const v = patch[k];
1989
+ if (v === undefined)
1990
+ continue;
1991
+ if (k === 'params' && isPlainObject(merged.params) && isPlainObject(v)) {
1992
+ merged.params = { ...merged.params, ...v };
1993
+ }
1994
+ else {
1995
+ merged[k] = v;
1996
+ }
1997
+ }
1998
+ if (patch.physics) {
1999
+ merged.physics = { ...(prev.physics ?? {}), ...patch.physics };
2000
+ }
2001
+ if (patch.properties) {
2002
+ merged.properties = { ...(prev.properties ?? {}), ...patch.properties };
2003
+ }
2004
+ if (patch.role !== undefined) {
2005
+ if (patch.role === null)
2006
+ delete merged.role;
2007
+ else
2008
+ merged.role = patch.role;
2009
+ }
2010
+ return merged;
2011
+ }
2012
+ /**
2013
+ * Re-apply a prefab patch to a single live instance.
2014
+ *
2015
+ * Visual: same paths `applyEdit` uses for world entities — `applyVisualPatch`
2016
+ * for sprite tint / alpha / size etc., `applyCodeRenderedParamsPatch` for
2017
+ * render-script param re-render.
2018
+ *
2019
+ * Physics: mutate the existing Arcade body in place (size / offset /
2020
+ * immovable / velocity / bounce). No recreation; no rendering change.
2021
+ *
2022
+ * Properties: merge into the GameObject's data-manager `entityProperties`
2023
+ * slot so behavior code reading `go.getData('entityProperties').hp` sees
2024
+ * the new value on next frame.
2025
+ */
2026
+ function applyPrefabPatchToInstance(game, go, patch) {
2027
+ // SDK 0.3.0: render fields live at the patch root. Apply them through the
2028
+ // same paths `applyEdit` uses for world entities — `applyVisualPatch` for
2029
+ // sprite tint / alpha / size / etc., `applyCodeRenderedParamsPatch` for
2030
+ // render-script param re-render.
2031
+ applyVisualPatch(go, patch);
2032
+ applyCodeRenderedParamsPatch(game, go, patch.params);
2033
+ if (patch.physics) {
2034
+ applyPhysicsPatchToBody(go, patch.physics);
2035
+ }
2036
+ if (patch.properties) {
2037
+ const prev = go.getData('entityProperties') ?? {};
2038
+ go.setData('entityProperties', { ...prev, ...patch.properties });
2039
+ }
2040
+ if (patch.role !== undefined) {
2041
+ go.setData('entityRole', patch.role ?? undefined);
2042
+ }
2043
+ }
2044
+ /**
2045
+ * Mutate an existing Arcade body with the patched physics fields.
2046
+ *
2047
+ * Distinct from `spawnEntity.ts`'s `applyEntityPhysics`: that one *creates*
2048
+ * the body (calls `scene.physics.add.existing`) and computes defaults from
2049
+ * the visual extent. This one only touches fields the patch carries — every
2050
+ * other body property is left as-is. No body recreate, no origin shift
2051
+ * (code-rendered origin shift only matters at body creation; size/offset
2052
+ * patches respect the existing frame).
2053
+ */
2054
+ function applyPhysicsPatchToBody(go, patch) {
2055
+ const body = go.body;
2056
+ if (!body)
2057
+ return;
2058
+ if (typeof patch.bodyW === 'number' || typeof patch.bodyH === 'number') {
2059
+ body.setSize(typeof patch.bodyW === 'number' ? patch.bodyW : body.width, typeof patch.bodyH === 'number' ? patch.bodyH : body.height);
2060
+ }
2061
+ if (typeof patch.offsetX === 'number' || typeof patch.offsetY === 'number') {
2062
+ body.setOffset(typeof patch.offsetX === 'number' ? patch.offsetX : body.offset.x, typeof patch.offsetY === 'number' ? patch.offsetY : body.offset.y);
2063
+ }
2064
+ if (typeof patch.immovable === 'boolean')
2065
+ body.setImmovable(patch.immovable);
2066
+ if (typeof patch.velocityX === 'number' || typeof patch.velocityY === 'number') {
2067
+ body.setVelocity(typeof patch.velocityX === 'number' ? patch.velocityX : body.velocity.x, typeof patch.velocityY === 'number' ? patch.velocityY : body.velocity.y);
2068
+ }
2069
+ if (typeof patch.collideWorldBounds === 'boolean') {
2070
+ body.setCollideWorldBounds(patch.collideWorldBounds);
2071
+ }
2072
+ if (typeof patch.bounceX === 'number' || typeof patch.bounceY === 'number') {
2073
+ body.setBounce(typeof patch.bounceX === 'number' ? patch.bounceX : body.bounce.x, typeof patch.bounceY === 'number' ? patch.bounceY : body.bounce.y);
2074
+ }
2075
+ }
2076
+ function isPlainObject(v) {
2077
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
2078
+ }
2079
+ // --- Slice 11 Phase B.5 / editor P0.3: rule live-edit --------------------
2080
+ /**
2081
+ * Handle `umicat:editor:patchRule { path, value }` — slice 11 B.5 / P0.3.
2082
+ *
2083
+ * Delegates to `Rules.ts`'s `patchRule(scene, path, value)` which:
2084
+ * - Writes the path into the cached rules tree (subsequent `getRule`
2085
+ * calls return the new value).
2086
+ * - Fires every subscriber whose path matches or is an ancestor of the
2087
+ * patched path — game code that called `onRuleChange(this, 'balance.lives', cb)`
2088
+ * sees the new value on the next frame.
2089
+ *
2090
+ * Game code that read rules via `getRule` ONCE in `create()` won't see
2091
+ * the change unless it subscribed; that's by design (most rules are
2092
+ * configuration, not live-tunable). See SDK-GUIDE.md "Rules" chapter for
2093
+ * the reactive subscription pattern.
2094
+ *
2095
+ * No-op when no world scene is loaded (edit fired during boot race).
2096
+ */
2097
+ function handlePatchRule(game, path, value) {
2098
+ const scene = findWorldScene(game);
2099
+ if (!scene) {
2100
+ console.warn(`[umicat/editor] patchRule: no world scene to patch '${path}' on`);
2101
+ return;
2102
+ }
2103
+ patchRule(scene, path, value);
2104
+ }
2105
+ // --- Slice 6 Phase B — tilemap painter -----------------------------------
2106
+ function handleSetTilemapTool(game, msg) {
2107
+ setTilemapToolState(game, {
2108
+ tilemapEditingId: msg.tilemapEditingId,
2109
+ activeLayerId: msg.activeLayerId,
2110
+ tool: msg.tool,
2111
+ activeTile: msg.activeTile,
2112
+ activeTerrainId: msg.activeTerrainId ?? null,
2113
+ });
2114
+ }
2115
+ /**
2116
+ * Apply one or more tilemap edit ops to the live Phaser TilemapLayer(s).
2117
+ *
2118
+ * Called from two paths:
2119
+ * 1. Host pushes `umicat:editor:editTilemap` (agent writes, undo/redo
2120
+ * replay) — see EditorBridge.handleMessage dispatch.
2121
+ * 2. SDK's pointer-up handler (live painter stroke) — calls this directly
2122
+ * after composing the stroke's accumulated cells into one op, before
2123
+ * posting `umicat:editor:tilemapEdited` to the host.
2124
+ *
2125
+ * Mirror of handleEditPrefab's pattern: lookup container in registry → find
2126
+ * matching layer child by `tilemapLayerId` data tag → apply Phaser tilemap
2127
+ * mutation API. Soft-fails (warn + skip) on missing entity / missing layer
2128
+ * / out-of-bounds cell so a stale op doesn't crash the runtime.
2129
+ */
2130
+ /**
2131
+ * Apply tilemap edit ops to live runtime. Public because the editor's
2132
+ * own drag-resize commit (in EditorOverlayScene) needs to apply the op
2133
+ * locally before posting `tilemapEdited` for host undo recording —
2134
+ * same pattern as brush/rect/fill commits (apply local, post for undo).
2135
+ * Skipping the local apply leaves SDK runtime + host draft out of sync
2136
+ * (host knows the new size, SDK keeps rendering old size → bounds snap-
2137
+ * back UX).
2138
+ *
2139
+ * Async because addLayer ops can reference a fresh tileset whose
2140
+ * texture hasn't loaded yet. Caller supplies optional `manifestAsset`
2141
+ * (same pattern as createEntity's drag-to-place) — handler upserts
2142
+ * cache + AWAITS texture load before invoking applyTilemapStructureOp.
2143
+ * Without the await, addLayer's `textures.exists()` check fails on
2144
+ * the in-flight load → skips layer creation → painter can't find
2145
+ * layer → click falls through to drag-the-entity.
2146
+ */
2147
+ export async function handleEditTilemap(game, entityId, ops, manifestAsset) {
2148
+ const scene = findWorldScene(game);
2149
+ if (!scene) {
2150
+ console.warn(`[umicat/editor] editTilemap: no world scene for '${entityId}'`);
2151
+ return;
2152
+ }
2153
+ // Upsert + await asset load BEFORE any op runs. addLayer is the
2154
+ // primary consumer of this; other ops (paint/erase/resize) don't
2155
+ // need it but harmless to upsert idempotently.
2156
+ if (manifestAsset) {
2157
+ upsertCachedManifestAsset(scene, manifestAsset);
2158
+ await ensureAssetLoaded(scene, manifestAsset);
2159
+ }
2160
+ const registry = getEntityRegistry(scene);
2161
+ if (!registry)
2162
+ return;
2163
+ const container = registry.byId(entityId);
2164
+ if (!container || !(container instanceof Phaser.GameObjects.Container)) {
2165
+ console.warn(`[umicat/editor] editTilemap: entity '${entityId}' is not a tilemap container`);
2166
+ return;
2167
+ }
2168
+ for (const op of ops) {
2169
+ // Phase B.5 / B.6 layer-structure ops mutate the layer list / dims,
2170
+ // not individual cell data. They take the container + scene, not a
2171
+ // single TilemapLayer, because addLayer creates a NEW layer (no
2172
+ // existing target), removeLayer destroys one, and resize rebuilds
2173
+ // all layers atomically.
2174
+ if (op.kind === 'addLayer' ||
2175
+ op.kind === 'removeLayer' ||
2176
+ op.kind === 'setLayerVisibility' ||
2177
+ op.kind === 'setLayerName' ||
2178
+ op.kind === 'resize') {
2179
+ applyTilemapStructureOp(scene, container, op);
2180
+ continue;
2181
+ }
2182
+ const layer = findTilemapLayerById(container, op.layerId);
2183
+ if (!layer) {
2184
+ console.warn(`[umicat/editor] editTilemap: layer '${op.layerId}' not found on tilemap '${entityId}'`);
2185
+ continue;
2186
+ }
2187
+ // Resolve the layer's tileset asset once — autotilePaint needs it
2188
+ // for the terrain lookup, sub-tile-body sync needs it after every op.
2189
+ const layerTilesetId = layer.getData('tilemapTilesetId');
2190
+ let layerAsset = null;
2191
+ if (layerTilesetId) {
2192
+ const manifest = getManifest(scene);
2193
+ layerAsset = manifest?.assets.find((a) => a.id === layerTilesetId) ?? null;
2194
+ }
2195
+ applyTilemapOp(layer, op, layerAsset);
2196
+ // Phase C follow-up — sub-tile collision rects. After any cell
2197
+ // mutation, re-sync the layer's sub-tile static body group from the
2198
+ // current `layer.data`. Cheap when the tileset has no collisionRect
2199
+ // (single map scan + early return). Without this, painting a new tile
2200
+ // with a collisionRect leaves the sub-rect body missing until next
2201
+ // scene reload.
2202
+ if (layerAsset) {
2203
+ const tilesetPhaser = layer.tileset[0];
2204
+ if (tilesetPhaser) {
2205
+ applyTilesetTileMetadata(tilesetPhaser, layer, layerAsset);
2206
+ // Phase F — re-arm tile animations after each paint op. A
2207
+ // newly-painted root tile starts animating immediately.
2208
+ applyTilesetAnimations(layer, layerAsset);
2209
+ }
2210
+ }
2211
+ }
2212
+ }
2213
+ /**
2214
+ * Layer-structure ops (add / remove / visibility) — slice 6 Phase B.5.
2215
+ * Mutate the tilemap container's list of `TilemapLayer` children, not
2216
+ * individual cells. Mirrors how `createTilemap` builds layers initially
2217
+ * (in spawnEntity.ts) — same Container parent, same per-layer tagging,
2218
+ * same centered (-w/2, -h/2) positioning.
2219
+ */
2220
+ function applyTilemapStructureOp(scene, container, op) {
2221
+ switch (op.kind) {
2222
+ case 'addLayer': {
2223
+ // Resolve tilemap entity dims (stashed by spawnEntity.tagGameObject
2224
+ // on the container). Without these we can't size the new layer.
2225
+ const tileSize = container.getData('tilemapTileSize');
2226
+ const size = container.getData('tilemapSize');
2227
+ if (!tileSize || !size) {
2228
+ console.warn('[umicat/editor] addLayer: container missing tilemap dims');
2229
+ return;
2230
+ }
2231
+ const tilesetId = op.layer.tilesetIds?.[0];
2232
+ if (!tilesetId) {
2233
+ console.warn(`[umicat/editor] addLayer '${op.layer.id}' has no tilesetIds`);
2234
+ return;
2235
+ }
2236
+ const manifest = getManifest(scene);
2237
+ const asset = manifest?.assets.find((a) => a.id === tilesetId);
2238
+ if (!asset) {
2239
+ console.warn(`[umicat/editor] addLayer: tileset '${tilesetId}' not in manifest`);
2240
+ return;
2241
+ }
2242
+ if (!scene.textures.exists(asset.textureKey)) {
2243
+ console.warn(`[umicat/editor] addLayer: tileset texture '${asset.textureKey}' not loaded; skipping`);
2244
+ return;
2245
+ }
2246
+ const cellW = asset.tileset?.cellSize.width
2247
+ ?? asset.spriteSheetConfig?.frameWidth
2248
+ ?? asset.frameWidth
2249
+ ?? tileSize.width;
2250
+ const cellH = asset.tileset?.cellSize.height
2251
+ ?? asset.spriteSheetConfig?.frameHeight
2252
+ ?? asset.frameHeight
2253
+ ?? tileSize.height;
2254
+ // Build an empty grid of -1 (Phaser's "no tile" sentinel) so the
2255
+ // new layer is immediately paintable. Same shape as Phase A's
2256
+ // empty-layer materialization path in renderLayerInto.
2257
+ const grid = [];
2258
+ for (let y = 0; y < size.height; y++) {
2259
+ grid.push(new Array(size.width).fill(-1));
2260
+ }
2261
+ let map;
2262
+ try {
2263
+ map = scene.make.tilemap({
2264
+ data: grid,
2265
+ tileWidth: cellW,
2266
+ tileHeight: cellH,
2267
+ });
2268
+ }
2269
+ catch (e) {
2270
+ console.warn(`[umicat/editor] addLayer: tilemap construction failed: ${String(e)}`);
2271
+ return;
2272
+ }
2273
+ const tileset = map.addTilesetImage(tilesetId, asset.textureKey, cellW, cellH, asset.tileset?.margin ?? 0, asset.tileset?.spacing ?? 0);
2274
+ if (!tileset) {
2275
+ console.warn(`[umicat/editor] addLayer: addTilesetImage null for '${tilesetId}'`);
2276
+ return;
2277
+ }
2278
+ const layerObj = map.createLayer(0, tileset, 0, 0);
2279
+ if (!layerObj) {
2280
+ console.warn(`[umicat/editor] addLayer: createLayer null for '${op.layer.id}'`);
2281
+ return;
2282
+ }
2283
+ if (cellW !== tileSize.width || cellH !== tileSize.height) {
2284
+ layerObj.setScale(tileSize.width / cellW, tileSize.height / cellH);
2285
+ }
2286
+ // Position in WORLD coords (NOT relative to container). See
2287
+ // createTilemap for why TilemapLayers can't be Container children.
2288
+ const pxW = size.width * tileSize.width;
2289
+ const pxH = size.height * tileSize.height;
2290
+ const left = -pxW / 2;
2291
+ const top = -pxH / 2;
2292
+ layerObj.x = container.x + left;
2293
+ layerObj.y = container.y + top;
2294
+ if (op.layer.visible === false)
2295
+ layerObj.setVisible(false);
2296
+ layerObj.setData('tilemapLayerId', op.layer.id);
2297
+ // Stash tileset asset id so live `assetUpdate` (Tile Metadata
2298
+ // Editor save) can find this layer + re-apply metadata.
2299
+ layerObj.setData('tilemapTilesetId', tilesetId);
2300
+ // Slice 6 Phase C — apply per-tile metadata + auto-collision AFTER
2301
+ // positioning so sub-tile static bodies land at correct world
2302
+ // coords. Calling before positioning would place bodies near world
2303
+ // origin (the original bug visualized as collision bodies in the
2304
+ // top-left of the canvas).
2305
+ applyTilesetTileMetadata(tileset, layerObj, asset);
2306
+ // Phase F — arm tile animations on this fresh layer.
2307
+ applyTilesetAnimations(layerObj, asset);
2308
+ // Parent-entity tag so the per-frame sync hook can find which
2309
+ // container drives this layer's position.
2310
+ const entityId = container.getData('entityId');
2311
+ if (entityId)
2312
+ layerObj.setData('tilemapEntityId', entityId);
2313
+ layerObj.setData('tilemapLocalOffsetX', left);
2314
+ layerObj.setData('tilemapLocalOffsetY', top);
2315
+ // Append to the container's stashed layer refs array.
2316
+ const layers = container.getData('tilemapLayers') ?? [];
2317
+ layers.push(layerObj);
2318
+ container.setData('tilemapLayers', layers);
2319
+ break;
2320
+ }
2321
+ case 'removeLayer': {
2322
+ const layer = findTilemapLayerById(container, op.layerId);
2323
+ if (!layer)
2324
+ return;
2325
+ // Drop from the container's stashed layers + destroy. Layer is in
2326
+ // scene root (not a container child), so container.remove() is wrong.
2327
+ const layers = container.getData('tilemapLayers') ?? [];
2328
+ const filtered = layers.filter((l) => l !== layer);
2329
+ container.setData('tilemapLayers', filtered);
2330
+ layer.destroy();
2331
+ break;
2332
+ }
2333
+ case 'setLayerVisibility': {
2334
+ const layer = findTilemapLayerById(container, op.layerId);
2335
+ if (!layer)
2336
+ return;
2337
+ layer.setVisible(op.visible);
2338
+ break;
2339
+ }
2340
+ case 'setLayerName': {
2341
+ // Editor-only metadata — name has no runtime effect (SDK lookups
2342
+ // are by id, never by name). Persisted to scene JSON via the host
2343
+ // draft. No runtime mutation needed; explicit case so the dispatcher
2344
+ // doesn't fall into the cell-op path.
2345
+ break;
2346
+ }
2347
+ case 'resize': {
2348
+ // Resize is destructive on shrink (cells outside new bounds are
2349
+ // lost). Host UI guards with a confirm dialog when shrinking
2350
+ // would discard painted cells — by the time the op reaches the
2351
+ // SDK, the user has accepted the loss. We just need to rebuild
2352
+ // every layer with the new dims, copying old cell data into the
2353
+ // intersection of old × new bounds.
2354
+ const tileSize = container.getData('tilemapTileSize');
2355
+ if (!tileSize) {
2356
+ console.warn('[umicat/editor] resize: container missing tilemapTileSize');
2357
+ return;
2358
+ }
2359
+ const newW = op.width;
2360
+ const newH = op.height;
2361
+ if (newW <= 0 || newH <= 0)
2362
+ return;
2363
+ // Phase B.6.1 — handle-drag resizes shift the tilemap center to
2364
+ // keep the opposite edge anchored. Apply BEFORE rebuilding layers
2365
+ // so the new layers are positioned around the new center.
2366
+ if (op.transformDelta) {
2367
+ container.x += op.transformDelta.x;
2368
+ container.y += op.transformDelta.y;
2369
+ // Keep the entity-transform stash in sync so other systems (drag,
2370
+ // selection rect, etc.) read the current pos.
2371
+ const tx = container.getData('entityTransform');
2372
+ if (tx) {
2373
+ tx.x += op.transformDelta.x;
2374
+ tx.y += op.transformDelta.y;
2375
+ }
2376
+ }
2377
+ const oldLayers = container.getData('tilemapLayers') ?? [];
2378
+ const snapshots = [];
2379
+ for (const layer of oldLayers) {
2380
+ const id = layer.getData('tilemapLayerId');
2381
+ if (!id)
2382
+ continue;
2383
+ const tileset = layer.tileset[0]; // single tileset per layer in v1
2384
+ if (!tileset)
2385
+ continue;
2386
+ const cells = [];
2387
+ // Phaser's TilemapLayer exposes `layer.layer.data[y][x].index`.
2388
+ const layerData = layer.layer.data;
2389
+ for (let y = 0; y < layerData.length; y++) {
2390
+ const row = [];
2391
+ const sourceRow = layerData[y];
2392
+ for (let x = 0; x < sourceRow.length; x++) {
2393
+ const tile = sourceRow[x];
2394
+ row.push(tile && tile.index >= 0 ? tile.index : -1);
2395
+ }
2396
+ cells.push(row);
2397
+ }
2398
+ snapshots.push({
2399
+ id,
2400
+ visible: layer.visible,
2401
+ tilesetId: tileset.name,
2402
+ textureKey: tileset.image?.key ?? tileset.name,
2403
+ cellSize: { width: tileset.tileWidth, height: tileset.tileHeight },
2404
+ margin: tileset.tileMargin,
2405
+ spacing: tileset.tileSpacing,
2406
+ cells,
2407
+ });
2408
+ layer.destroy();
2409
+ }
2410
+ container.setData('tilemapLayers', []);
2411
+ // Update size stash so subsequent ops (addLayer, etc.) see the new dims.
2412
+ container.setData('tilemapSize', { width: newW, height: newH });
2413
+ // Re-stamp the editor hit-test dims so clicks on the new (resized)
2414
+ // bounds register on the entity. Without this, the container's
2415
+ // hit-test rect stays at the original spawn dims — clicks in the
2416
+ // new "outside-original-bounds" area would miss the entity, click
2417
+ // fell through to empty canvas → selection clears → user thinks the
2418
+ // tilemap "disappeared". Mirrors spawnEntity's sizeForHitTest stash
2419
+ // which only runs at spawn time.
2420
+ const newPxW = newW * tileSize.width;
2421
+ const newPxH = newH * tileSize.height;
2422
+ container.setData('editorHitWidth', newPxW);
2423
+ container.setData('editorHitHeight', newPxH);
2424
+ // Recreate layers with the new dims, copying clipped/padded data.
2425
+ // Same algorithm as createTilemap's renderLayerInto, just sourcing
2426
+ // the cell grid from the snapshot.
2427
+ const pxW = newW * tileSize.width;
2428
+ const pxH = newH * tileSize.height;
2429
+ const left = -pxW / 2;
2430
+ const top = -pxH / 2;
2431
+ const newLayers = [];
2432
+ for (const snap of snapshots) {
2433
+ // Build new grid: copy intersect of (snap.cells, newW × newH).
2434
+ const grid = [];
2435
+ for (let y = 0; y < newH; y++) {
2436
+ const row = new Array(newW).fill(-1);
2437
+ const sourceRow = snap.cells[y];
2438
+ if (sourceRow) {
2439
+ for (let x = 0; x < Math.min(newW, sourceRow.length); x++) {
2440
+ row[x] = sourceRow[x];
2441
+ }
2442
+ }
2443
+ grid.push(row);
2444
+ }
2445
+ let map;
2446
+ try {
2447
+ map = scene.make.tilemap({
2448
+ data: grid,
2449
+ tileWidth: snap.cellSize.width,
2450
+ tileHeight: snap.cellSize.height,
2451
+ });
2452
+ }
2453
+ catch (e) {
2454
+ console.warn(`[umicat/editor] resize: tilemap rebuild failed for layer '${snap.id}': ${String(e)}`);
2455
+ continue;
2456
+ }
2457
+ const ts = map.addTilesetImage(snap.tilesetId, snap.textureKey, snap.cellSize.width, snap.cellSize.height, snap.margin, snap.spacing);
2458
+ if (!ts)
2459
+ continue;
2460
+ const layerObj = map.createLayer(0, ts, 0, 0);
2461
+ if (!layerObj)
2462
+ continue;
2463
+ if (snap.cellSize.width !== tileSize.width || snap.cellSize.height !== tileSize.height) {
2464
+ layerObj.setScale(tileSize.width / snap.cellSize.width, tileSize.height / snap.cellSize.height);
2465
+ }
2466
+ layerObj.x = container.x + left;
2467
+ layerObj.y = container.y + top;
2468
+ if (!snap.visible)
2469
+ layerObj.setVisible(false);
2470
+ layerObj.setData('tilemapLayerId', snap.id);
2471
+ const entityId = container.getData('entityId');
2472
+ if (entityId)
2473
+ layerObj.setData('tilemapEntityId', entityId);
2474
+ layerObj.setData('tilemapLocalOffsetX', left);
2475
+ layerObj.setData('tilemapLocalOffsetY', top);
2476
+ newLayers.push(layerObj);
2477
+ }
2478
+ container.setData('tilemapLayers', newLayers);
2479
+ // Redraw the background grid sketch inside the container. The
2480
+ // grid sketch is the first non-null Graphics child of the container
2481
+ // (added in createTilemap). We rebuild it in place to match new
2482
+ // dims. Without this, the visual bounds would still show the
2483
+ // pre-resize rectangle even though paintable cells are at the
2484
+ // new dims — confusing for the user.
2485
+ const cellsW = newW;
2486
+ const cellsH = newH;
2487
+ for (const child of container.list) {
2488
+ if (child instanceof Phaser.GameObjects.Graphics) {
2489
+ child.clear();
2490
+ child.fillStyle(0xffffff, 0.04);
2491
+ child.fillRect(left, top, pxW, pxH);
2492
+ child.lineStyle(2, 0xaaaaaa, 0.45);
2493
+ child.strokeRect(left, top, pxW, pxH);
2494
+ if (tileSize.width >= 8 && cellsW * cellsH < 4000) {
2495
+ child.lineStyle(1, 0xaaaaaa, 0.1);
2496
+ for (let i = 1; i < cellsW; i++) {
2497
+ const x = left + i * tileSize.width;
2498
+ child.beginPath();
2499
+ child.moveTo(x, top);
2500
+ child.lineTo(x, top + pxH);
2501
+ child.strokePath();
2502
+ }
2503
+ for (let i = 1; i < cellsH; i++) {
2504
+ const y = top + i * tileSize.height;
2505
+ child.beginPath();
2506
+ child.moveTo(left, y);
2507
+ child.lineTo(left + pxW, y);
2508
+ child.strokePath();
2509
+ }
2510
+ }
2511
+ break; // only the grid sketch — there are no other Graphics children
2512
+ }
2513
+ }
2514
+ break;
2515
+ }
2516
+ }
2517
+ }
2518
+ /**
2519
+ * Find the TilemapLayer tagged with `layerId` belonging to the given tilemap
2520
+ * container. Layers live in scene root (not as container children) because
2521
+ * Phaser's TilemapLayer doesn't inherit parent Container transforms — see
2522
+ * `createTilemap` for the rationale. The container's data manager stashes
2523
+ * a `tilemapLayers` array of refs so we can find them without a scene walk.
2524
+ */
2525
+ export function findTilemapLayerById(container, layerId) {
2526
+ const layers = container.getData('tilemapLayers');
2527
+ if (!layers)
2528
+ return null;
2529
+ for (const layer of layers) {
2530
+ if (layer.getData('tilemapLayerId') === layerId)
2531
+ return layer;
2532
+ }
2533
+ return null;
2534
+ }
2535
+ /**
2536
+ * Apply a single op to a live TilemapLayer using Phaser's native mutation
2537
+ * APIs. All ops are in-place — no full re-render needed. Out-of-bounds
2538
+ * cells are tolerated by Phaser's `putTileAt` (no-op outside layer bounds).
2539
+ *
2540
+ * The optional `asset` arg is required for `autotilePaint` (the only op
2541
+ * that needs to resolve a terrain's ruleMap); other ops ignore it.
2542
+ */
2543
+ export function applyTilemapOp(layer, op, asset) {
2544
+ switch (op.kind) {
2545
+ case 'paint':
2546
+ for (const c of op.cells)
2547
+ layer.putTileAt(c.index, c.x, c.y);
2548
+ break;
2549
+ case 'erase':
2550
+ for (const c of op.cells)
2551
+ layer.removeTileAt(c.x, c.y);
2552
+ break;
2553
+ case 'autotilePaint': {
2554
+ const terrain = findTerrain(asset, op.terrainId);
2555
+ if (!terrain) {
2556
+ console.warn(`[umicat/editor] autotilePaint: tileset has no terrain '${op.terrainId}' (asset=${asset?.id ?? '<unknown>'})`);
2557
+ return;
2558
+ }
2559
+ const mode = op.erase ? 'erase' : 'paint';
2560
+ const kind = getAutotileKind(asset);
2561
+ for (const c of op.cells)
2562
+ applyAutotile(layer, c.x, c.y, terrain, mode, kind);
2563
+ break;
2564
+ }
2565
+ case 'fillRect':
2566
+ if (typeof op.index === 'number') {
2567
+ layer.fill(op.index, op.x, op.y, op.w, op.h);
2568
+ }
2569
+ else {
2570
+ // No index → erase-rect. Phaser's `fill` with -1 sets every cell
2571
+ // in the region to "no tile", same effect as removeTileAt per cell.
2572
+ layer.fill(-1, op.x, op.y, op.w, op.h);
2573
+ }
2574
+ break;
2575
+ case 'bucketFill': {
2576
+ // Phaser doesn't ship a flood-fill primitive — walk the layer ourselves
2577
+ // from the seed cell, replacing matching cells 4-connected. Bounded by
2578
+ // layer dims so degenerate input (huge layer all one tile) doesn't
2579
+ // run unbounded.
2580
+ const seed = layer.getTileAt(op.x, op.y, true);
2581
+ const startIndex = seed ? seed.index : -1;
2582
+ if (startIndex === op.index)
2583
+ return; // already painted with target
2584
+ const w = layer.tilemap.width;
2585
+ const h = layer.tilemap.height;
2586
+ const stack = [[op.x, op.y]];
2587
+ let safety = 0;
2588
+ while (stack.length > 0 && safety++ < w * h) {
2589
+ const [cx, cy] = stack.pop();
2590
+ if (cx < 0 || cy < 0 || cx >= w || cy >= h)
2591
+ continue;
2592
+ const tile = layer.getTileAt(cx, cy, true);
2593
+ const idx = tile ? tile.index : -1;
2594
+ if (idx !== startIndex)
2595
+ continue;
2596
+ layer.putTileAt(op.index, cx, cy);
2597
+ stack.push([cx + 1, cy], [cx - 1, cy], [cx, cy + 1], [cx, cy - 1]);
2598
+ }
2599
+ break;
2600
+ }
2601
+ }
2602
+ }
2603
+ // --- postMessage helper ---------------------------------------------------
2604
+ function postToHost(msg) {
2605
+ if (typeof window === 'undefined' || !window.parent)
2606
+ return;
2607
+ window.parent.postMessage(msg, '*');
2608
+ }