@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,1896 @@
1
+ import Phaser from 'phaser';
2
+ import { getEntityRegistry } from '../scene/EntityRegistry.js';
3
+ import { findHudEntity, findHudRegistry, UMICAT_HUD_SCENE_KEY, } from '../scene/HudRuntime.js';
4
+ import { isPerFrameHitbox } from '../scene/types.js';
5
+ import { getManifest } from '../scene/SceneLoader.js';
6
+ import { getEditorState, startDrag, clearDrag, getDrag, getSelection, getEditorMode, getDebugOverlayState, getTilemapToolState, isTilemapPaintMode, beginTilemapStroke, beginAutotileStroke, appendTilemapStrokeCell, appendAutotileStrokeClick, endTilemapStroke, getTilemapStroke, beginTilemapRect, updateTilemapRect, endTilemapRect, getTilemapRect, getTilemapResize, beginTilemapResize, updateTilemapResize, endTilemapResize, } from './EditorState.js';
7
+ import { applyTilemapOp, findTilemapLayerById, handleEditTilemap } from './EditorBridge.js';
8
+ import { applyAutotile, findTerrain, getAutotileKind } from '../scene/autotile.js';
9
+ /**
10
+ * Editor overlay — slice 2.
11
+ *
12
+ * Runs ABOVE the world/HUD scenes. Its job:
13
+ *
14
+ * 1. Render selection rectangle around the currently selected entity
15
+ * 2. Render world bounds rectangle (visual cue for "edge of the game world")
16
+ * 3. Capture pointer events:
17
+ * - pointerdown on an entity → posts pickEntity to host
18
+ * - drag → mutates the entity's x/y in real time (visual only)
19
+ * - pointerup → posts dragEnd with before/after to host
20
+ *
21
+ * The overlay scene is launched by EditorBridge when entering Edit mode,
22
+ * stopped on exit. It holds no persistent state of its own — selection +
23
+ * drag info live in the shared EditorState attached to the Phaser game.
24
+ */
25
+ export const EDITOR_OVERLAY_KEY = '__UmicatEditorOverlay';
26
+ const SELECTION_COLOR = 0x4662d8;
27
+ const SELECTION_ALPHA = 1;
28
+ const SELECTION_LINE_WIDTH = 2;
29
+ const WORLD_BOUNDS_COLOR = 0x888888;
30
+ const WORLD_BOUNDS_ALPHA = 0.7;
31
+ // (OUTSIDE_FILL_COLOR / OUTSIDE_FILL_ALPHA moved to EditorBridge as
32
+ // VOID_FILL_COLOR / VOID_FILL_ALPHA — the fill is now drawn in the
33
+ // world scene at low depth, not in the overlay, so entities render on
34
+ // top of it. 2026-05-17 fix.)
35
+ // P1 — camera viewport rect ("Camera screen" in Godot terms): shows where
36
+ // the game's runtime camera is currently looking. Blue, matches Godot 2D
37
+ // editor's Camera2D editor_draw_screen rect.
38
+ const CAMERA_VIEWPORT_COLOR = 0x4488ee;
39
+ const CAMERA_VIEWPORT_ALPHA = 0.9;
40
+ const CAMERA_VIEWPORT_LINE_WIDTH = 2;
41
+ // FB.10 polish — CSS cursor name per tilemap resize handle. Corner handles
42
+ // get the diagonal arrows (`nwse-resize` ↖↘ for NW/SE, `nesw-resize` ↗↙ for
43
+ // NE/SW). Edge handles get the axis arrows (ns / ew). Same conventions as
44
+ // Figma / Sketch / native HTML resize.
45
+ const RESIZE_CURSOR_FOR_HANDLE = {
46
+ nw: 'nwse-resize',
47
+ se: 'nwse-resize',
48
+ ne: 'nesw-resize',
49
+ sw: 'nesw-resize',
50
+ n: 'ns-resize',
51
+ s: 'ns-resize',
52
+ e: 'ew-resize',
53
+ w: 'ew-resize',
54
+ };
55
+ // Slice 8: "Show hitboxes" debug overlay colors. Green for hitboxes (matches
56
+ // Hitbox Editor canvas), cyan for depth-anchor crosshairs (distinct + visible
57
+ // on most sprite backgrounds).
58
+ const HITBOX_FILL_COLOR = 0x33dd55;
59
+ const HITBOX_FILL_ALPHA = 0.25;
60
+ const HITBOX_STROKE_COLOR = 0x22aa44;
61
+ const HITBOX_STROKE_ALPHA = 0.9;
62
+ const ANCHOR_COLOR = 0x00d0ff;
63
+ const ANCHOR_ALPHA = 1;
64
+ const ANCHOR_CROSS_LEN = 6;
65
+ export class EditorOverlayScene extends Phaser.Scene {
66
+ constructor() {
67
+ super({ key: EDITOR_OVERLAY_KEY });
68
+ // P1 infinite canvas — input state. Pan can be triggered by middle-button
69
+ // drag OR by space+left-drag. We accumulate a "pan active" flag so the
70
+ // existing selection / drag-to-move handlers don't fire while panning.
71
+ this.panActive = false;
72
+ this.spaceHeld = false;
73
+ this.panStartScreen = null;
74
+ this.panStartScroll = null;
75
+ this.handleShortcut = (e) => {
76
+ // Don't swallow keys when the user is typing in a real form control —
77
+ // shouldn't happen inside the iframe, but be defensive.
78
+ const target = e.target;
79
+ if (target) {
80
+ const tag = target.tagName;
81
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT')
82
+ return;
83
+ if (target.isContentEditable)
84
+ return;
85
+ }
86
+ const k = e.key;
87
+ if (e.metaKey || e.ctrlKey) {
88
+ const lower = k.toLowerCase();
89
+ if (lower === 'z') {
90
+ e.preventDefault();
91
+ this.postShortcut(e.shiftKey ? 'redo' : 'undo');
92
+ }
93
+ else if (lower === 'y') {
94
+ e.preventDefault();
95
+ this.postShortcut('redo');
96
+ }
97
+ else if (lower === 's') {
98
+ e.preventDefault();
99
+ this.postShortcut('save');
100
+ }
101
+ return;
102
+ }
103
+ if (k === 'Backspace' || k === 'Delete') {
104
+ // Suppress default (Backspace = browser back-nav inside iframe).
105
+ e.preventDefault();
106
+ this.postShortcut('delete');
107
+ }
108
+ };
109
+ }
110
+ init(data) {
111
+ this.worldBounds = data.worldBounds;
112
+ this.hitTest = data.hitTest;
113
+ this.hitTestAll = data.hitTestAll;
114
+ this.postPick = data.postPick;
115
+ this.postDragEnd = data.postDragEnd;
116
+ this.postShortcut = data.postShortcut;
117
+ this.applyPanZoom = data.applyPanZoom;
118
+ }
119
+ create() {
120
+ this.graphics = this.add.graphics();
121
+ // Mirror the EDITOR camera (P1 infinite canvas, 2026-05-17). Falls back
122
+ // to the world scene's cameras.main on the very first frame when the
123
+ // editor cam hasn't been installed yet (enterEdit order may race) —
124
+ // `applyEditorPanZoom`'s subsequent mutations will keep the overlay
125
+ // cam in sync.
126
+ const sourceCam = this.findEditorCamera() ?? this.findWorldSceneCamera();
127
+ if (sourceCam) {
128
+ this.cameras.main.setScroll(sourceCam.scrollX, sourceCam.scrollY);
129
+ this.cameras.main.setZoom(sourceCam.zoom);
130
+ }
131
+ this.input.on('pointerdown', this.handlePointerDown, this);
132
+ this.input.on('pointermove', this.handlePointerMove, this);
133
+ this.input.on('pointerup', this.handlePointerUp, this);
134
+ // P1 infinite canvas — wheel for zoom (cursor-anchored), middle-button
135
+ // and space+drag for pan. Only active in world mode (HUD's identity
136
+ // camera doesn't pan/zoom). Wired via native DOM event on the canvas
137
+ // because Phaser's pointer events don't surface `wheel`.
138
+ this.installPanZoomInput();
139
+ // Forward editor shortcuts back to the host. The user clicks the canvas
140
+ // to select an entity, which moves focus into the iframe — after that,
141
+ // native Cmd+Z lands here, not on the host's window. We catch it before
142
+ // Phaser's keyboard plugin gets it (DOM phase is fine because Phaser
143
+ // listens via its own KeyboardManager), preventDefault'ing browser
144
+ // back-nav-on-Cmd+Left etc. is unnecessary; we only intercept the few
145
+ // editor keys we care about.
146
+ if (typeof document !== 'undefined') {
147
+ document.addEventListener('keydown', this.handleShortcut, true);
148
+ this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => {
149
+ document.removeEventListener('keydown', this.handleShortcut, true);
150
+ });
151
+ }
152
+ }
153
+ /**
154
+ * Pointer coords mode-aware. World mode uses world coords (camera-relative);
155
+ * HUD mode transforms pointer through the HUD scene's camera to land in
156
+ * HUD-intrinsic coords (the coord space widget positions live in).
157
+ *
158
+ * Pre-0.2.90 this returned `pointer.x/y` (canvas pixels) for HUD mode
159
+ * — fine when HUD cam was an identity camera (viewport == canvas,
160
+ * zoom == 1). But 0.2.89 changed HUD cam to render INSIDE the camera-
161
+ * viewport rect on the editor canvas with editor-cam zoom, so canvas
162
+ * pixels no longer equal HUD-intrinsic coords. Hit-test against
163
+ * widgets (positioned in HUD-intrinsic) would only match a tiny
164
+ * patch near the canvas top-left, hence the user's "have to click
165
+ * many times" complaint. Using `hudCam.getWorldPoint(...)` applies
166
+ * the cam's inverse transform (subtract viewport offset, divide by
167
+ * zoom) and lands in widget coord space.
168
+ */
169
+ pointerCoords(pointer) {
170
+ if (getEditorMode(this.game) === 'hud') {
171
+ const hudScene = this.game.scene.getScene(UMICAT_HUD_SCENE_KEY);
172
+ const hudCam = hudScene?.cameras.main;
173
+ if (hudCam) {
174
+ const p = hudCam.getWorldPoint(pointer.x, pointer.y);
175
+ return { x: p.x, y: p.y };
176
+ }
177
+ return { x: pointer.x, y: pointer.y };
178
+ }
179
+ return { x: pointer.worldX, y: pointer.worldY };
180
+ }
181
+ handlePointerDown(pointer) {
182
+ const state = getEditorState(this.game);
183
+ if (!state.active)
184
+ return;
185
+ // P1 infinite canvas — pan trigger: middle button OR space+left.
186
+ // Allowed in both world AND hud edit modes (2026-05-17 fix). The
187
+ // pan only mutates the EDITOR cam (world scene), so HUD widgets
188
+ // (rendered by HUD's separate identity camera) stay anchored to
189
+ // their canvas-pixel positions — pan doesn't visually move them.
190
+ // User gets to see HUD overlaid on different world-pan states.
191
+ const event = pointer.event;
192
+ const isMiddleButton = pointer.button === 1;
193
+ const isSpaceDrag = this.spaceHeld && pointer.button === 0;
194
+ if (isMiddleButton || isSpaceDrag) {
195
+ this.panActive = true;
196
+ this.panStartScreen = { x: pointer.x, y: pointer.y };
197
+ const editorCam = this.findActiveEditorCamera();
198
+ this.panStartScroll = editorCam
199
+ ? { x: editorCam.scrollX, y: editorCam.scrollY }
200
+ : { x: 0, y: 0 };
201
+ event?.preventDefault?.();
202
+ return;
203
+ }
204
+ const { x: px, y: py } = this.pointerCoords(pointer);
205
+ // FB.9a — Alt+click cycles through overlapping entities. When 2+
206
+ // entities visually overlap at the cursor, normal selection always
207
+ // picks topmost; Alt+click instead picks the entity just under
208
+ // (lower depth than) the current selection in the hit stack. Wraps
209
+ // around at the bottom. Standard Figma / Sketch / XD convention.
210
+ // Runs BEFORE paint-mode dispatch — Alt is a deliberate "show me
211
+ // something else under here" gesture; user can always click without
212
+ // Alt afterward to paint.
213
+ if (event?.altKey && !event?.metaKey && !event?.ctrlKey) {
214
+ const stack = this.hitTestAll(px, py);
215
+ if (stack.length > 0) {
216
+ const currentSelectedId = getSelection(this.game);
217
+ const currentIdx = stack.findIndex((go) => go.getData('entityId') === currentSelectedId);
218
+ // First Alt+click on this spot (current selection not in stack) →
219
+ // pick topmost. Subsequent Alt+clicks → cycle down through stack;
220
+ // after the bottommost, wrap back to topmost.
221
+ const nextIdx = currentIdx === -1 ? 0 : (currentIdx + 1) % stack.length;
222
+ const nextEntityId = stack[nextIdx].getData('entityId');
223
+ if (nextEntityId) {
224
+ this.postPick(nextEntityId, {
225
+ shift: !!event.shiftKey,
226
+ cmdOrCtrl: false,
227
+ alt: true,
228
+ });
229
+ event.preventDefault?.();
230
+ return;
231
+ }
232
+ }
233
+ }
234
+ // Slice 6 Phase B.6.1: resize-handle hit-test takes priority over
235
+ // BOTH paint and entity selection. Handles are drawn on top of the
236
+ // tilemap bounds when paint mode is active. Click+drag → resize the
237
+ // tilemap; one-edge-anchored (opposite edge stays fixed).
238
+ if (isTilemapPaintMode(this.game)) {
239
+ const handle = this.hitTestTilemapResizeHandle(px, py);
240
+ if (handle) {
241
+ this.beginTilemapResizeDrag(handle);
242
+ event?.preventDefault?.();
243
+ return;
244
+ }
245
+ }
246
+ // Slice 6 Phase B: tilemap paint mode hijacks pointerdown — clicks
247
+ // paint cells instead of selecting / dragging entities. Check before
248
+ // hitTest so a click on the tilemap doesn't reselect-and-drag-itself.
249
+ // Mode is true only when (a) the selected entity is a tilemap AND
250
+ // (b) the host has pushed a TilemapTool state with a non-null layer.
251
+ if (isTilemapPaintMode(this.game)) {
252
+ const handled = this.handleTilemapPaintDown(px, py);
253
+ if (handled) {
254
+ event?.preventDefault?.();
255
+ return;
256
+ }
257
+ // Pointer fell outside the tilemap bounds — fall through to normal
258
+ // selection so the user can click out of paint mode by clicking
259
+ // another entity / empty canvas.
260
+ }
261
+ const hit = this.hitTest(px, py);
262
+ const modifiers = {
263
+ shift: !!event?.shiftKey,
264
+ cmdOrCtrl: !!(event?.metaKey || event?.ctrlKey),
265
+ alt: !!event?.altKey,
266
+ };
267
+ if (!hit) {
268
+ this.postPick(null, modifiers);
269
+ return;
270
+ }
271
+ const entityId = hit.getData('entityId');
272
+ if (!entityId) {
273
+ this.postPick(null, modifiers);
274
+ return;
275
+ }
276
+ // Surface the prefab id when the picked GameObject was spawned via
277
+ // `spawnPrefab` (set on the data manager at spawn — see Prefabs.ts).
278
+ // The host routes prefab-instance picks to the Prefab Inspector.
279
+ const prefabId = hit.getData('entityPrefabId');
280
+ this.postPick(entityId, modifiers, prefabId);
281
+ // Begin drag immediately on pointerdown (drag threshold can be added later).
282
+ // In HUD mode `startEntity` carries the entity's anchor offset (not the
283
+ // GameObject's canvas-pixel position) so dragEnd can report the new
284
+ // offset values directly.
285
+ if (getEditorMode(this.game) === 'hud') {
286
+ const ent = findHudEntity(this.game, entityId);
287
+ const startOffset = {
288
+ x: ent?.anchor.offsetX ?? 0,
289
+ y: ent?.anchor.offsetY ?? 0,
290
+ };
291
+ startDrag(this.game, entityId, { x: px, y: py }, startOffset);
292
+ }
293
+ else {
294
+ const target = hit;
295
+ startDrag(this.game, entityId, { x: px, y: py }, { x: target.x, y: target.y });
296
+ }
297
+ }
298
+ handlePointerMove(pointer) {
299
+ // FB.10 polish — update canvas cursor to indicate resize when hovering
300
+ // a tilemap resize handle. Done first so cursor reflects the immediate
301
+ // visual state regardless of which branch we hit below.
302
+ this.updateResizeCursor(pointer);
303
+ // P1 infinite canvas — pan in progress: translate screen delta into
304
+ // world delta via the current zoom, set scroll absolute (relative-mode
305
+ // would accumulate floating-point drift over many move events).
306
+ if (this.panActive && this.panStartScreen && this.panStartScroll) {
307
+ const editorCam = this.findActiveEditorCamera();
308
+ const zoom = editorCam?.zoom ?? 1;
309
+ const dxScreen = pointer.x - this.panStartScreen.x;
310
+ const dyScreen = pointer.y - this.panStartScreen.y;
311
+ this.applyPanZoom({
312
+ scrollX: this.panStartScroll.x - dxScreen / zoom,
313
+ scrollY: this.panStartScroll.y - dyScreen / zoom,
314
+ });
315
+ return;
316
+ }
317
+ // Slice 6 Phase B.6.1: resize-handle drag in flight. Update preview
318
+ // dims based on cursor position; ghost rect renders in update().
319
+ if (getTilemapResize(this.game)) {
320
+ const { x: px, y: py } = this.pointerCoords(pointer);
321
+ this.updateTilemapResizeDrag(px, py);
322
+ return;
323
+ }
324
+ // Slice 6 Phase B: continue an in-flight tilemap stroke (brush/eraser
325
+ // drag) OR rect drag. handleTilemapPaintMove already branches on
326
+ // which one is active internally — we just need to route to it
327
+ // whenever EITHER is in flight. Without checking getTilemapRect
328
+ // here, rect-drag pointermoves would fall through to entity-drag
329
+ // logic (which finds no drag since paint mode preempted startDrag),
330
+ // and the rect endpoint would never update → release always paints
331
+ // a 1×1 rect (single tile).
332
+ if (getTilemapStroke(this.game) || getTilemapRect(this.game)) {
333
+ const { x: px, y: py } = this.pointerCoords(pointer);
334
+ this.handleTilemapPaintMove(px, py);
335
+ return;
336
+ }
337
+ const drag = getDrag(this.game);
338
+ if (!drag)
339
+ return;
340
+ const registry = this.findEntityRegistry();
341
+ if (!registry)
342
+ return;
343
+ const go = registry.byId(drag.entityId);
344
+ if (!go)
345
+ return;
346
+ const { x: px, y: py } = this.pointerCoords(pointer);
347
+ const dx = px - drag.startWorld.x;
348
+ const dy = py - drag.startWorld.y;
349
+ if (getEditorMode(this.game) === 'hud') {
350
+ // HUD `startEntity` carries the offset, not the canvas-pixel start
351
+ // pos. We track per-frame delta on the GO so each pointermove just
352
+ // applies the incremental nudge — `go.x = startGoPos.x + dx` doesn't
353
+ // work because startGoPos isn't stored. The visual position the user
354
+ // sees is the canvas pos derived from the live anchor base + delta.
355
+ const lastDx = go.__lastDragDx ?? 0;
356
+ const lastDy = go.__lastDragDy ?? 0;
357
+ go.x += dx - lastDx;
358
+ go.y += dy - lastDy;
359
+ go.__lastDragDx = dx;
360
+ go.__lastDragDy = dy;
361
+ }
362
+ else {
363
+ go.x = drag.startEntity.x + dx;
364
+ go.y = drag.startEntity.y + dy;
365
+ }
366
+ }
367
+ handlePointerUp(pointer) {
368
+ if (this.panActive) {
369
+ this.panActive = false;
370
+ this.panStartScreen = null;
371
+ this.panStartScroll = null;
372
+ return;
373
+ }
374
+ // Slice 6 Phase B.6.1: end a resize-handle drag — commit the final
375
+ // resize op (size + transformDelta to anchor opposite edge).
376
+ if (getTilemapResize(this.game)) {
377
+ this.commitTilemapResize();
378
+ return;
379
+ }
380
+ // Slice 6 Phase B: end an in-flight tilemap stroke (brush/eraser) OR
381
+ // rect drag. Both compose a single TilemapEditOp + post `tilemapEdited`
382
+ // so undo reverts the whole stroke/rect in one step.
383
+ if (getTilemapStroke(this.game) || getTilemapRect(this.game)) {
384
+ this.handleTilemapPaintUp();
385
+ return;
386
+ }
387
+ const drag = getDrag(this.game);
388
+ if (!drag)
389
+ return;
390
+ const { x: px, y: py } = this.pointerCoords(pointer);
391
+ const dx = px - drag.startWorld.x;
392
+ const dy = py - drag.startWorld.y;
393
+ const before = drag.startEntity;
394
+ const after = { x: drag.startEntity.x + dx, y: drag.startEntity.y + dy };
395
+ // Reset the per-drag delta accumulator on the GO if HUD mode left one.
396
+ if (getEditorMode(this.game) === 'hud') {
397
+ const reg = this.findEntityRegistry();
398
+ const go = reg?.byId(drag.entityId);
399
+ if (go) {
400
+ go.__lastDragDx = 0;
401
+ go.__lastDragDy = 0;
402
+ }
403
+ }
404
+ clearDrag(this.game);
405
+ if (Math.abs(dx) < 0.5 && Math.abs(dy) < 0.5)
406
+ return;
407
+ this.postDragEnd(drag.entityId, before, after);
408
+ }
409
+ // --- Slice 6 Phase B — tilemap painter -----------------------------------
410
+ /**
411
+ * Pointer-down inside paint mode. Returns true when the pointer hit the
412
+ * active layer (stroke began); false when the pointer fell outside the
413
+ * tilemap's bounds (caller falls through to normal selection).
414
+ *
415
+ * Begins a stroke + applies the first cell. brush/eraser tools paint
416
+ * incrementally per pointermove; rect/fill/picker (Phase B.4) handle
417
+ * pointerdown specially — for now only brush + eraser are wired.
418
+ */
419
+ handleTilemapPaintDown(worldX, worldY) {
420
+ const tool = getTilemapToolState(this.game);
421
+ if (!tool.tilemapEditingId || !tool.activeLayerId) {
422
+ // Paint mode is active per `isTilemapPaintMode` (which gated the caller)
423
+ // but the per-tool state is incomplete. This means the host pushed an
424
+ // intermediate state — fall through to drag is OK here.
425
+ return false;
426
+ }
427
+ const resolved = this.findActiveTilemapLayerWithContainer(tool.tilemapEditingId, tool.activeLayerId);
428
+ if (!resolved) {
429
+ console.warn(`[umicat/editor] tilemap paint: active layer '${tool.activeLayerId}' on entity '${tool.tilemapEditingId}' not found in scene — falling through to drag. ` +
430
+ `Likely cause: layer ref stale after a structure op (addLayer/removeLayer) failed to update the host's activeLayerId.`);
431
+ return false;
432
+ }
433
+ const { layer, container } = resolved;
434
+ // Layers live in scene root (not as Container children) and are
435
+ // positioned at WORLD coords via `installTilemapLayerSync`. So
436
+ // worldToTileXY operates on raw world coords directly — no coord
437
+ // shift needed.
438
+ const tile = layer.worldToTileXY(worldX, worldY, true);
439
+ if (tile.x < 0 || tile.y < 0 ||
440
+ tile.x >= layer.tilemap.width || tile.y >= layer.tilemap.height) {
441
+ // Click outside the active layer's bounds — fall-through to entity
442
+ // selection is the right behavior (user clicked off the tilemap).
443
+ return false;
444
+ }
445
+ // ---- From here on, click is INSIDE the tilemap bounds. ----
446
+ //
447
+ // Paint dispatch must either succeed (return true) OR no-op + warn
448
+ // (also return true so the click is consumed). NEVER fall through to
449
+ // drag — that's the recurring "selected terrain + clicked brush, but
450
+ // the tilemap drags instead of painting" bug. The user's click
451
+ // intent is unambiguously "paint" once we're past the bounds check.
452
+ // Drag-vs-paint policy:
453
+ //
454
+ // armed = (activeTerrainId != null) || (activeTile != null)
455
+ // || (tool === 'eraser') // eraser doesn't need a tile
456
+ // || (tool === 'picker') // picker reads, doesn't need a tile
457
+ //
458
+ // - armed=false → user has nothing loaded in the brush AND the tool
459
+ // isn't one that works without a tile. Fall through to entity
460
+ // selection/drag — that's how the user moves the tilemap.
461
+ // - armed=true → user expects to interact with the tilemap. If dispatch
462
+ // fails (terrain unresolved, etc.), CONSUME the click + warn. Never
463
+ // silently drag the tilemap — that was the recurring "selected
464
+ // terrain but brush doesn't paint, it drags" bug.
465
+ //
466
+ // Eraser is armed-by-tool because its job is "erase the cell I clicked"
467
+ // — no activeTile required. Same for picker (eyedropper reads the cell).
468
+ // Brush / rect / fill REQUIRE activeTile or activeTerrainId; without
469
+ // either, click is unambiguously a drag intent.
470
+ const armed = tool.activeTerrainId != null ||
471
+ tool.activeTile != null ||
472
+ tool.tool === 'eraser' ||
473
+ tool.tool === 'picker';
474
+ if (!armed) {
475
+ // Nothing to paint with — let the click flow to entity drag.
476
+ return false;
477
+ }
478
+ switch (tool.tool) {
479
+ case 'brush': {
480
+ if (tool.activeTerrainId != null) {
481
+ const ctx = this.resolveAutotileTerrainContext(layer, tool.activeTerrainId);
482
+ if (ctx) {
483
+ beginAutotileStroke(this.game, tool.tilemapEditingId, tool.activeLayerId, tool.activeTerrainId,
484
+ /* erase */ false);
485
+ this.applyAutotileStrokeCell(layer, tile.x, tile.y, ctx);
486
+ return true;
487
+ }
488
+ // Terrain context unresolved — warn already emitted. Consume
489
+ // (we're armed) so the tilemap doesn't drag.
490
+ return true;
491
+ }
492
+ // tool.activeTile is non-null per `armed` check above.
493
+ beginTilemapStroke(this.game, tool.tilemapEditingId, tool.activeLayerId);
494
+ this.applyStrokeCell(layer, tile.x, tile.y, tool);
495
+ return true;
496
+ }
497
+ case 'eraser': {
498
+ // Eraser is always "armed" — it doesn't need activeTile. But to
499
+ // keep drag-when-idle working, eraser must EITHER have a terrain
500
+ // (autotile erase) OR be the explicitly-chosen tool (user picked
501
+ // eraser from the palette → intent is clearly to erase).
502
+ // The `armed` gate above already required activeTile|terrain, so
503
+ // bare eraser with neither falls through to drag. The user picks
504
+ // eraser → tool is 'eraser' but armed=false → drag wins. That's
505
+ // fine: if you want to erase, pick eraser AND keep activeTile/
506
+ // terrain set so we know you mean "erase paint", not "move".
507
+ if (tool.activeTerrainId != null) {
508
+ const ctx = this.resolveAutotileTerrainContext(layer, tool.activeTerrainId);
509
+ if (ctx) {
510
+ beginAutotileStroke(this.game, tool.tilemapEditingId, tool.activeLayerId, tool.activeTerrainId,
511
+ /* erase */ true);
512
+ this.applyAutotileStrokeCell(layer, tile.x, tile.y, ctx);
513
+ return true;
514
+ }
515
+ return true;
516
+ }
517
+ beginTilemapStroke(this.game, tool.tilemapEditingId, tool.activeLayerId);
518
+ this.applyStrokeCell(layer, tile.x, tile.y, tool);
519
+ return true;
520
+ }
521
+ case 'rect': {
522
+ if (tool.activeTerrainId != null) {
523
+ const ctx = this.resolveAutotileTerrainContext(layer, tool.activeTerrainId);
524
+ if (ctx) {
525
+ beginTilemapRect(this.game, tool.tilemapEditingId, tool.activeLayerId, tile.x, tile.y, { terrainId: tool.activeTerrainId, erase: false });
526
+ return true;
527
+ }
528
+ return true;
529
+ }
530
+ beginTilemapRect(this.game, tool.tilemapEditingId, tool.activeLayerId, tile.x, tile.y);
531
+ return true;
532
+ }
533
+ case 'fill': {
534
+ if (tool.activeTerrainId != null) {
535
+ const ctx = this.resolveAutotileTerrainContext(layer, tool.activeTerrainId);
536
+ if (ctx) {
537
+ this.applyAutotileBucketFill(layer, tile.x, tile.y, ctx, tool);
538
+ return true;
539
+ }
540
+ return true;
541
+ }
542
+ this.applyBucketFill(layer, tile.x, tile.y, tool.activeTile, tool);
543
+ return true;
544
+ }
545
+ case 'picker': {
546
+ // Eyedropper — read cell + post to host.
547
+ const t = layer.getTileAt(tile.x, tile.y, true);
548
+ const tileIndex = t && t.index >= 0 ? t.index : null;
549
+ const msg = {
550
+ type: 'umicat:editor:tilemapTilePicked',
551
+ entityId: tool.tilemapEditingId,
552
+ layerId: tool.activeLayerId,
553
+ tileIndex,
554
+ };
555
+ if (typeof window !== 'undefined' && window.parent) {
556
+ window.parent.postMessage(msg, '*');
557
+ }
558
+ return true;
559
+ }
560
+ }
561
+ return false;
562
+ }
563
+ handleTilemapPaintMove(worldX, worldY) {
564
+ // Rect-drag in flight — update preview rect end point; clamp into layer
565
+ // bounds so the preview never extends past the tilemap.
566
+ const rect = getTilemapRect(this.game);
567
+ if (rect) {
568
+ const resolved = this.findActiveTilemapLayerWithContainer(rect.entityId, rect.layerId);
569
+ if (!resolved)
570
+ return;
571
+ const { layer, container } = resolved;
572
+ const tile = layer.worldToTileXY(worldX, worldY, true);
573
+ const clampedX = Math.max(0, Math.min(layer.tilemap.width - 1, tile.x));
574
+ const clampedY = Math.max(0, Math.min(layer.tilemap.height - 1, tile.y));
575
+ updateTilemapRect(this.game, clampedX, clampedY);
576
+ return;
577
+ }
578
+ // Brush/eraser stroke in flight — paint one more cell. Layers are now
579
+ // world-positioned (see createTilemap) so worldToTileXY takes raw
580
+ // world coords — no container-offset shift needed.
581
+ const stroke = getTilemapStroke(this.game);
582
+ if (!stroke)
583
+ return;
584
+ const tool = getTilemapToolState(this.game);
585
+ const layer = this.findActiveTilemapLayer(stroke.entityId, stroke.layerId);
586
+ if (!layer)
587
+ return;
588
+ const tile = layer.worldToTileXY(worldX, worldY, true);
589
+ if (tile.x < 0 || tile.y < 0 ||
590
+ tile.x >= layer.tilemap.width || tile.y >= layer.tilemap.height) {
591
+ return;
592
+ }
593
+ // Slice 6 Phase D — drag-paint continues in the stroke's mode.
594
+ if (stroke.autotile) {
595
+ const ctx = this.resolveAutotileTerrainContext(layer, stroke.autotile.terrainId);
596
+ if (!ctx)
597
+ return;
598
+ this.applyAutotileStrokeCell(layer, tile.x, tile.y, ctx);
599
+ return;
600
+ }
601
+ this.applyStrokeCell(layer, tile.x, tile.y, tool);
602
+ }
603
+ /**
604
+ * Compose stroke into one TilemapEditOp + post to host. Host records the
605
+ * undo command + persists on next flush. Stroke state is cleared.
606
+ *
607
+ * Brush stroke → `paint` op with all cells. Eraser stroke → `erase` op.
608
+ * (Phase B.4 adds rect / fill emit paths.)
609
+ */
610
+ handleTilemapPaintUp() {
611
+ // Rect-drag takes priority — only one of (stroke, rect) is ever in
612
+ // flight, but check rect first so a partial brush stroke doesn't
613
+ // accidentally swallow a rect commit (defensive).
614
+ const rect = endTilemapRect(this.game);
615
+ if (rect) {
616
+ this.commitTilemapRect(rect);
617
+ return;
618
+ }
619
+ const stroke = endTilemapStroke(this.game);
620
+ if (!stroke || stroke.cells.length === 0)
621
+ return;
622
+ // Slice 6 Phase D — autotile-mode stroke. Emit a single `autotilePaint`
623
+ // op carrying the user-clicked cells; the `previousCells` array (built
624
+ // from stroke.cells with first-wins prev semantics) captures every
625
+ // cascade-affected cell so undo replays the inverse op cleanly.
626
+ let op;
627
+ if (stroke.autotile) {
628
+ if (stroke.autotile.clickedCells.length === 0)
629
+ return;
630
+ // D.2.5.2 fix — include cascade-resolved cells so host save can
631
+ // persist the autotile output without recomputing.
632
+ const resolvedCells = stroke.cells.map((c) => ({
633
+ x: c.x,
634
+ y: c.y,
635
+ index: c.index,
636
+ }));
637
+ op = {
638
+ kind: 'autotilePaint',
639
+ layerId: stroke.layerId,
640
+ terrainId: stroke.autotile.terrainId,
641
+ cells: stroke.autotile.clickedCells.slice(),
642
+ erase: stroke.autotile.erase,
643
+ resolvedCells,
644
+ };
645
+ const previousCells = stroke.cells.map((c) => ({
646
+ x: c.x,
647
+ y: c.y,
648
+ index: c.prevIndex,
649
+ }));
650
+ const message = {
651
+ type: 'umicat:editor:tilemapEdited',
652
+ entityId: stroke.entityId,
653
+ op,
654
+ previousCells,
655
+ };
656
+ if (typeof window !== 'undefined' && window.parent) {
657
+ window.parent.postMessage(message, '*');
658
+ }
659
+ return;
660
+ }
661
+ const isErase = stroke.cells.every((c) => c.index == null);
662
+ if (isErase) {
663
+ op = {
664
+ kind: 'erase',
665
+ layerId: stroke.layerId,
666
+ cells: stroke.cells.map((c) => ({ x: c.x, y: c.y })),
667
+ };
668
+ }
669
+ else {
670
+ op = {
671
+ kind: 'paint',
672
+ layerId: stroke.layerId,
673
+ cells: stroke.cells.map((c) => ({
674
+ x: c.x,
675
+ y: c.y,
676
+ // Brush stroke — every cell has index. Non-null asserted because
677
+ // mixed-mode strokes shouldn't happen (one tool active per stroke).
678
+ index: c.index,
679
+ })),
680
+ };
681
+ }
682
+ const previousCells = stroke.cells.map((c) => ({
683
+ x: c.x,
684
+ y: c.y,
685
+ index: c.prevIndex,
686
+ }));
687
+ const message = {
688
+ type: 'umicat:editor:tilemapEdited',
689
+ entityId: stroke.entityId,
690
+ op,
691
+ previousCells,
692
+ };
693
+ if (typeof window !== 'undefined' && window.parent) {
694
+ window.parent.postMessage(message, '*');
695
+ }
696
+ }
697
+ /**
698
+ * Compose a `fillRect` op from the completed rect drag + apply it live +
699
+ * post tilemapEdited. `previousCells` captures every cell the rect
700
+ * overwrites so undo replays them onto the layer.
701
+ */
702
+ commitTilemapRect(rect) {
703
+ const tool = getTilemapToolState(this.game);
704
+ const layer = this.findActiveTilemapLayer(rect.entityId, rect.layerId);
705
+ if (!layer)
706
+ return;
707
+ const x0 = Math.min(rect.startX, rect.endX);
708
+ const y0 = Math.min(rect.startY, rect.endY);
709
+ const x1 = Math.max(rect.startX, rect.endX);
710
+ const y1 = Math.max(rect.startY, rect.endY);
711
+ const w = x1 - x0 + 1;
712
+ const h = y1 - y0 + 1;
713
+ // Slice 6 Phase D — autotile rect commit. Each in-rect cell is one
714
+ // "clicked cell" for the cascade; previousCells covers every cell
715
+ // the union of cascades touched (rect interior + 1-cell border).
716
+ if (rect.autotile) {
717
+ const ctx = this.resolveAutotileTerrainContext(layer, rect.autotile.terrainId);
718
+ if (!ctx)
719
+ return;
720
+ const mode = rect.autotile.erase ? 'erase' : 'paint';
721
+ const clickedCells = [];
722
+ const affectedMap = new Map();
723
+ // Snapshot prev indices BEFORE any cascade runs (first-wins per cell).
724
+ const layerW = layer.tilemap.width;
725
+ const layerH = layer.tilemap.height;
726
+ for (let y = y0 - 1; y <= y1 + 1; y++) {
727
+ for (let x = x0 - 1; x <= x1 + 1; x++) {
728
+ if (x < 0 || y < 0 || x >= layerW || y >= layerH)
729
+ continue;
730
+ const key = `${x},${y}`;
731
+ if (affectedMap.has(key))
732
+ continue;
733
+ const t = layer.getTileAt(x, y, true);
734
+ const prev = t && t.index >= 0 ? t.index : -1;
735
+ affectedMap.set(key, { x, y, prevIndex: prev });
736
+ }
737
+ }
738
+ const rectKind = getAutotileKind(ctx.asset);
739
+ for (let y = y0; y <= y1; y++) {
740
+ for (let x = x0; x <= x1; x++) {
741
+ clickedCells.push({ x, y });
742
+ applyAutotile(layer, x, y, ctx.terrain, mode, rectKind);
743
+ }
744
+ }
745
+ // D.2.5.2 fix — read post-cascade tile indices for save persistence.
746
+ const resolvedCells = Array.from(affectedMap.values()).map((c) => {
747
+ const t = layer.getTileAt(c.x, c.y, true);
748
+ const idx = t && t.index >= 0 ? t.index : null;
749
+ return { x: c.x, y: c.y, index: idx };
750
+ });
751
+ const op = {
752
+ kind: 'autotilePaint',
753
+ layerId: rect.layerId,
754
+ terrainId: rect.autotile.terrainId,
755
+ cells: clickedCells,
756
+ erase: rect.autotile.erase,
757
+ resolvedCells,
758
+ };
759
+ const previousCells = Array.from(affectedMap.values()).map((c) => ({
760
+ x: c.x,
761
+ y: c.y,
762
+ index: c.prevIndex < 0 ? null : c.prevIndex,
763
+ }));
764
+ const message = {
765
+ type: 'umicat:editor:tilemapEdited',
766
+ entityId: rect.entityId,
767
+ op,
768
+ previousCells,
769
+ };
770
+ if (typeof window !== 'undefined' && window.parent) {
771
+ window.parent.postMessage(message, '*');
772
+ }
773
+ return;
774
+ }
775
+ if (tool.activeTile == null)
776
+ return;
777
+ const previousCells = [];
778
+ for (let y = y0; y <= y1; y++) {
779
+ for (let x = x0; x <= x1; x++) {
780
+ const t = layer.getTileAt(x, y, true);
781
+ const prev = t && t.index >= 0 ? t.index : null;
782
+ previousCells.push({ x, y, index: prev });
783
+ }
784
+ }
785
+ const op = {
786
+ kind: 'fillRect',
787
+ layerId: rect.layerId,
788
+ x: x0,
789
+ y: y0,
790
+ w,
791
+ h,
792
+ index: tool.activeTile,
793
+ };
794
+ applyTilemapOp(layer, op);
795
+ const message = {
796
+ type: 'umicat:editor:tilemapEdited',
797
+ entityId: rect.entityId,
798
+ op,
799
+ previousCells,
800
+ };
801
+ if (typeof window !== 'undefined' && window.parent) {
802
+ window.parent.postMessage(message, '*');
803
+ }
804
+ }
805
+ /**
806
+ * Bucket fill (4-connected flood). Captures every cell the flood
807
+ * touches into previousCells before applying, then posts one tilemapEdited
808
+ * so undo restores the whole flooded area.
809
+ *
810
+ * Reads the layer in one pass to compute the visited set + previous
811
+ * indices, then commits via Phaser's putTileAt. Single-pass because
812
+ * applyTilemapOp would re-walk + duplicate work.
813
+ */
814
+ applyBucketFill(layer, seedX, seedY, targetIndex, tool) {
815
+ if (!tool.tilemapEditingId || !tool.activeLayerId)
816
+ return;
817
+ const w = layer.tilemap.width;
818
+ const h = layer.tilemap.height;
819
+ const seedTile = layer.getTileAt(seedX, seedY, true);
820
+ const seedIndex = seedTile && seedTile.index >= 0 ? seedTile.index : -1;
821
+ if (seedIndex === targetIndex)
822
+ return; // already painted
823
+ const visited = new Set();
824
+ const previousCells = [];
825
+ const stack = [[seedX, seedY]];
826
+ let safety = 0;
827
+ while (stack.length > 0 && safety++ < w * h) {
828
+ const [cx, cy] = stack.pop();
829
+ if (cx < 0 || cy < 0 || cx >= w || cy >= h)
830
+ continue;
831
+ const key = cy * w + cx;
832
+ if (visited.has(key))
833
+ continue;
834
+ const t = layer.getTileAt(cx, cy, true);
835
+ const idx = t && t.index >= 0 ? t.index : -1;
836
+ if (idx !== seedIndex)
837
+ continue;
838
+ visited.add(key);
839
+ previousCells.push({ x: cx, y: cy, index: idx < 0 ? null : idx });
840
+ layer.putTileAt(targetIndex, cx, cy);
841
+ stack.push([cx + 1, cy], [cx - 1, cy], [cx, cy + 1], [cx, cy - 1]);
842
+ }
843
+ if (previousCells.length === 0)
844
+ return;
845
+ const op = {
846
+ kind: 'bucketFill',
847
+ layerId: tool.activeLayerId,
848
+ x: seedX,
849
+ y: seedY,
850
+ index: targetIndex,
851
+ };
852
+ const message = {
853
+ type: 'umicat:editor:tilemapEdited',
854
+ entityId: tool.tilemapEditingId,
855
+ op,
856
+ previousCells,
857
+ };
858
+ if (typeof window !== 'undefined' && window.parent) {
859
+ window.parent.postMessage(message, '*');
860
+ }
861
+ }
862
+ /**
863
+ * Slice 6 Phase D — autotile bucket fill. 4-connected flood over cells
864
+ * with matching tile index (or all empties), then runs the wang cascade
865
+ * for each flooded cell. The resulting `autotilePaint` op carries every
866
+ * flooded cell as a clicked cell; `previousCells` covers the flood +
867
+ * its 1-cell border for cascade-correct undo.
868
+ */
869
+ applyAutotileBucketFill(layer, seedX, seedY, ctx, tool) {
870
+ if (!tool.tilemapEditingId || !tool.activeLayerId || !tool.activeTerrainId)
871
+ return;
872
+ const w = layer.tilemap.width;
873
+ const h = layer.tilemap.height;
874
+ const seedTile = layer.getTileAt(seedX, seedY, true);
875
+ const seedIndex = seedTile && seedTile.index >= 0 ? seedTile.index : -1;
876
+ const visited = new Set();
877
+ const clicked = [];
878
+ const stack = [[seedX, seedY]];
879
+ let safety = 0;
880
+ while (stack.length > 0 && safety++ < w * h) {
881
+ const [cx, cy] = stack.pop();
882
+ if (cx < 0 || cy < 0 || cx >= w || cy >= h)
883
+ continue;
884
+ const key = cy * w + cx;
885
+ if (visited.has(key))
886
+ continue;
887
+ const t = layer.getTileAt(cx, cy, true);
888
+ const idx = t && t.index >= 0 ? t.index : -1;
889
+ if (idx !== seedIndex)
890
+ continue;
891
+ visited.add(key);
892
+ clicked.push({ x: cx, y: cy });
893
+ stack.push([cx + 1, cy], [cx - 1, cy], [cx, cy + 1], [cx, cy - 1]);
894
+ }
895
+ if (clicked.length === 0)
896
+ return;
897
+ // Snapshot prev indices for flooded cells + their 1-cell border BEFORE
898
+ // any cascade runs (cascade will mutate neighbors; first-wins matters).
899
+ const affectedMap = new Map();
900
+ for (const c of clicked) {
901
+ for (let dy = -1; dy <= 1; dy++) {
902
+ for (let dx = -1; dx <= 1; dx++) {
903
+ const x = c.x + dx;
904
+ const y = c.y + dy;
905
+ if (x < 0 || y < 0 || x >= w || y >= h)
906
+ continue;
907
+ const k = `${x},${y}`;
908
+ if (affectedMap.has(k))
909
+ continue;
910
+ const tt = layer.getTileAt(x, y, true);
911
+ const prev = tt && tt.index >= 0 ? tt.index : -1;
912
+ affectedMap.set(k, { x, y, prevIndex: prev });
913
+ }
914
+ }
915
+ }
916
+ const fillKind = getAutotileKind(ctx.asset);
917
+ for (const c of clicked) {
918
+ applyAutotile(layer, c.x, c.y, ctx.terrain, 'paint', fillKind);
919
+ }
920
+ // D.2.5.2 fix — capture cascade-resolved tile indices for save.
921
+ const resolvedCells = Array.from(affectedMap.values()).map((c) => {
922
+ const tt = layer.getTileAt(c.x, c.y, true);
923
+ const idx = tt && tt.index >= 0 ? tt.index : null;
924
+ return { x: c.x, y: c.y, index: idx };
925
+ });
926
+ const op = {
927
+ kind: 'autotilePaint',
928
+ layerId: tool.activeLayerId,
929
+ terrainId: tool.activeTerrainId,
930
+ cells: clicked,
931
+ erase: false,
932
+ resolvedCells,
933
+ };
934
+ const previousCells = Array.from(affectedMap.values()).map((c) => ({
935
+ x: c.x,
936
+ y: c.y,
937
+ index: c.prevIndex < 0 ? null : c.prevIndex,
938
+ }));
939
+ const message = {
940
+ type: 'umicat:editor:tilemapEdited',
941
+ entityId: tool.tilemapEditingId,
942
+ op,
943
+ previousCells,
944
+ };
945
+ if (typeof window !== 'undefined' && window.parent) {
946
+ window.parent.postMessage(message, '*');
947
+ }
948
+ }
949
+ /**
950
+ * Look up the active TilemapLayer in the world scene's entity registry,
951
+ * then walk the container's children for the layer with matching
952
+ * `tilemapLayerId` data tag.
953
+ */
954
+ findActiveTilemapLayer(tilemapId, layerId) {
955
+ const resolved = this.findActiveTilemapLayerWithContainer(tilemapId, layerId);
956
+ return resolved?.layer ?? null;
957
+ }
958
+ /**
959
+ * Same as findActiveTilemapLayer but returns the container too. Callers
960
+ * that need to convert world coords to tile coords need the container's
961
+ * position to shift the input — `TilemapLayer.worldToTileXY` doesn't
962
+ * apply the container transform on its own (the layer's `x`/`y` are
963
+ * local to the container, not world coords).
964
+ */
965
+ findActiveTilemapLayerWithContainer(tilemapId, layerId) {
966
+ const registry = this.findEntityRegistry();
967
+ if (!registry)
968
+ return null;
969
+ const container = registry.byId(tilemapId);
970
+ if (!container || !(container instanceof Phaser.GameObjects.Container)) {
971
+ return null;
972
+ }
973
+ const layer = findTilemapLayerById(container, layerId);
974
+ if (!layer)
975
+ return null;
976
+ return { layer, container };
977
+ }
978
+ /**
979
+ * Apply one stroke cell: read prev index (for undo), apply the new
980
+ * value via Phaser's tilemap API, push the cell into the stroke
981
+ * accumulator. Dedup happens in `appendTilemapStrokeCell` so dragging
982
+ * over the same cell multiple times only records once.
983
+ */
984
+ applyStrokeCell(layer, x, y, tool) {
985
+ const prev = layer.getTileAt(x, y, true);
986
+ const prevIndex = prev ? prev.index : -1;
987
+ // -1 is Phaser's "no tile" sentinel; for our op shape we use null to
988
+ // mean "cell was empty" so the host's undo logic doesn't have to
989
+ // remember the magic number.
990
+ const prevIndexOrNull = prevIndex < 0 ? null : prevIndex;
991
+ let appliedOp;
992
+ if (tool.tool === 'eraser') {
993
+ appliedOp = { kind: 'erase', layerId: tool.activeLayerId, cells: [{ x, y }] };
994
+ appendTilemapStrokeCell(this.game, { x, y, index: null, prevIndex: prevIndexOrNull });
995
+ }
996
+ else {
997
+ const idx = tool.activeTile;
998
+ appliedOp = { kind: 'paint', layerId: tool.activeLayerId, cells: [{ x, y, index: idx }] };
999
+ appendTilemapStrokeCell(this.game, { x, y, index: idx, prevIndex: prevIndexOrNull });
1000
+ }
1001
+ applyTilemapOp(layer, appliedOp);
1002
+ }
1003
+ /**
1004
+ * Slice 6 Phase D — resolve `{ asset, terrain }` for the layer's
1005
+ * tileset + a requested terrain id. Returns null when:
1006
+ * - the layer has no `tilemapTilesetId` stamp (legacy / unattached layer)
1007
+ * - the manifest has no asset for that id
1008
+ * - the asset's tileset metadata lacks `autotile.terrains` with that id
1009
+ * Callers fall back to stamp-mode behavior on null.
1010
+ */
1011
+ resolveAutotileTerrainContext(layer, terrainId) {
1012
+ const tilesetId = layer.getData('tilemapTilesetId');
1013
+ if (!tilesetId) {
1014
+ console.warn(`[umicat/editor] resolveAutotileTerrainContext: layer is missing 'tilemapTilesetId' data stamp. ` +
1015
+ `Layer was created without a tileset binding — likely cause: addLayer op fired before the asset was loaded into the iframe cache, or the layer's tilesetIds[] was empty at spawn.`);
1016
+ return null;
1017
+ }
1018
+ const manifest = getManifest(layer.scene);
1019
+ const asset = manifest?.assets.find((a) => a.id === tilesetId);
1020
+ if (!asset) {
1021
+ console.warn(`[umicat/editor] resolveAutotileTerrainContext: asset '${tilesetId}' (referenced by the active tilemap layer) is not in the iframe's manifest cache. ` +
1022
+ `Likely cause: the asset was added to the workspace manifest on disk but the host didn't push an 'umicat:editor:assetUpdate' message to sync the iframe cache.`);
1023
+ return null;
1024
+ }
1025
+ const terrain = findTerrain(asset, terrainId);
1026
+ if (!terrain) {
1027
+ const available = (asset.tileset?.autotile?.terrains ?? []).map((t) => t.id);
1028
+ console.warn(`[umicat/editor] resolveAutotileTerrainContext: asset '${tilesetId}' has no terrain with id '${terrainId}'. ` +
1029
+ `Available terrain ids: [${available.join(', ') || '(none — autotile not configured on this asset)'}]. ` +
1030
+ `Likely cause: TilemapInspectorPanel's terrain palette is reading a fresher AssetSummary than the iframe's manifest cache; an 'umicat:editor:assetUpdate' should have synced after the AutotileEditorModal save.`);
1031
+ return null;
1032
+ }
1033
+ return { asset, terrain };
1034
+ }
1035
+ /**
1036
+ * Slice 6 Phase D — autotile-mode stroke cell. Runs the Wang cascade
1037
+ * via `applyAutotile`, threads every affected cell (clicked + cascade
1038
+ * neighbors) into the stroke accumulator so the pointerup commit can
1039
+ * emit one `autotilePaint` op + a previousCells array that captures
1040
+ * the entire region the click changed.
1041
+ *
1042
+ * Per-frame metadata + sub-tile body sync is handled by the host-
1043
+ * driven `handleEditTilemap` path when undo replays inverse ops; the
1044
+ * live stroke only touches `layer.data` (cell indices) — fine because
1045
+ * cell-rect collision is re-armed at the next handleEditTilemap call.
1046
+ */
1047
+ applyAutotileStrokeCell(layer, x, y, ctx) {
1048
+ const stroke = getTilemapStroke(this.game);
1049
+ if (!stroke || !stroke.autotile)
1050
+ return;
1051
+ const mode = stroke.autotile.erase ? 'erase' : 'paint';
1052
+ const kind = getAutotileKind(ctx.asset);
1053
+ const affected = applyAutotile(layer, x, y, ctx.terrain, mode, kind);
1054
+ appendAutotileStrokeClick(this.game, x, y, affected);
1055
+ }
1056
+ /**
1057
+ * Sync every tilemap entity's TilemapLayers to its container's
1058
+ * current world position. Runs every frame in edit mode (see update()).
1059
+ * Matches the play-mode hook in SceneLoader#installTilemapLayerSync —
1060
+ * same logic, different trigger source (overlay vs world scene event).
1061
+ * Cheap: handful of number assignments per tilemap.
1062
+ */
1063
+ syncTilemapLayersFromContainers() {
1064
+ const registry = this.findEntityRegistry();
1065
+ if (!registry)
1066
+ return;
1067
+ for (const go of registry.all()) {
1068
+ if (go.getData('entityKind') !== 'tilemap')
1069
+ continue;
1070
+ const container = go;
1071
+ const layers = go.getData('tilemapLayers');
1072
+ if (!layers)
1073
+ continue;
1074
+ const containerDepth = container.depth ?? 0;
1075
+ for (const layer of layers) {
1076
+ const offX = layer.getData('tilemapLocalOffsetX') ?? 0;
1077
+ const offY = layer.getData('tilemapLocalOffsetY') ?? 0;
1078
+ const targetX = container.x + offX;
1079
+ const targetY = container.y + offY;
1080
+ if (layer.x !== targetX)
1081
+ layer.x = targetX;
1082
+ if (layer.y !== targetY)
1083
+ layer.y = targetY;
1084
+ // FB.10 — sync depth too; matches the play-mode SceneLoader hook.
1085
+ if (layer.depth !== containerDepth)
1086
+ layer.setDepth(containerDepth);
1087
+ }
1088
+ }
1089
+ }
1090
+ // --- Slice 6 Phase B.6.1 — resize-handle drag ----------------------------
1091
+ /**
1092
+ * Read the selected tilemap entity's bounds + return the 8 handle
1093
+ * positions in world coords. Returns null when the selected entity
1094
+ * is not a tilemap (no handles to draw / hit-test).
1095
+ */
1096
+ tilemapHandlePositions() {
1097
+ const selectedId = getSelection(this.game);
1098
+ if (!selectedId)
1099
+ return null;
1100
+ const registry = this.findEntityRegistry();
1101
+ if (!registry)
1102
+ return null;
1103
+ const go = registry.byId(selectedId);
1104
+ if (!go || go.getData('entityKind') !== 'tilemap')
1105
+ return null;
1106
+ const container = go;
1107
+ const tileSize = go.getData('tilemapTileSize');
1108
+ const size = go.getData('tilemapSize');
1109
+ if (!tileSize || !size)
1110
+ return null;
1111
+ const pxW = size.width * tileSize.width;
1112
+ const pxH = size.height * tileSize.height;
1113
+ const cx = container.x;
1114
+ const cy = container.y;
1115
+ const left = cx - pxW / 2;
1116
+ const right = cx + pxW / 2;
1117
+ const top = cy - pxH / 2;
1118
+ const bottom = cy + pxH / 2;
1119
+ return {
1120
+ entityId: selectedId,
1121
+ center: { x: cx, y: cy },
1122
+ pxW,
1123
+ pxH,
1124
+ cellsW: size.width,
1125
+ cellsH: size.height,
1126
+ tileSize,
1127
+ handles: [
1128
+ { id: 'nw', x: left, y: top },
1129
+ { id: 'n', x: cx, y: top },
1130
+ { id: 'ne', x: right, y: top },
1131
+ { id: 'e', x: right, y: cy },
1132
+ { id: 'se', x: right, y: bottom },
1133
+ { id: 's', x: cx, y: bottom },
1134
+ { id: 'sw', x: left, y: bottom },
1135
+ { id: 'w', x: left, y: cy },
1136
+ ],
1137
+ };
1138
+ }
1139
+ /**
1140
+ * Hit-test the resize handles. Corners (NW/NE/SW/SE) use a dedicated
1141
+ * ~14px square click target. Edges (N/S/E/W) are grabbable along the
1142
+ * ENTIRE edge line (Figma / Sketch convention) — clicking anywhere on
1143
+ * the blue bounds line resizes that edge. Corner zones take priority
1144
+ * over edges so dragging right at a corner gives diagonal resize, not
1145
+ * single-axis.
1146
+ *
1147
+ * All click targets are zoom-invariant via `1/zoom` scaling so they
1148
+ * stay constant size on screen regardless of how zoomed in/out.
1149
+ */
1150
+ hitTestTilemapResizeHandle(worldX, worldY) {
1151
+ const info = this.tilemapHandlePositions();
1152
+ if (!info)
1153
+ return null;
1154
+ const cam = this.findActiveEditorCamera();
1155
+ const zoom = cam?.zoom ?? 1;
1156
+ const halfHandleWorld = 7 / zoom; // 14px square = 7px half-side
1157
+ const edgeTolerance = 6 / zoom; // 12px-thick zone perpendicular to edge
1158
+ // 1. Corner handles take priority — small dedicated squares.
1159
+ for (const id of ['nw', 'ne', 'sw', 'se']) {
1160
+ const h = info.handles.find((h) => h.id === id);
1161
+ if (!h)
1162
+ continue;
1163
+ if (Math.abs(worldX - h.x) <= halfHandleWorld &&
1164
+ Math.abs(worldY - h.y) <= halfHandleWorld) {
1165
+ return id;
1166
+ }
1167
+ }
1168
+ // 2. Edge zones — entire edge line is the click target, EXCLUDING
1169
+ // the corner zones (so corners always win when overlap).
1170
+ const left = info.center.x - info.pxW / 2;
1171
+ const right = info.center.x + info.pxW / 2;
1172
+ const top = info.center.y - info.pxH / 2;
1173
+ const bottom = info.center.y + info.pxH / 2;
1174
+ const xWithinHorizontalEdge = worldX >= left + halfHandleWorld && worldX <= right - halfHandleWorld;
1175
+ const yWithinVerticalEdge = worldY >= top + halfHandleWorld && worldY <= bottom - halfHandleWorld;
1176
+ if (Math.abs(worldY - top) <= edgeTolerance && xWithinHorizontalEdge)
1177
+ return 'n';
1178
+ if (Math.abs(worldY - bottom) <= edgeTolerance && xWithinHorizontalEdge)
1179
+ return 's';
1180
+ if (Math.abs(worldX - left) <= edgeTolerance && yWithinVerticalEdge)
1181
+ return 'w';
1182
+ if (Math.abs(worldX - right) <= edgeTolerance && yWithinVerticalEdge)
1183
+ return 'e';
1184
+ return null;
1185
+ }
1186
+ /**
1187
+ * Set canvas cursor to a resize-arrow when hovering a handle, or restore
1188
+ * default. Direction matches the handle's axis (Figma / Sketch / browser
1189
+ * native convention):
1190
+ * NW/SE corners → `nwse-resize` (↖↘)
1191
+ * NE/SW corners → `nesw-resize` (↗↙)
1192
+ * N/S edges → `ns-resize` (↕)
1193
+ * E/W edges → `ew-resize` (↔)
1194
+ *
1195
+ * Active drag (getTilemapResize non-null) pins the cursor to the dragged
1196
+ * handle so it doesn't flicker back to default when the cursor briefly
1197
+ * leaves the handle's 14px target box during fast drag.
1198
+ */
1199
+ updateResizeCursor(pointer) {
1200
+ const canvas = this.game.canvas;
1201
+ if (!canvas)
1202
+ return;
1203
+ let handle = null;
1204
+ const inDrag = getTilemapResize(this.game);
1205
+ if (inDrag) {
1206
+ handle = inDrag.handle;
1207
+ }
1208
+ else if (isTilemapPaintMode(this.game)) {
1209
+ const { x, y } = this.pointerCoords(pointer);
1210
+ handle = this.hitTestTilemapResizeHandle(x, y);
1211
+ }
1212
+ if (!handle) {
1213
+ // Only reset when WE set it earlier; don't clobber pan-grab or other
1214
+ // cursor states future features might add.
1215
+ if (canvas.style.cursor.endsWith('resize'))
1216
+ canvas.style.cursor = '';
1217
+ return;
1218
+ }
1219
+ canvas.style.cursor = RESIZE_CURSOR_FOR_HANDLE[handle];
1220
+ }
1221
+ /**
1222
+ * Begin a resize-handle drag. Snapshots the starting size + center so
1223
+ * pointermove can compute deltas relative to drag-start.
1224
+ */
1225
+ beginTilemapResizeDrag(handle) {
1226
+ const info = this.tilemapHandlePositions();
1227
+ if (!info)
1228
+ return;
1229
+ beginTilemapResize(this.game, {
1230
+ entityId: info.entityId,
1231
+ handle,
1232
+ startSize: { width: info.cellsW, height: info.cellsH },
1233
+ startCenter: { x: info.center.x, y: info.center.y },
1234
+ tileSize: info.tileSize,
1235
+ previewWidth: info.cellsW,
1236
+ previewHeight: info.cellsH,
1237
+ previewCenter: { x: info.center.x, y: info.center.y },
1238
+ });
1239
+ }
1240
+ /**
1241
+ * Update preview dims based on cursor position. Snaps to integer cells.
1242
+ * The handle dictates which edges move — opposite edges stay fixed.
1243
+ *
1244
+ * Algorithm: from the cursor position + handle, compute the new L/R/T/B
1245
+ * edges; opposite edges are pinned to start values. New size = R-L, B-T
1246
+ * in pixels → divide by tileSize for cell counts. New center = midpoint
1247
+ * of new L/R/T/B.
1248
+ */
1249
+ updateTilemapResizeDrag(worldX, worldY) {
1250
+ const resize = getTilemapResize(this.game);
1251
+ if (!resize)
1252
+ return;
1253
+ const { handle, startSize, startCenter, tileSize } = resize;
1254
+ const startPxW = startSize.width * tileSize.width;
1255
+ const startPxH = startSize.height * tileSize.height;
1256
+ const startLeft = startCenter.x - startPxW / 2;
1257
+ const startRight = startCenter.x + startPxW / 2;
1258
+ const startTop = startCenter.y - startPxH / 2;
1259
+ const startBottom = startCenter.y + startPxH / 2;
1260
+ let newLeft = startLeft;
1261
+ let newRight = startRight;
1262
+ let newTop = startTop;
1263
+ let newBottom = startBottom;
1264
+ // Which edges does this handle move?
1265
+ const movesLeft = handle === 'nw' || handle === 'w' || handle === 'sw';
1266
+ const movesRight = handle === 'ne' || handle === 'e' || handle === 'se';
1267
+ const movesTop = handle === 'nw' || handle === 'n' || handle === 'ne';
1268
+ const movesBottom = handle === 'sw' || handle === 's' || handle === 'se';
1269
+ if (movesLeft)
1270
+ newLeft = worldX;
1271
+ if (movesRight)
1272
+ newRight = worldX;
1273
+ if (movesTop)
1274
+ newTop = worldY;
1275
+ if (movesBottom)
1276
+ newBottom = worldY;
1277
+ // Snap to whole cells. Round each moving edge to nearest cell boundary
1278
+ // measured from the anchor (opposite) edge.
1279
+ if (movesLeft) {
1280
+ const cellsFromRight = Math.max(1, Math.round((newRight - newLeft) / tileSize.width));
1281
+ newLeft = newRight - cellsFromRight * tileSize.width;
1282
+ }
1283
+ if (movesRight) {
1284
+ const cellsFromLeft = Math.max(1, Math.round((newRight - newLeft) / tileSize.width));
1285
+ newRight = newLeft + cellsFromLeft * tileSize.width;
1286
+ }
1287
+ if (movesTop) {
1288
+ const cellsFromBottom = Math.max(1, Math.round((newBottom - newTop) / tileSize.height));
1289
+ newTop = newBottom - cellsFromBottom * tileSize.height;
1290
+ }
1291
+ if (movesBottom) {
1292
+ const cellsFromTop = Math.max(1, Math.round((newBottom - newTop) / tileSize.height));
1293
+ newBottom = newTop + cellsFromTop * tileSize.height;
1294
+ }
1295
+ const previewWidth = Math.round((newRight - newLeft) / tileSize.width);
1296
+ const previewHeight = Math.round((newBottom - newTop) / tileSize.height);
1297
+ if (previewWidth < 1 || previewHeight < 1)
1298
+ return;
1299
+ const previewCenter = {
1300
+ x: (newLeft + newRight) / 2,
1301
+ y: (newTop + newBottom) / 2,
1302
+ };
1303
+ updateTilemapResize(this.game, {
1304
+ previewWidth,
1305
+ previewHeight,
1306
+ previewCenter,
1307
+ });
1308
+ }
1309
+ /**
1310
+ * Commit the resize drag — post a `resize` op via `editTilemap` channel,
1311
+ * carrying the new dims + transformDelta to keep the opposite edge
1312
+ * anchored. Posted directly to host (skipping the local SDK apply path)
1313
+ * because the host's draft + iframe runtime BOTH need the op — host
1314
+ * records the command for undo + applies the same op back via the
1315
+ * normal editTilemap dispatch (which mutates the live runtime).
1316
+ */
1317
+ commitTilemapResize() {
1318
+ const resize = endTilemapResize(this.game);
1319
+ if (!resize)
1320
+ return;
1321
+ const { entityId, startSize, startCenter, previewWidth, previewHeight, previewCenter } = resize;
1322
+ if (previewWidth === startSize.width && previewHeight === startSize.height)
1323
+ return;
1324
+ const transformDelta = {
1325
+ x: previewCenter.x - startCenter.x,
1326
+ y: previewCenter.y - startCenter.y,
1327
+ };
1328
+ const op = {
1329
+ kind: 'resize',
1330
+ width: previewWidth,
1331
+ height: previewHeight,
1332
+ ...(transformDelta.x !== 0 || transformDelta.y !== 0 ? { transformDelta } : {}),
1333
+ };
1334
+ // Apply LOCALLY first — destroys + rebuilds the live TilemapLayers
1335
+ // at the new dims, shifts container.x/y by transformDelta to keep
1336
+ // the opposite edge anchored. Without this local apply, the SDK
1337
+ // runtime would stay at the old size (bounds rect reads stale
1338
+ // `tilemapSize` stash) → user sees the bounds "snap back" after
1339
+ // release, even though the host's draft + persisted scene file
1340
+ // ARE at the new size.
1341
+ // Same pattern as brush/rect/fill: apply local, post for undo.
1342
+ handleEditTilemap(this.game, entityId, [op]);
1343
+ const message = {
1344
+ type: 'umicat:editor:tilemapEdited',
1345
+ entityId,
1346
+ op,
1347
+ previousCells: [],
1348
+ };
1349
+ if (typeof window !== 'undefined' && window.parent) {
1350
+ window.parent.postMessage(message, '*');
1351
+ }
1352
+ }
1353
+ update() {
1354
+ this.graphics.clear();
1355
+ // Slice 6 Phase B (fix): sync tilemap layer world positions to their
1356
+ // entity transform. Runs from overlay scene (always active in edit
1357
+ // mode) — the equivalent hook on world scene's UPDATE event doesn't
1358
+ // fire because the world scene is paused in edit mode (see
1359
+ // SceneLoader#installTilemapLayerSync's comment). Without this sync,
1360
+ // dragging a tilemap entity moves the bounds but not the painted
1361
+ // layers (or the resize-rebuilt layers don't track post-resize
1362
+ // container.x/y changes).
1363
+ this.syncTilemapLayersFromContainers();
1364
+ if (this.worldBounds) {
1365
+ // World bounds outline (the visible game frame). Outside-fill grey
1366
+ // strips moved to a separate Graphics in the WORLD scene at very
1367
+ // low depth (see `installVoidFill` in EditorBridge) — drawing
1368
+ // them HERE in overlay scene meant they rendered ABOVE entities,
1369
+ // so an entity dropped outside world bounds was visually covered
1370
+ // by grey (only the selection rect was visible). Putting the
1371
+ // fill below entities lets users see their out-of-bounds drops.
1372
+ this.graphics.lineStyle(2, WORLD_BOUNDS_COLOR, WORLD_BOUNDS_ALPHA);
1373
+ this.graphics.strokeRect(this.worldBounds.x, this.worldBounds.y, this.worldBounds.width, this.worldBounds.height);
1374
+ }
1375
+ // P1 infinite canvas — camera viewport rect ("Camera screen"): shows
1376
+ // where the GAME'S runtime camera (cameras.main, untouched during edit
1377
+ // mode) is currently positioned + how much it sees. Lets the user
1378
+ // place entities inside vs outside the camera's view at game start.
1379
+ // For games where world == camera viewport (Space Invaders, Tetris)
1380
+ // this rect coincides with the world bounds rect. For Mario / RPG /
1381
+ // farming etc. where world > camera, this is a small blue rect inside
1382
+ // the larger world rect.
1383
+ const worldSceneCam = this.findWorldSceneCamera();
1384
+ if (worldSceneCam) {
1385
+ const camX = worldSceneCam.scrollX;
1386
+ const camY = worldSceneCam.scrollY;
1387
+ // Use the GAME'S INTRINSIC size (snapshot from enterEdit), not the
1388
+ // current canvas dims. During edit the canvas is expanded to fill
1389
+ // the iframe, and game.scale.width is the expanded value — which
1390
+ // would draw the "Camera screen" rect at expanded canvas size,
1391
+ // not at the game's runtime camera viewport size. The runtime cam
1392
+ // ALWAYS renders the game's declared resolution (e.g. 720×1280),
1393
+ // regardless of what the editor does to the canvas.
1394
+ const snap = this.game['__unboxyEditorScaleSnapshot'];
1395
+ const intrinsicW = snap?.gameWidth ?? this.game.scale.width;
1396
+ const intrinsicH = snap?.gameHeight ?? this.game.scale.height;
1397
+ const camW = intrinsicW / worldSceneCam.zoom;
1398
+ const camH = intrinsicH / worldSceneCam.zoom;
1399
+ this.graphics.lineStyle(CAMERA_VIEWPORT_LINE_WIDTH, CAMERA_VIEWPORT_COLOR, CAMERA_VIEWPORT_ALPHA);
1400
+ this.graphics.strokeRect(camX, camY, camW, camH);
1401
+ }
1402
+ const selectedId = getSelection(this.game);
1403
+ if (selectedId) {
1404
+ const registry = this.findEntityRegistry();
1405
+ const go = registry?.byId(selectedId);
1406
+ if (go) {
1407
+ const bounds = computeBounds(go);
1408
+ if (bounds) {
1409
+ // HUD-mode selection-rect transform fix (2026-05-17): HUD
1410
+ // widgets render through the HUD cam, which has its viewport
1411
+ // POSITIONED at the camera-viewport rect on the editor canvas
1412
+ // (offset by `(gameCam.scrollX - editorCam.scrollX) *
1413
+ // editorCam.zoom`). But this overlay scene's selection rect
1414
+ // draws through the editor cam directly. The mismatch leaves
1415
+ // a `gameCam.scrollX * editorCam.zoom` drift that scales
1416
+ // with zoom — visible as the selection rect getting more
1417
+ // and more off the widget as the user zooms in. Math:
1418
+ // bg canvas pos = camRectCanvasX + (bounds.x) * zoom
1419
+ // = (gameCam.scrollX - editorCam.scrollX) * zoom
1420
+ // + bounds.x * zoom
1421
+ // selRect canvas pos = (bounds.x - editorCam.scrollX) * zoom
1422
+ // diff = gameCam.scrollX * zoom
1423
+ // Fix: add gameCam.scrollX/Y to the rect's world coords so
1424
+ // the overlay-cam transform produces the same canvas pos as
1425
+ // the HUD-cam transform. World-mode entities are unaffected
1426
+ // because they render through the editor cam directly, no
1427
+ // camRectCanvasX offset.
1428
+ let drawX = bounds.x;
1429
+ let drawY = bounds.y;
1430
+ if (getEditorMode(this.game) === 'hud') {
1431
+ const ws = this.findWorldSceneInstance();
1432
+ const gameCam = ws?.cameras.main;
1433
+ if (gameCam) {
1434
+ drawX += gameCam.scrollX;
1435
+ drawY += gameCam.scrollY;
1436
+ }
1437
+ }
1438
+ this.graphics.lineStyle(SELECTION_LINE_WIDTH, SELECTION_COLOR, SELECTION_ALPHA);
1439
+ this.graphics.strokeRect(drawX, drawY, bounds.width, bounds.height);
1440
+ }
1441
+ }
1442
+ }
1443
+ // Slice 8: "Show hitboxes" debug overlay. Only renders in world mode;
1444
+ // HUD widgets don't carry world hitboxes.
1445
+ if (getEditorMode(this.game) !== 'hud' && getDebugOverlayState(this.game).showHitboxes) {
1446
+ this.drawHitboxDebug();
1447
+ }
1448
+ // Slice 6 Phase B.4: rect-drag preview. Drawn over the live tilemap so
1449
+ // the user sees the rect being composed before committing on pointerup.
1450
+ // Layers are now world-positioned (see createTilemap), so
1451
+ // `tileToWorldXY` returns world coords directly — no shift needed.
1452
+ const rect = getTilemapRect(this.game);
1453
+ if (rect) {
1454
+ const layer = this.findActiveTilemapLayer(rect.entityId, rect.layerId);
1455
+ if (layer) {
1456
+ const x0 = Math.min(rect.startX, rect.endX);
1457
+ const y0 = Math.min(rect.startY, rect.endY);
1458
+ const x1 = Math.max(rect.startX, rect.endX);
1459
+ const y1 = Math.max(rect.startY, rect.endY);
1460
+ const tl = layer.tileToWorldXY(x0, y0);
1461
+ const br = layer.tileToWorldXY(x1 + 1, y1 + 1);
1462
+ const w = br.x - tl.x;
1463
+ const h = br.y - tl.y;
1464
+ this.graphics.fillStyle(0xffaa00, 0.18);
1465
+ this.graphics.fillRect(tl.x, tl.y, w, h);
1466
+ this.graphics.lineStyle(2, 0xffaa00, 0.85);
1467
+ this.graphics.strokeRect(tl.x, tl.y, w, h);
1468
+ }
1469
+ }
1470
+ // Slice 6 Phase B.6.1: tilemap resize handles + drag preview.
1471
+ // Handles render whenever paint mode is active on a tilemap entity
1472
+ // (the same condition that makes the tile palette appear in the
1473
+ // host Inspector). Ghost rect preview shows the new bounds during
1474
+ // an active resize drag.
1475
+ this.drawTilemapResizeOverlay();
1476
+ }
1477
+ /**
1478
+ * Render the 8 resize handles (when paint mode is active) and the
1479
+ * orange ghost-rect preview (when a resize drag is in progress).
1480
+ * Handles are zoom-invariant ~12px squares; preview rect snaps to
1481
+ * cell boundaries via the resize-drag's running preview state.
1482
+ */
1483
+ drawTilemapResizeOverlay() {
1484
+ if (!isTilemapPaintMode(this.game))
1485
+ return;
1486
+ const info = this.tilemapHandlePositions();
1487
+ if (!info)
1488
+ return;
1489
+ const cam = this.findActiveEditorCamera();
1490
+ const zoom = cam?.zoom ?? 1;
1491
+ const handleSize = 14 / zoom; // 14px on screen regardless of zoom
1492
+ const half = handleSize / 2;
1493
+ // Tilemap bounds outline (cyan, dashed-ish via solid moderate alpha).
1494
+ // Helps the user see what they're about to resize.
1495
+ this.graphics.lineStyle(1, 0x4488ee, 0.5);
1496
+ this.graphics.strokeRect(info.center.x - info.pxW / 2, info.center.y - info.pxH / 2, info.pxW, info.pxH);
1497
+ // 8 handles — small blue squares with white outline for contrast
1498
+ // against any underlying tile colors.
1499
+ for (const h of info.handles) {
1500
+ this.graphics.fillStyle(0xffffff, 1);
1501
+ this.graphics.fillRect(h.x - half, h.y - half, handleSize, handleSize);
1502
+ this.graphics.fillStyle(0x4488ee, 1);
1503
+ this.graphics.fillRect(h.x - half + 1.5 / zoom, h.y - half + 1.5 / zoom, handleSize - 3 / zoom, handleSize - 3 / zoom);
1504
+ }
1505
+ // Active resize drag — orange ghost rect at preview bounds.
1506
+ const resize = getTilemapResize(this.game);
1507
+ if (resize) {
1508
+ const pxW = resize.previewWidth * resize.tileSize.width;
1509
+ const pxH = resize.previewHeight * resize.tileSize.height;
1510
+ const left = resize.previewCenter.x - pxW / 2;
1511
+ const top = resize.previewCenter.y - pxH / 2;
1512
+ this.graphics.fillStyle(0xffaa00, 0.12);
1513
+ this.graphics.fillRect(left, top, pxW, pxH);
1514
+ this.graphics.lineStyle(2, 0xffaa00, 0.85);
1515
+ this.graphics.strokeRect(left, top, pxW, pxH);
1516
+ }
1517
+ }
1518
+ /**
1519
+ * Walks the world entity registry and draws each sprite's `hitbox` rect +
1520
+ * `depthAnchor` crosshair (when present on the entity's Asset). Sprites
1521
+ * without metadata get nothing drawn — chat-only Workflow A respect.
1522
+ *
1523
+ * Per-frame mode: reads the sprite's CURRENT frame index and looks up the
1524
+ * override (falls back to default) so the rendered rect tracks what the
1525
+ * SDK is actually applying to the body at runtime.
1526
+ */
1527
+ drawHitboxDebug() {
1528
+ const registry = this.findEntityRegistry();
1529
+ if (!registry)
1530
+ return;
1531
+ const worldScene = this.findWorldSceneInstance();
1532
+ const manifest = worldScene ? getManifest(worldScene) : undefined;
1533
+ if (!manifest)
1534
+ return;
1535
+ const assetsById = new Map();
1536
+ for (const asset of manifest.assets ?? []) {
1537
+ assetsById.set(asset.id, asset);
1538
+ }
1539
+ for (const go of registry.all()) {
1540
+ // (1) Tilemap collision — painted-cell rects + per-tile collisionRects.
1541
+ // Walks every painted tile on every layer of every tilemap entity;
1542
+ // draws the cell-rect for tiles with `solid: true` AND each sub-tile
1543
+ // rect for tiles with `collisionRects`. This is the visualization of
1544
+ // what `setCollisionByProperty({ solid: true })` + `syncSubTileBodies`
1545
+ // armed at scene-load. Without this pass the debug overlay only shows
1546
+ // sprite hitboxes — tile collision is invisible even when configured,
1547
+ // so "Show hitboxes" looked broken for tilemap-only games.
1548
+ if (go.getData('entityKind') === 'tilemap') {
1549
+ const container = go;
1550
+ const layers = container.getData('tilemapLayers') ?? [];
1551
+ for (const layer of layers) {
1552
+ const tilesetId = layer.getData('tilemapTilesetId');
1553
+ if (!tilesetId)
1554
+ continue;
1555
+ const asset = assetsById.get(tilesetId);
1556
+ const tileMeta = asset?.tileset?.tiles ?? {};
1557
+ // Layer position in world coords (layers live in scene root with
1558
+ // their own x/y per `installTilemapLayerSync`).
1559
+ const layerX = layer.x ?? 0;
1560
+ const layerY = layer.y ?? 0;
1561
+ layer.forEachTile((tile) => {
1562
+ if (tile.index < 0)
1563
+ return; // unpainted cell
1564
+ const meta = tileMeta[tile.index];
1565
+ const cellX = layerX + tile.x * tile.width;
1566
+ const cellY = layerY + tile.y * tile.height;
1567
+ const subRects = meta?.collisionRects;
1568
+ if (subRects && subRects.length > 0) {
1569
+ // Sub-tile rects override cell-rect collision (per SDK 0.2.116).
1570
+ for (const r of subRects) {
1571
+ this.graphics.fillStyle(HITBOX_FILL_COLOR, HITBOX_FILL_ALPHA);
1572
+ this.graphics.fillRect(cellX + r.x, cellY + r.y, r.w, r.h);
1573
+ this.graphics.lineStyle(1, HITBOX_STROKE_COLOR, HITBOX_STROKE_ALPHA);
1574
+ this.graphics.strokeRect(cellX + r.x, cellY + r.y, r.w, r.h);
1575
+ }
1576
+ }
1577
+ else if (meta?.solid === true) {
1578
+ // Full-cell collision (no sub-rects authored).
1579
+ this.graphics.fillStyle(HITBOX_FILL_COLOR, HITBOX_FILL_ALPHA);
1580
+ this.graphics.fillRect(cellX, cellY, tile.width, tile.height);
1581
+ this.graphics.lineStyle(1, HITBOX_STROKE_COLOR, HITBOX_STROKE_ALPHA);
1582
+ this.graphics.strokeRect(cellX, cellY, tile.width, tile.height);
1583
+ }
1584
+ });
1585
+ }
1586
+ continue; // tilemap GO — nothing else to draw
1587
+ }
1588
+ // (2) Sprite hitbox + depth anchor (slice 8). Reads asset metadata
1589
+ // and renders the rect in source-pixel coords mapped to the sprite's
1590
+ // actual rendered position (compensating for origin shift).
1591
+ const assetId = go.getData('entityAssetId');
1592
+ if (!assetId)
1593
+ continue; // primitives / code-rendered / group — skip
1594
+ const asset = assetsById.get(assetId);
1595
+ if (!asset)
1596
+ continue;
1597
+ const sprite = go;
1598
+ const frameW = sprite.frame?.width ?? sprite.width ?? 0;
1599
+ const frameH = sprite.frame?.height ?? sprite.height ?? 0;
1600
+ if (frameW === 0 || frameH === 0)
1601
+ continue;
1602
+ const originX = sprite.originX ?? 0.5;
1603
+ const originY = sprite.originY ?? 0.5;
1604
+ const frameLeft = sprite.x - frameW * originX;
1605
+ const frameTop = sprite.y - frameH * originY;
1606
+ if (asset.hitbox) {
1607
+ const shape = this.resolveCurrentHitboxShape(asset, sprite);
1608
+ if (shape) {
1609
+ this.graphics.fillStyle(HITBOX_FILL_COLOR, HITBOX_FILL_ALPHA);
1610
+ this.graphics.fillRect(frameLeft + shape.x, frameTop + shape.y, shape.w, shape.h);
1611
+ this.graphics.lineStyle(1, HITBOX_STROKE_COLOR, HITBOX_STROKE_ALPHA);
1612
+ this.graphics.strokeRect(frameLeft + shape.x, frameTop + shape.y, shape.w, shape.h);
1613
+ }
1614
+ }
1615
+ if (asset.depthAnchor) {
1616
+ const ax = frameLeft + asset.depthAnchor.x;
1617
+ const ay = frameTop + asset.depthAnchor.y;
1618
+ this.graphics.lineStyle(1, ANCHOR_COLOR, ANCHOR_ALPHA);
1619
+ this.graphics.lineBetween(ax - ANCHOR_CROSS_LEN, ay, ax + ANCHOR_CROSS_LEN, ay);
1620
+ this.graphics.lineBetween(ax, ay - ANCHOR_CROSS_LEN, ax, ay + ANCHOR_CROSS_LEN);
1621
+ }
1622
+ }
1623
+ }
1624
+ /**
1625
+ * For per-frame hitboxes, pick the shape the SDK would currently be
1626
+ * applying — the override for the playing frame, falling back to default.
1627
+ * For single-shape hitboxes, just return the rect.
1628
+ */
1629
+ resolveCurrentHitboxShape(asset, sprite) {
1630
+ const h = asset.hitbox;
1631
+ if (!h)
1632
+ return undefined;
1633
+ if (isPerFrameHitbox(h)) {
1634
+ const idx = sprite.anims?.currentFrame?.index;
1635
+ if (typeof idx === 'number' && h.frames[idx])
1636
+ return h.frames[idx];
1637
+ return h.default;
1638
+ }
1639
+ return h;
1640
+ }
1641
+ findWorldSceneInstance() {
1642
+ for (const scene of this.game.scene.getScenes(false)) {
1643
+ const key = scene.scene.key;
1644
+ if (key === EDITOR_OVERLAY_KEY)
1645
+ continue;
1646
+ if (key === UMICAT_HUD_SCENE_KEY)
1647
+ continue;
1648
+ if (key === 'BootScene' || key === 'Boot')
1649
+ continue;
1650
+ // First non-overlay non-HUD non-Boot scene is the world scene.
1651
+ return scene;
1652
+ }
1653
+ return undefined;
1654
+ }
1655
+ /**
1656
+ * Find the world scene's main camera so the overlay can mirror it.
1657
+ * In edit mode, the world scene is paused but its camera state is what
1658
+ * the overlay's pointer math should use.
1659
+ */
1660
+ findWorldSceneCamera() {
1661
+ for (const scene of this.game.scene.getScenes(false)) {
1662
+ if (scene.scene.key === 'GameScene') {
1663
+ return scene.cameras.main;
1664
+ }
1665
+ }
1666
+ return null;
1667
+ }
1668
+ /**
1669
+ * P1 infinite canvas — read the editor camera installed by
1670
+ * `installEditorCameras` in EditorBridge. Returns null on the boot race
1671
+ * when the overlay scene's `create` runs before the bridge attaches it
1672
+ * (resolved by the next `applyEditorPanZoom` mirror).
1673
+ */
1674
+ findEditorCamera() {
1675
+ return this.game['__unboxyEditorCam'] ?? null;
1676
+ }
1677
+ /**
1678
+ * The camera the editor is currently viewing through. Prefers the
1679
+ * editor cam; falls back to the game's cameras.main if the editor cam
1680
+ * hasn't been installed yet (boot race). All pan/zoom math + selection
1681
+ * rects should use this, NOT cameras.main directly.
1682
+ */
1683
+ findActiveEditorCamera() {
1684
+ return this.findEditorCamera() ?? this.findWorldSceneCamera();
1685
+ }
1686
+ /**
1687
+ * P1 infinite canvas — install native DOM listeners for wheel + space
1688
+ * tracking. Phaser's pointer events don't surface `wheel`, and we want
1689
+ * Space to be a global modifier (held while moving cursor), not tied to
1690
+ * a specific GameObject. Listeners are torn down on scene shutdown.
1691
+ *
1692
+ * World mode only: HUD has identity camera + anchor-positioned widgets,
1693
+ * panning/zooming it would rip widgets off their anchors.
1694
+ */
1695
+ installPanZoomInput() {
1696
+ if (typeof document === 'undefined')
1697
+ return;
1698
+ const canvas = this.game.canvas;
1699
+ if (!canvas)
1700
+ return;
1701
+ // Wheel handler — FIGMA-STYLE (matches Mac trackpad muscle memory):
1702
+ // - ctrlKey OR metaKey → zoom. ctrlKey covers Mac trackpad pinch
1703
+ // (Chrome/Safari synthesize ctrlKey=true on
1704
+ // pinch) AND Windows/Linux Ctrl+wheel.
1705
+ // metaKey covers Mac Cmd+wheel (the actual
1706
+ // Mac mouse convention — Figma uses this).
1707
+ // - Otherwise → pan (Mac trackpad two-finger swipe sends
1708
+ // deltaX + deltaY here; mouse wheel without
1709
+ // modifier also pans vertically).
1710
+ //
1711
+ // Briefly switched to engine-style (plain wheel = zoom) in 0.2.84
1712
+ // but reverted in 0.2.85 — Mac trackpad users lost two-finger pan
1713
+ // and the gain wasn't worth it. 0.2.86 added metaKey to fix Mac
1714
+ // mouse Cmd+wheel zoom.
1715
+ this.wheelHandler = (e) => {
1716
+ if (!getEditorState(this.game).active)
1717
+ return;
1718
+ // Allowed in both world AND hud edit modes (2026-05-17). Wheel
1719
+ // mutates the editor cam; HUD widgets use a separate identity
1720
+ // camera so they stay anchored regardless.
1721
+ e.preventDefault(); // suppress browser page scroll
1722
+ const editorCam = this.findActiveEditorCamera();
1723
+ if (!editorCam)
1724
+ return;
1725
+ if (e.ctrlKey || e.metaKey) {
1726
+ // ZOOM — cursor-anchored. World coord under the cursor stays put
1727
+ // after zoom (standard map-app feel).
1728
+ const rect = canvas.getBoundingClientRect();
1729
+ const cssX = e.clientX - rect.left;
1730
+ const cssY = e.clientY - rect.top;
1731
+ const sx = this.game.scale.width / rect.width;
1732
+ const sy = this.game.scale.height / rect.height;
1733
+ const logicalX = cssX * sx;
1734
+ const logicalY = cssY * sy;
1735
+ const worldX = editorCam.scrollX + logicalX / editorCam.zoom;
1736
+ const worldY = editorCam.scrollY + logicalY / editorCam.zoom;
1737
+ // Scale the per-event zoom factor by deltaY magnitude. Mac
1738
+ // trackpad pinch emits ~20-30 wheel events for even a brief
1739
+ // gesture, so a fixed 5% per event compounded into "slight
1740
+ // pinch → 50% to 200% in a blink". deltaY-proportional matches
1741
+ // Figma/Photoshop: slow pinch = fine zoom, fast pinch = quick.
1742
+ // Sensitivity (0.005) tuned so deltaY≈10 (typical Mac pinch
1743
+ // event) ≈ 5% zoom step — same speed as before for moderate
1744
+ // gestures, but much finer for slow ones. Clamp deltaY so a
1745
+ // freak large event (e.g. fling) can't zoom 100x in one tick.
1746
+ const ZOOM_SENSITIVITY = 0.005;
1747
+ const clampedDelta = Math.max(-100, Math.min(100, e.deltaY));
1748
+ const factor = Math.exp(-clampedDelta * ZOOM_SENSITIVITY);
1749
+ // Clamp HERE so the cursor-anchor math uses the same zoom value
1750
+ // applyPanZoom will actually commit. Otherwise at the cap, the
1751
+ // zoom snaps back but the scroll was computed for the (smaller)
1752
+ // unclamped zoom — world drifts on every wheel event while the
1753
+ // zoom indicator stays pinned at 25%. ZOOM_MIN/MAX must mirror
1754
+ // EditorBridge.clampZoom — kept local to avoid a cross-file
1755
+ // import for two constants.
1756
+ const ZOOM_MIN = 0.25;
1757
+ const ZOOM_MAX = 4;
1758
+ const newZoom = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, editorCam.zoom * factor));
1759
+ // No-op when at the cap — nothing to update, prevents accumulated
1760
+ // floating-point drift from many same-zoom events. Approximate
1761
+ // equality because `editorCam.zoom` can have sub-ulp drift from
1762
+ // prior Phaser internal ops (setZoom → cam.dirty resets → etc.),
1763
+ // and strict `===` would miss it.
1764
+ if (Math.abs(newZoom - editorCam.zoom) < 1e-9)
1765
+ return;
1766
+ const newScrollX = worldX - logicalX / newZoom;
1767
+ const newScrollY = worldY - logicalY / newZoom;
1768
+ this.applyPanZoom({ scrollX: newScrollX, scrollY: newScrollY, zoom: newZoom });
1769
+ }
1770
+ else {
1771
+ // PAN — translate scroll delta by the current zoom so 1px of
1772
+ // trackpad swipe ≈ 1px of canvas pan regardless of zoom.
1773
+ // Dead zone: Mac trackpad pinch gestures emit a stray sub-pixel
1774
+ // pan wheel event alongside each ctrlKey=true zoom event
1775
+ // (gesture jitter, finger drift). Without filtering, those
1776
+ // accumulate into visible scroll drift during pinch-at-cap —
1777
+ // the residual horizontal drift the user saw after the
1778
+ // zoom-branch cap fix. Real two-finger pan swipes emit deltas
1779
+ // well over 1px per event, so a 1px floor doesn't impact them.
1780
+ if (Math.abs(e.deltaX) < 1 && Math.abs(e.deltaY) < 1)
1781
+ return;
1782
+ const rect = canvas.getBoundingClientRect();
1783
+ const scaleSx = rect.width > 0 ? this.game.scale.width / rect.width : 1;
1784
+ const scaleSy = rect.height > 0 ? this.game.scale.height / rect.height : 1;
1785
+ const deltaX = (e.deltaX * scaleSx) / editorCam.zoom;
1786
+ const deltaY = (e.deltaY * scaleSy) / editorCam.zoom;
1787
+ this.applyPanZoom({
1788
+ scrollX: editorCam.scrollX + deltaX,
1789
+ scrollY: editorCam.scrollY + deltaY,
1790
+ });
1791
+ }
1792
+ };
1793
+ canvas.addEventListener('wheel', this.wheelHandler, { passive: false });
1794
+ // Space-held tracking so left-drag becomes pan instead of selection.
1795
+ const onKeyDown = (e) => {
1796
+ if (e.code === 'Space' && !this.isTypingTarget(e.target)) {
1797
+ this.spaceHeld = true;
1798
+ // Prevent page scroll while space is held in canvas-focused state.
1799
+ e.preventDefault();
1800
+ }
1801
+ };
1802
+ const onKeyUp = (e) => {
1803
+ if (e.code === 'Space')
1804
+ this.spaceHeld = false;
1805
+ };
1806
+ document.addEventListener('keydown', onKeyDown, true);
1807
+ document.addEventListener('keyup', onKeyUp, true);
1808
+ this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => {
1809
+ if (this.wheelHandler)
1810
+ canvas.removeEventListener('wheel', this.wheelHandler);
1811
+ document.removeEventListener('keydown', onKeyDown, true);
1812
+ document.removeEventListener('keyup', onKeyUp, true);
1813
+ this.spaceHeld = false;
1814
+ this.panActive = false;
1815
+ });
1816
+ }
1817
+ /**
1818
+ * Be polite to text input — don't intercept Space when the user is
1819
+ * typing into a form control inside the iframe (shouldn't normally
1820
+ * happen, but be defensive).
1821
+ */
1822
+ isTypingTarget(target) {
1823
+ const el = target;
1824
+ if (!el)
1825
+ return false;
1826
+ const tag = el.tagName;
1827
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT')
1828
+ return true;
1829
+ return el.isContentEditable === true;
1830
+ }
1831
+ /**
1832
+ * Mode-aware registry lookup. World mode skips the HUD scene; HUD mode
1833
+ * picks the HUD scene specifically.
1834
+ */
1835
+ findEntityRegistry() {
1836
+ if (getEditorMode(this.game) === 'hud')
1837
+ return findHudRegistry(this.game);
1838
+ for (const scene of this.game.scene.getScenes(false)) {
1839
+ const key = scene.scene.key;
1840
+ if (key === UMICAT_HUD_SCENE_KEY)
1841
+ continue;
1842
+ const reg = getEntityRegistry(scene);
1843
+ if (reg)
1844
+ return reg;
1845
+ }
1846
+ return undefined;
1847
+ }
1848
+ }
1849
+ function computeBounds(go) {
1850
+ const positioned = go;
1851
+ // Live tracker query (0.2.97) — code-rendered entities expose a
1852
+ // getter that yields the latest draw extent. See
1853
+ // EditorBridge.readLiveTrackedBounds for the rationale.
1854
+ const getter = go.getData('__unboxyGraphicsBoundsGetter');
1855
+ if (typeof getter === 'function') {
1856
+ const live = getter();
1857
+ if (live) {
1858
+ return {
1859
+ x: positioned.x + live.x,
1860
+ y: positioned.y + live.y,
1861
+ width: live.width,
1862
+ height: live.height,
1863
+ };
1864
+ }
1865
+ }
1866
+ // Code-rendered entities stash hit dimensions on data because Phaser
1867
+ // Graphics has no intrinsic bounds. Use those when present.
1868
+ const hitW = go.getData('editorHitWidth');
1869
+ const hitH = go.getData('editorHitHeight');
1870
+ if (typeof hitW === 'number' && typeof hitH === 'number') {
1871
+ // editorHitOffsetX/Y (0.2.96) for off-center procedural draws.
1872
+ const offX = go.getData('editorHitOffsetX');
1873
+ const offY = go.getData('editorHitOffsetY');
1874
+ if (typeof offX === 'number' && typeof offY === 'number') {
1875
+ return {
1876
+ x: positioned.x + offX,
1877
+ y: positioned.y + offY,
1878
+ width: hitW,
1879
+ height: hitH,
1880
+ };
1881
+ }
1882
+ return {
1883
+ x: positioned.x - hitW / 2,
1884
+ y: positioned.y - hitH / 2,
1885
+ width: hitW,
1886
+ height: hitH,
1887
+ };
1888
+ }
1889
+ const withBounds = go;
1890
+ if (typeof withBounds.getBounds !== 'function')
1891
+ return null;
1892
+ const r = withBounds.getBounds();
1893
+ if (r.width === 0 && r.height === 0)
1894
+ return null;
1895
+ return { x: r.x, y: r.y, width: r.width, height: r.height };
1896
+ }