@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,1326 @@
1
+ import Phaser from 'phaser';
2
+ import { isPerFrameHitbox, } from './types.js';
3
+ import { getEntityRegistry } from './EntityRegistry.js';
4
+ /**
5
+ * Spawn one entity into the scene and register it. Returns the created
6
+ * GameObject so callers (notably `spawnEntity` itself, recursing on a
7
+ * group's children) can attach it to a parent container.
8
+ */
9
+ export function spawnEntity(ctx, entity) {
10
+ let go;
11
+ switch (entity.kind) {
12
+ case 'sprite':
13
+ go = createSprite(ctx, entity);
14
+ break;
15
+ case 'rect':
16
+ go = createRect(ctx, entity);
17
+ break;
18
+ case 'circle':
19
+ go = createCircle(ctx, entity);
20
+ break;
21
+ case 'group':
22
+ go = createGroup(ctx, entity);
23
+ break;
24
+ case 'code-rendered':
25
+ go = createCodeRendered(ctx, entity);
26
+ break;
27
+ case 'tilemap':
28
+ go = createTilemap(ctx, entity);
29
+ break;
30
+ case 'trigger':
31
+ go = createTriggerStub(ctx, entity);
32
+ break;
33
+ default: {
34
+ const exhaustive = entity;
35
+ throw new Error(`[umicat/scene] unknown entity kind: ${JSON.stringify(exhaustive)}`);
36
+ }
37
+ }
38
+ applyTransform(go, entity.transform);
39
+ tagGameObject(go, entity);
40
+ applyEntityPhysicsIfDeclared(ctx.scene, go, entity);
41
+ ctx.registry.register(entity.id, entity.role, go);
42
+ return go;
43
+ }
44
+ /**
45
+ * Apply a scene entity's optional `physics` block. Only renderable entity
46
+ * kinds (sprite / rect / circle / code-rendered) carry a body — they mirror
47
+ * `PrefabKind`. group / tilemap / trigger declare no body even though
48
+ * `physics` is on the shared `WorldEntityBase`. Routed through the same
49
+ * `applyEntityPhysics` the prefab path uses (via `prefabToEntity`), so
50
+ * authored entities and runtime instances get identical, correctly-anchored
51
+ * bodies.
52
+ */
53
+ function applyEntityPhysicsIfDeclared(scene, go, entity) {
54
+ if (!entity.physics)
55
+ return;
56
+ if (entity.kind === 'sprite' ||
57
+ entity.kind === 'rect' ||
58
+ entity.kind === 'circle' ||
59
+ entity.kind === 'code-rendered') {
60
+ applyEntityPhysics(scene, go, entity, entity.physics);
61
+ }
62
+ }
63
+ /**
64
+ * Apply an Arcade physics body to a freshly spawned GameObject from a
65
+ * `PrefabPhysics` block. Shared by `spawnEntity` (authored scene entities)
66
+ * and `spawnPrefab` (runtime prefab instances, which route through
67
+ * `spawnEntity` via `prefabToEntity`) so both paths produce identical bodies.
68
+ *
69
+ * Body size defaults to the visual's drawn extent; offset defaults to
70
+ * centering the body inside that extent.
71
+ *
72
+ * Code-rendered visuals get an extra origin shift. A Phaser `Graphics` has
73
+ * `displayOrigin = (0, 0)` (it has no Origin component) while render scripts
74
+ * draw centered on local (0, 0). The Arcade body positions itself at
75
+ * `gameObject.x + offset.x` for a Graphics, so a default-offset body would
76
+ * land at the bottom-right of the visual — the "player floats above the
77
+ * platform" bug. Shifting the body frame by `(-visualW/2, -visualH/2)`
78
+ * measures offsets from the visual's top-left, matching sprite / primitive
79
+ * semantics (those have an Origin component that already accounts for the
80
+ * centering). This only touches the body, never the Graphics — so rendering
81
+ * is unaffected. (0.2.52 tried `g.setOrigin` on the Graphics instead, which
82
+ * shifted rendering and was reverted in 0.2.53; this is the body-only fix.)
83
+ */
84
+ function applyEntityPhysics(scene, go, entity, physics) {
85
+ scene.physics.add.existing(go);
86
+ const body = go.body;
87
+ if (!body)
88
+ return; // shouldn't happen post `physics.add.existing` but guard
89
+ const visualW = renderableWidth(entity);
90
+ const visualH = renderableHeight(entity);
91
+ const bodyW = physics.bodyW ?? visualW;
92
+ const bodyH = physics.bodyH ?? visualH;
93
+ body.setSize(bodyW, bodyH);
94
+ // Code-rendered (Graphics) measures offsets from the visual's top-left —
95
+ // see the doc comment above. Sprite / primitive have an Origin component,
96
+ // so originShift stays 0 and behavior is identical to <= 0.2.53.
97
+ const isCodeRendered = entity.kind === 'code-rendered';
98
+ const originShiftX = isCodeRendered ? -visualW / 2 : 0;
99
+ const originShiftY = isCodeRendered ? -visualH / 2 : 0;
100
+ const defaultOffsetX = visualW > bodyW ? (visualW - bodyW) / 2 : 0;
101
+ const defaultOffsetY = visualH > bodyH ? (visualH - bodyH) / 2 : 0;
102
+ body.setOffset(originShiftX + (physics.offsetX ?? defaultOffsetX), originShiftY + (physics.offsetY ?? defaultOffsetY));
103
+ if (physics.immovable !== undefined)
104
+ body.setImmovable(physics.immovable);
105
+ if (physics.velocityX !== undefined || physics.velocityY !== undefined) {
106
+ body.setVelocity(physics.velocityX ?? body.velocity.x, physics.velocityY ?? body.velocity.y);
107
+ }
108
+ if (physics.collideWorldBounds)
109
+ body.setCollideWorldBounds(true);
110
+ if (physics.bounceX !== undefined || physics.bounceY !== undefined) {
111
+ body.setBounce(physics.bounceX ?? 0, physics.bounceY ?? 0);
112
+ }
113
+ }
114
+ /**
115
+ * Drawn width of a renderable entity, used as the default Arcade body width.
116
+ * Sprites return 0 so `body.setSize` falls back to the texture frame size.
117
+ */
118
+ function renderableWidth(e) {
119
+ if (e.kind === 'sprite')
120
+ return 0; // body falls back to texture frame
121
+ if (e.kind === 'rect')
122
+ return e.width;
123
+ if (e.kind === 'circle')
124
+ return e.radius * 2;
125
+ if (e.kind === 'code-rendered')
126
+ return e.width ?? 64;
127
+ return 64;
128
+ }
129
+ /** Drawn height of a renderable entity — see `renderableWidth`. */
130
+ function renderableHeight(e) {
131
+ if (e.kind === 'sprite')
132
+ return 0; // body falls back to texture frame
133
+ if (e.kind === 'rect')
134
+ return e.height;
135
+ if (e.kind === 'circle')
136
+ return e.radius * 2;
137
+ if (e.kind === 'code-rendered')
138
+ return e.height ?? 64;
139
+ return 64;
140
+ }
141
+ function createSprite(ctx, entity) {
142
+ // Schema guard — flat schema (SDK 0.3.0). Sprite entities require
143
+ // `assetId` at the top level. Empty / missing → render a magenta
144
+ // placeholder so the rest of the scene loads + the agent sees a clear
145
+ // console message rather than the whole scene boot crashing.
146
+ if (typeof entity.assetId !== 'string') {
147
+ console.warn(`[umicat/scene] sprite entity '${entity.id}' is missing 'assetId'. ` +
148
+ `Sprite entities require { kind: 'sprite', assetId: '<id>', transform: {...} }. ` +
149
+ `Got: ${JSON.stringify(entity, null, 2)}`);
150
+ return missingAssetPlaceholder(ctx, '<missing assetId>');
151
+ }
152
+ let asset;
153
+ try {
154
+ asset = ctx.resolveAsset(entity.assetId);
155
+ }
156
+ catch (e) {
157
+ // Soft-fail when the assetId isn't in the manifest. SceneLoader's
158
+ // preloadSceneAssets logged the warning; here we render a clear
159
+ // magenta-bordered "?" placeholder so the entity is still visible
160
+ // and selectable in the editor. Same pattern as createCodeRendered's
161
+ // missing-render-script path.
162
+ return missingAssetPlaceholder(ctx, entity.assetId);
163
+ }
164
+ // Phaser's add.sprite handles both plain images and atlas/spritesheet
165
+ // textures uniformly when given (key, frame). For plain images, frame
166
+ // is undefined and Phaser uses the default frame.
167
+ const sprite = ctx.scene.add.sprite(0, 0, asset.textureKey, entity.frame);
168
+ if (entity.tint)
169
+ sprite.setTint(parseColor(entity.tint));
170
+ if (typeof entity.alpha === 'number')
171
+ sprite.setAlpha(entity.alpha);
172
+ if (entity.flipX)
173
+ sprite.setFlipX(true);
174
+ if (entity.flipY)
175
+ sprite.setFlipY(true);
176
+ // Slice 8: depth anchor → setOrigin so sprite.y aligns with the asset's
177
+ // footprint pixel (feet, trunk base, etc.). Without this, ySort compares
178
+ // geometric centers — characters draw behind walls they're standing in
179
+ // front of. Phaser's sprite.width / .height reflect frame dims (not the
180
+ // whole spritesheet texture).
181
+ if (asset.depthAnchor) {
182
+ const frameW = sprite.width || 1;
183
+ const frameH = sprite.height || 1;
184
+ sprite.setOrigin(asset.depthAnchor.x / frameW, asset.depthAnchor.y / frameH);
185
+ }
186
+ // Slice 8: apply asset.hitbox to the physics body IF a body exists. We do
187
+ // NOT auto-add a body — the agent's physics skill / behavior code decides
188
+ // when to wire collision. This call is a no-op when asset.hitbox is unset
189
+ // (chat-only Workflow A path stays untouched).
190
+ applyAssetHitbox(sprite, asset);
191
+ return sprite;
192
+ }
193
+ /**
194
+ * Magenta "?" placeholder for an entity whose assetId isn't in the manifest.
195
+ * Drawn as a Graphics so the editor's hit-test (which uses getBounds) lands
196
+ * on the visible square. Mirrors createCodeRendered's missing-script path.
197
+ */
198
+ function missingAssetPlaceholder(ctx, assetId) {
199
+ const w = 64;
200
+ const h = 64;
201
+ const g = ctx.scene.add.graphics();
202
+ g.fillStyle(0x4d2244, 0.4);
203
+ g.fillRect(-w / 2, -h / 2, w, h);
204
+ g.lineStyle(2, 0xff00ff, 0.9);
205
+ g.strokeRect(-w / 2, -h / 2, w, h);
206
+ // Diagonal "no entry" lines + a "?" text so it's obvious from a glance.
207
+ g.lineBetween(-w / 2, -h / 2, w / 2, h / 2);
208
+ g.lineBetween(w / 2, -h / 2, -w / 2, h / 2);
209
+ // Phaser Graphics has no intrinsic size — sizeForHitTest sets bounds so
210
+ // the editor's hit-test (which uses getBounds) can find this entity.
211
+ sizeForHitTest(g, w, h);
212
+ g.setDataEnabled();
213
+ g.setData('unboxyMissingAssetId', assetId);
214
+ return g;
215
+ }
216
+ function createRect(ctx, e) {
217
+ if (typeof e.width !== 'number' || typeof e.height !== 'number') {
218
+ console.warn(`[umicat/scene] rect entity '${e.id}' is missing 'width' / 'height'. ` +
219
+ `Rect entities require { kind: 'rect', width: N, height: N, fillColor?: '#hex', transform: {...} }. ` +
220
+ `Got: ${JSON.stringify(e, null, 2)}`);
221
+ return ctx.scene.add.rectangle(0, 0, 64, 64, 0xff00ff);
222
+ }
223
+ const fill = e.fillColor ? parseColor(e.fillColor) : 0xffffff;
224
+ const rect = ctx.scene.add.rectangle(0, 0, e.width, e.height, fill);
225
+ if (e.strokeColor && e.strokeWidth) {
226
+ rect.setStrokeStyle(e.strokeWidth, parseColor(e.strokeColor));
227
+ }
228
+ if (typeof e.alpha === 'number')
229
+ rect.setAlpha(e.alpha);
230
+ return rect;
231
+ }
232
+ function createCircle(ctx, e) {
233
+ if (typeof e.radius !== 'number') {
234
+ console.warn(`[umicat/scene] circle entity '${e.id}' is missing 'radius'. ` +
235
+ `Circle entities require { kind: 'circle', radius: N, fillColor?: '#hex', transform: {...} }. ` +
236
+ `Got: ${JSON.stringify(e, null, 2)}`);
237
+ return ctx.scene.add.circle(0, 0, 32, 0xff00ff);
238
+ }
239
+ const fill = e.fillColor ? parseColor(e.fillColor) : 0xffffff;
240
+ const arc = ctx.scene.add.circle(0, 0, e.radius, fill);
241
+ if (e.strokeColor && e.strokeWidth) {
242
+ arc.setStrokeStyle(e.strokeWidth, parseColor(e.strokeColor));
243
+ }
244
+ if (typeof e.alpha === 'number')
245
+ arc.setAlpha(e.alpha);
246
+ return arc;
247
+ }
248
+ function createGroup(ctx, entity) {
249
+ const container = ctx.scene.add.container(0, 0);
250
+ for (const child of entity.children) {
251
+ if (child.kind === 'group') {
252
+ // v1 cap: groups can't nest. Surface a clear error rather than
253
+ // silently producing a half-attached hierarchy.
254
+ throw new Error(`[umicat/scene] group '${entity.id}' has a nested group child '${child.id}' — v1 supports one level of nesting only`);
255
+ }
256
+ const childGo = spawnEntity(ctx, child);
257
+ container.add(childGo);
258
+ }
259
+ return container;
260
+ }
261
+ function createCodeRendered(ctx, entity) {
262
+ // Schema guard — flat schema (SDK 0.3.0). Code-rendered entities require
263
+ // `script` at the top level. Without it, accessing `entity.script` is
264
+ // undefined and we can't resolve a renderer.
265
+ if (typeof entity.script !== 'string') {
266
+ console.warn(`[umicat/scene] code-rendered entity '${entity.id}' is missing 'script'. ` +
267
+ `Code-rendered entities require { kind: 'code-rendered', script: 'src/visuals/<name>.ts', transform: {...} }. ` +
268
+ `Got: ${JSON.stringify(entity, null, 2)}`);
269
+ return missingAssetPlaceholder(ctx, '<missing script>');
270
+ }
271
+ const w = entity.width ?? 64;
272
+ const h = entity.height ?? 64;
273
+ const render = ctx.resolveRenderScript?.(entity.script);
274
+ if (!render) {
275
+ // Render scripts have a registry, but a missing entry shouldn't crash
276
+ // the whole scene boot — the agent might be writing the script in a
277
+ // follow-up turn. Render a clear "?" placeholder so the user sees the
278
+ // entity exists but the visual is unresolved.
279
+ const ph = ctx.scene.add.graphics();
280
+ ph.fillStyle(0x666666, 0.4);
281
+ ph.fillRect(-w / 2, -h / 2, w, h);
282
+ ph.lineStyle(2, 0xff8800, 0.9);
283
+ ph.strokeRect(-w / 2, -h / 2, w, h);
284
+ sizeForHitTest(ph, w, h);
285
+ ph.setData('renderScriptMissing', entity.script);
286
+ return ph;
287
+ }
288
+ const g = ctx.scene.add.graphics();
289
+ // Install the auto-tracker (0.2.96) so the editor's hit area follows
290
+ // the script's actual drawn extent — not a stale `width/height` from
291
+ // the scene file. Critical for procedural visuals (snake, grids,
292
+ // tilemaps, dynamic UI) where the rendered area changes every frame.
293
+ const getBounds = installGraphicsTracker(g);
294
+ g.setData('__unboxyGraphicsBoundsGetter', getBounds);
295
+ const params = entity.params ?? {};
296
+ render(g, params);
297
+ applyTrackedHitArea(g, w, h);
298
+ // Stash data needed for live re-render from EditorBridge.applyEdit.
299
+ g.setData('renderScriptPath', entity.script);
300
+ g.setData('renderScriptParams', params);
301
+ return g;
302
+ }
303
+ /**
304
+ * Read the auto-tracker's recorded draw extent and set editor hit-area
305
+ * data on the GameObject. Falls back to declared `visual.width/height`
306
+ * (centered on transform) when nothing was tracked (script didn't call
307
+ * any tracked methods, or called them through unwrapped paths).
308
+ *
309
+ * Exported so EditorBridge can call it after a re-render too —
310
+ * applyCodeRenderedParamsPatch reads new params, calls render again,
311
+ * then this updates the hit area to the new draw extent.
312
+ */
313
+ export function applyTrackedHitArea(g, fallbackW, fallbackH) {
314
+ const getter = g.getData('__unboxyGraphicsBoundsGetter');
315
+ const bounds = getter ? getter() : null;
316
+ if (bounds && bounds.width > 0 && bounds.height > 0) {
317
+ g.setData('editorHitWidth', bounds.width);
318
+ g.setData('editorHitHeight', bounds.height);
319
+ g.setData('editorHitOffsetX', bounds.x);
320
+ g.setData('editorHitOffsetY', bounds.y);
321
+ }
322
+ else {
323
+ g.setData('editorHitWidth', fallbackW);
324
+ g.setData('editorHitHeight', fallbackH);
325
+ g.setData('editorHitOffsetX', undefined);
326
+ g.setData('editorHitOffsetY', undefined);
327
+ }
328
+ }
329
+ /**
330
+ * Wrap a Phaser Graphics object's draw methods so they record min/max
331
+ * XY of every shape drawn through them. Returns a getter that yields
332
+ * the current bounds (or null if no draws since last `clear()`).
333
+ *
334
+ * Covers the methods agents commonly reach for in render scripts:
335
+ * fillRect, strokeRect, fillRoundedRect, strokeRoundedRect, fillCircle,
336
+ * strokeCircle, fillEllipse, strokeEllipse, fillTriangle, strokeTriangle,
337
+ * lineBetween, fillPoint, arc, moveTo, lineTo. `clear()` resets the
338
+ * tracker so each redraw cycle starts fresh. Less-common methods
339
+ * (fillPath/strokePath after manual moveTo/lineTo, fillRectShape, etc.)
340
+ * partially covered via moveTo/lineTo tracking; pure-shape variants
341
+ * fall back to `visual.width/height` — acceptable for v1.
342
+ */
343
+ function installGraphicsTracker(g) {
344
+ let minX = Infinity;
345
+ let minY = Infinity;
346
+ let maxX = -Infinity;
347
+ let maxY = -Infinity;
348
+ const track = (x, y) => {
349
+ if (x < minX)
350
+ minX = x;
351
+ if (x > maxX)
352
+ maxX = x;
353
+ if (y < minY)
354
+ minY = y;
355
+ if (y > maxY)
356
+ maxY = y;
357
+ };
358
+ const trackRect = (x, y, w, h) => {
359
+ track(x, y);
360
+ track(x + w, y + h);
361
+ };
362
+ const trackRadius = (x, y, r) => {
363
+ track(x - r, y - r);
364
+ track(x + r, y + r);
365
+ };
366
+ const gAny = g;
367
+ const wrap = (name, pre) => {
368
+ const orig = gAny[name].bind(g);
369
+ gAny[name] = (...args) => {
370
+ pre(...args);
371
+ return orig(...args);
372
+ };
373
+ };
374
+ wrap('fillRect', (x, y, w, h) => trackRect(x, y, w, h));
375
+ wrap('strokeRect', (x, y, w, h) => trackRect(x, y, w, h));
376
+ wrap('fillRoundedRect', (x, y, w, h) => trackRect(x, y, w, h));
377
+ wrap('strokeRoundedRect', (x, y, w, h) => trackRect(x, y, w, h));
378
+ wrap('fillCircle', (x, y, r) => trackRadius(x, y, r));
379
+ wrap('strokeCircle', (x, y, r) => trackRadius(x, y, r));
380
+ wrap('fillEllipse', (x, y, w, h) => trackRect(x - w / 2, y - h / 2, w, h));
381
+ wrap('strokeEllipse', (x, y, w, h) => trackRect(x - w / 2, y - h / 2, w, h));
382
+ wrap('fillTriangle', (x1, y1, x2, y2, x3, y3) => {
383
+ track(x1, y1);
384
+ track(x2, y2);
385
+ track(x3, y3);
386
+ });
387
+ wrap('strokeTriangle', (x1, y1, x2, y2, x3, y3) => {
388
+ track(x1, y1);
389
+ track(x2, y2);
390
+ track(x3, y3);
391
+ });
392
+ wrap('lineBetween', (x1, y1, x2, y2) => {
393
+ track(x1, y1);
394
+ track(x2, y2);
395
+ });
396
+ wrap('moveTo', (x, y) => track(x, y));
397
+ wrap('lineTo', (x, y) => track(x, y));
398
+ wrap('fillPoint', (x, y, size) => {
399
+ const s = (typeof size === 'number' ? size : 1) / 2;
400
+ trackRect(x - s, y - s, s * 2, s * 2);
401
+ });
402
+ wrap('arc', (x, y, r) => trackRadius(x, y, r));
403
+ // `clear()` resets the tracker so each redraw cycle starts fresh.
404
+ const origClear = gAny['clear'].bind(g);
405
+ gAny['clear'] = () => {
406
+ minX = Infinity;
407
+ minY = Infinity;
408
+ maxX = -Infinity;
409
+ maxY = -Infinity;
410
+ return origClear();
411
+ };
412
+ return () => {
413
+ if (minX === Infinity)
414
+ return null;
415
+ return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
416
+ };
417
+ }
418
+ /**
419
+ * Phaser Graphics doesn't track its drawn area — `getBounds()` returns
420
+ * a 0×0 rect because the Graphics class doesn't include the Size/Origin
421
+ * mixins by default. We:
422
+ *
423
+ * 1. Call `setSize(w, h)` so the GameObject has proper dimensions
424
+ * for Phaser arcade physics to size + position bodies correctly.
425
+ * Without this, `physics.add.existing(go)` creates a 0×0 body
426
+ * anchored to the GO's transform position, and any subsequent
427
+ * `body.setOffset(x, y)` is measured from sprite.x/sprite.y
428
+ * rather than from a top-left corner — that mismatch between the
429
+ * agent's mental model (Phaser docs: offset is from sprite
430
+ * top-left) and Phaser's actual math (offset is from
431
+ * `sprite.x - sprite.displayOriginX`) produces a "player floats
432
+ * above the platform" bug because the body sits to the bottom-
433
+ * right of the drawn visual.
434
+ *
435
+ * 2. Call `setOrigin(0.5, 0.5)` so the GO's transform position IS
436
+ * its visual center — matching the render-script convention of
437
+ * drawing around (0, 0). Combined with the explicit setSize above,
438
+ * this makes `body.setOffset` behave per standard Phaser arcade
439
+ * semantics (offset from GO top-left).
440
+ *
441
+ * 3. Stash the same dimensions on the data manager too, for the
442
+ * editor's hit-test path (some legacy editor code reads from
443
+ * `editorHitWidth`/`editorHitHeight` before falling back to
444
+ * `getBounds()`).
445
+ *
446
+ * Surfaced 2026-05-14 on game 7f624e76 (platformer test): player
447
+ * floated ~20 px above platforms because the agent's reasonable
448
+ * `body.setSize(20, 40); body.setOffset(4, 4)` (which assumes
449
+ * Phaser docs' "offset from top-left" semantics) didn't behave
450
+ * that way without explicit setSize / setOrigin on the Graphics.
451
+ * Per 0.2.23 changelog this was supposed to be done; the actual
452
+ * setSize / setOrigin calls were dropped in a later refactor when
453
+ * this helper was extracted.
454
+ */
455
+ function sizeForHitTest(g, width, height) {
456
+ // Reverted in 0.2.53 (2026-05-14): 0.2.52 added g.setSize + g.setOrigin
457
+ // here to make Phaser arcade body offsets behave per standard Phaser
458
+ // docs ("offset from top-left"). But Phaser's `setOrigin` ALSO shifts
459
+ // Graphics rendering — Phaser computes `displayOriginX = originX *
460
+ // width` and renders the Graphics commands offset by -displayOrigin.
461
+ // Render scripts draw around (0, 0) assuming that's the visual center;
462
+ // adding setOrigin(0.5, 0.5) made those drawings render at
463
+ // (transform.x - w/2, transform.y - h/2) instead — i.e., a 14×24 shift
464
+ // up-left for the canonical 28×48 humanoid. Every code-rendered entity
465
+ // in every existing game appeared in the wrong world position.
466
+ //
467
+ // The right long-term fix is documentation: code-rendered entities have
468
+ // Graphics with width=0/height=0/origin=(0,0), so Phaser arcade body
469
+ // offsets are interpreted from `sprite.x` / `sprite.y` directly (not
470
+ // from a top-left). Agents writing physics for code-rendered entities
471
+ // should compute body.setOffset accounting for this — see the prefab
472
+ // skill's body-offset cookbook.
473
+ //
474
+ // For now, restore the data-manager mirror that was the only thing
475
+ // this helper ever actually did before 0.2.52.
476
+ g.setData('editorHitWidth', width);
477
+ g.setData('editorHitHeight', height);
478
+ }
479
+ function applyTransform(go, t) {
480
+ // Most game objects implement Transform; Container does too. Cast through
481
+ // a permissive shape so this works for sprite/rect/circle/container alike.
482
+ const target = go;
483
+ target.x = t.x;
484
+ target.y = t.y;
485
+ if (typeof t.rotation === 'number')
486
+ target.rotation = t.rotation;
487
+ if (typeof t.scaleX === 'number')
488
+ target.scaleX = t.scaleX;
489
+ if (typeof t.scaleY === 'number')
490
+ target.scaleY = t.scaleY;
491
+ if (typeof t.depth === 'number' && target.setDepth)
492
+ target.setDepth(t.depth);
493
+ }
494
+ function tagGameObject(go, entity) {
495
+ go.setData('entityId', entity.id);
496
+ // Slice 6 Phase B: paint-mode detection reads entityKind off the GO without
497
+ // a registry round-trip. Tag every entity so the check is uniform — only
498
+ // tilemap consumers branch on it today, but other kinds (trigger, group)
499
+ // may need similar fast-path checks in future slices.
500
+ go.setData('entityKind', entity.kind);
501
+ if (entity.role)
502
+ go.setData('entityRole', entity.role);
503
+ if (entity.properties)
504
+ go.setData('entityProperties', entity.properties);
505
+ // Slice 8: ySort loop reads transform.depth to skip explicit-depth entities.
506
+ // Stash the transform on the GO's data manager to avoid a round-trip back
507
+ // to the scene file every frame.
508
+ go.setData('entityTransform', entity.transform);
509
+ // Slice 8: assetUpdate handler walks the registry to find all instances of
510
+ // an asset whose hitbox/anchor changed. Stash the assetId for sprite
511
+ // entities so the lookup is O(n) on the registry rather than a per-entity
512
+ // re-parse of the scene file.
513
+ if (entity.kind === 'sprite') {
514
+ go.setData('entityAssetId', entity.assetId);
515
+ }
516
+ // Slice 6 Phase B: tilemap-specific stash so the editor's tilemap
517
+ // handler can read cellSize / size / per-layer dims without re-parsing
518
+ // the scene file every paint op.
519
+ if (entity.kind === 'tilemap') {
520
+ go.setData('tilemapTileSize', entity.tileSize);
521
+ go.setData('tilemapSize', entity.size);
522
+ }
523
+ }
524
+ /**
525
+ * Trigger stub renderer — slice 3. Renders the trigger zone as a
526
+ * semi-transparent fill with cyan tint per design 03 §8.2 (in edit mode it
527
+ * is always visible; at play time the SDK will hide it once slice 5 wires
528
+ * the behavior layer).
529
+ */
530
+ function createTriggerStub(ctx, entity) {
531
+ const TINT = 0x00bfff; // cyan-ish "neutral trigger" per design
532
+ const FILL_ALPHA = 0.25;
533
+ const STROKE_ALPHA = 0.7;
534
+ if (entity.shape.kind === 'rect') {
535
+ const r = ctx.scene.add.rectangle(0, 0, entity.shape.width, entity.shape.height, TINT, FILL_ALPHA);
536
+ r.setStrokeStyle(1.5, TINT, STROKE_ALPHA);
537
+ return r;
538
+ }
539
+ // circle
540
+ const c = ctx.scene.add.circle(0, 0, entity.shape.radius, TINT, FILL_ALPHA);
541
+ c.setStrokeStyle(1.5, TINT, STROKE_ALPHA);
542
+ return c;
543
+ }
544
+ /**
545
+ * Tilemap renderer — slice 6 Phase A (2026-05-18).
546
+ *
547
+ * Returns a `Container` parent holding (a) a grid-sketch Graphics drawn at
548
+ * low alpha as background so the tilemap's bounds remain visible even
549
+ * where layer data is empty, and (b) one Phaser `TilemapLayer` per
550
+ * authored layer (only when that layer has `data`). The container is
551
+ * positioned at the entity's `transform.x/y`; children are offset by
552
+ * `(-w/2, -h/2)` so the tilemap is centered on the entity origin —
553
+ * matches sprite / primitive / code-rendered convention.
554
+ *
555
+ * Soft-fail strategy mirrors the rest of the loader: a layer whose
556
+ * tileset isn't in the manifest or isn't loaded yet is skipped with a
557
+ * warn; the background grid still renders so the entity stays selectable.
558
+ * preloadSceneAssets already warned about missing assets — this is
559
+ * defense-in-depth.
560
+ *
561
+ * Phase B will add live-edit handlers (paint cell, add/remove layer,
562
+ * toggle visibility). Phase D will add Wang autotile resolution at
563
+ * paint time. Phase C wires per-tile collision via `setCollisionByProperty`
564
+ * once tileset metadata carries `tiles[<idx>].metadata.solid`.
565
+ */
566
+ function createTilemap(ctx, entity) {
567
+ const tileW = entity.tileSize.width;
568
+ const tileH = entity.tileSize.height;
569
+ const cellsW = entity.size.width;
570
+ const cellsH = entity.size.height;
571
+ const pxW = cellsW * tileW;
572
+ const pxH = cellsH * tileH;
573
+ const left = -pxW / 2;
574
+ const top = -pxH / 2;
575
+ const container = ctx.scene.add.container(0, 0);
576
+ // Background grid sketch — drawn first so layer tiles render on top.
577
+ // Kept even when layers have data so empty cells show the grid; this
578
+ // also makes empty tilemaps (no painted data yet) visible + selectable.
579
+ // **Editor-only**: hidden by default; `enterEdit` walks tilemap entities
580
+ // and toggles visibility on. `exitEdit` toggles back off. Without this
581
+ // the grid bleeds through into Play mode where it has no purpose and
582
+ // visually conflicts with the actual painted tiles.
583
+ const grid = ctx.scene.add.graphics();
584
+ grid.fillStyle(0xffffff, 0.04);
585
+ grid.fillRect(left, top, pxW, pxH);
586
+ grid.lineStyle(2, 0xaaaaaa, 0.45);
587
+ grid.strokeRect(left, top, pxW, pxH);
588
+ if (tileW >= 8 && cellsW * cellsH < 4000) {
589
+ grid.lineStyle(1, 0xaaaaaa, 0.1);
590
+ for (let i = 1; i < cellsW; i++) {
591
+ const x = left + i * tileW;
592
+ grid.beginPath();
593
+ grid.moveTo(x, top);
594
+ grid.lineTo(x, top + pxH);
595
+ grid.strokePath();
596
+ }
597
+ for (let i = 1; i < cellsH; i++) {
598
+ const y = top + i * tileH;
599
+ grid.beginPath();
600
+ grid.moveTo(left, y);
601
+ grid.lineTo(left + pxW, y);
602
+ grid.strokePath();
603
+ }
604
+ }
605
+ grid.setData('unboxyTilemapGrid', true);
606
+ grid.setVisible(false);
607
+ container.add(grid);
608
+ // Create one Phaser TilemapLayer per authored layer.
609
+ //
610
+ // **Phaser quirk**: TilemapLayer does NOT inherit parent Container
611
+ // transforms at render time (the renderer treats `layer.x`/`layer.y` as
612
+ // world coords directly). If we made layers Container children, the cell
613
+ // grid would render at the wrong world position even though click→cell
614
+ // math worked. Symptom: paint clicks land in the editor void (near world
615
+ // origin) instead of inside the tilemap's bounds.
616
+ //
617
+ // Workaround: layers are scene-root siblings (NOT container children),
618
+ // positioned at WORLD coords matching the container's transform. The
619
+ // container holds only the grid sketch for visual bounds + hit-test.
620
+ // Layer refs are stashed on the container's data manager so the painter
621
+ // can find them by id. A per-frame sync hook (`installTilemapLayerSync`,
622
+ // installed by `loadWorldScene` when any tilemap entity exists) keeps
623
+ // layer positions in lockstep with the container's transform — so
624
+ // dragging the entity still visually moves the painted tiles.
625
+ const sortedLayers = [...entity.layers].sort((a, b) => (a.z ?? 0) - (b.z ?? 0));
626
+ const layerRefs = [];
627
+ for (const layer of sortedLayers) {
628
+ const layerObj = renderLayerInto(ctx, entity, layer, cellsW, cellsH, tileW, tileH);
629
+ if (!layerObj)
630
+ continue;
631
+ // Position in WORLD coords directly. The container's transform.x/y has
632
+ // already been applied to the container above; we mirror it to the
633
+ // layer so the layer renders at the same world position.
634
+ layerObj.x = entity.transform.x + left;
635
+ layerObj.y = entity.transform.y + top;
636
+ if (layer.visible === false)
637
+ layerObj.setVisible(false);
638
+ // Tag with layer id so the painter can locate by id (survives reorders).
639
+ layerObj.setData('tilemapLayerId', layer.id);
640
+ // Stamp the parent tilemap entity id so the sync hook can find which
641
+ // container each layer belongs to without a registry walk per frame.
642
+ layerObj.setData('tilemapEntityId', entity.id);
643
+ // Stash the layer's local-offset (left/top) so the sync hook can
644
+ // recompute world position from container.x/y + offset whenever the
645
+ // container moves. These are stable for a layer's lifetime.
646
+ layerObj.setData('tilemapLocalOffsetX', left);
647
+ layerObj.setData('tilemapLocalOffsetY', top);
648
+ // Slice 6 Phase C — per-tile metadata + auto-collision. Called here
649
+ // AFTER layer.x/y are set so `tile.getLeft()` returns correct world
650
+ // coords for sub-tile static body placement; calling this inside
651
+ // `renderLayerInto` would create bodies at world origin (visible as
652
+ // collision bodies stacked top-left of the canvas in earlier bug).
653
+ const tilesetForLayer = layerObj.tileset[0];
654
+ const tilesetIdForLayer = layer.tilesetIds?.[0];
655
+ if (tilesetForLayer && tilesetIdForLayer) {
656
+ try {
657
+ const layerAsset = ctx.resolveAsset(tilesetIdForLayer);
658
+ applyTilesetTileMetadata(tilesetForLayer, layerObj, layerAsset);
659
+ // Phase F — arm tile animations (water flow / lava bubble /
660
+ // torch flicker). Must run AFTER applyTilesetTileMetadata so
661
+ // the layer's tile.index field is fully populated when we scan
662
+ // for cells matching animation rootTileIndex.
663
+ applyTilesetAnimations(layerObj, layerAsset);
664
+ }
665
+ catch {
666
+ /* asset missing already warned by renderLayerInto */
667
+ }
668
+ }
669
+ layerRefs.push(layerObj);
670
+ }
671
+ // Stash layer refs on the container so the painter + structure-op handlers
672
+ // (addLayer / removeLayer / setLayerVisibility) can find them without a
673
+ // scene-wide walk. The container's `list` no longer holds them.
674
+ container.setData('tilemapLayers', layerRefs);
675
+ // Hit-test stash for the editor (Container reports 0×0 bounds otherwise).
676
+ sizeForHitTest(container, pxW, pxH);
677
+ return container;
678
+ }
679
+ /**
680
+ * Build one `TilemapLayer` for a single tilemap-entity layer. Returns
681
+ * `null` when the layer is unrenderable (no data, missing tileset, etc.)
682
+ * — caller skips and the background grid still indicates the bounds.
683
+ */
684
+ function renderLayerInto(ctx, entity, layer, cellsW, cellsH, tileW, tileH) {
685
+ // Pick first tileset for Phase A (multi-tileset blending deferred).
686
+ const tilesetId = layer.tilesetIds?.[0];
687
+ if (!tilesetId) {
688
+ // Without a tileset there's nothing to paint with — skip even an empty
689
+ // layer. The grid sketch from the parent container still indicates
690
+ // bounds. Phase B's painter creates layers with a tileset attached;
691
+ // this branch only fires for hand-authored degenerate cases.
692
+ return null;
693
+ }
694
+ let asset;
695
+ try {
696
+ asset = ctx.resolveAsset(tilesetId);
697
+ }
698
+ catch {
699
+ // preloadSceneAssets already warned about this missing asset.
700
+ return null;
701
+ }
702
+ if (!ctx.scene.textures.exists(asset.textureKey)) {
703
+ // eslint-disable-next-line no-console
704
+ console.warn(`[umicat/scene] tilemap layer '${layer.id}' tileset '${tilesetId}' texture not loaded; skipping layer`);
705
+ return null;
706
+ }
707
+ // Cell size: prefer the asset's tileset metadata, then fall back to
708
+ // spritesheet's frame dims (if the user is using a spritesheet asset
709
+ // as a tileset), then fall back to the tilemap entity's tileSize.
710
+ // The Configure Tileset modal (Phase A.2) is what gets users to populate
711
+ // `asset.tileset.cellSize` deliberately — but for the SDK alone, these
712
+ // fallbacks let a hand-authored tilemap render against any image asset.
713
+ const cellSize = resolveTilesetCellSize(asset, tileW, tileH);
714
+ // Normalise data: Phaser's tilemap factory wants a rectangular 2D array
715
+ // of integers. Coerce missing rows / cells / non-numeric entries to -1
716
+ // (Phaser's "no tile" sentinel). Tile indices are 0-based in the source
717
+ // image (row-major), matching Phaser's native convention. An empty layer
718
+ // (no `data` field) materializes as an all-(-1) grid so the painter has
719
+ // something to mutate — Phase B's `putTileAt` calls then flip cells from
720
+ // -1 to real indices in place.
721
+ const grid = [];
722
+ const sourceData = layer.data ?? [];
723
+ for (let y = 0; y < cellsH; y++) {
724
+ const row = sourceData[y] ?? [];
725
+ const out = new Array(cellsW);
726
+ for (let x = 0; x < cellsW; x++) {
727
+ const v = row[x];
728
+ out[x] = typeof v === 'number' ? v : -1;
729
+ }
730
+ grid.push(out);
731
+ }
732
+ let map;
733
+ try {
734
+ map = ctx.scene.make.tilemap({
735
+ data: grid,
736
+ tileWidth: cellSize.width,
737
+ tileHeight: cellSize.height,
738
+ });
739
+ }
740
+ catch (e) {
741
+ // eslint-disable-next-line no-console
742
+ console.warn(`[umicat/scene] failed to construct tilemap for entity '${entity.id}' layer '${layer.id}': ${String(e)}`);
743
+ return null;
744
+ }
745
+ const tileset = map.addTilesetImage(tilesetId, asset.textureKey, cellSize.width, cellSize.height, asset.tileset?.margin ?? 0, asset.tileset?.spacing ?? 0);
746
+ if (!tileset) {
747
+ // eslint-disable-next-line no-console
748
+ console.warn(`[umicat/scene] addTilesetImage returned null for '${tilesetId}' on entity '${entity.id}' layer '${layer.id}'; skipping`);
749
+ return null;
750
+ }
751
+ const layerObj = map.createLayer(0, tileset, 0, 0);
752
+ if (!layerObj) {
753
+ // eslint-disable-next-line no-console
754
+ console.warn(`[umicat/scene] createLayer returned null for entity '${entity.id}' layer '${layer.id}'; skipping`);
755
+ return null;
756
+ }
757
+ // Slice 6 Phase D fix — prevent tile edge bleeding. Two-pronged:
758
+ // (1) NEAREST filter on tileset texture — kills bilinear interpolation
759
+ // that would sample neighbor-tile pixels at tile boundaries.
760
+ // (2) `roundPixels = true` on the world camera — snaps the camera to
761
+ // integer pixel positions so the GPU's NEAREST sampler can't pick
762
+ // "between" pixels at tile edges due to subpixel camera offsets.
763
+ // Together they kill the thin dark hairlines visible at tile edges on
764
+ // typical (non-extruded) tilesets like Sprout Lands. Tilemaps are
765
+ // virtually always pixel art; applying these unconditionally is safe.
766
+ // Editor camera roundPixels is set separately in EditorBridge.
767
+ const tex = ctx.scene.textures.get(asset.textureKey);
768
+ if (tex)
769
+ tex.setFilter(Phaser.Textures.FilterMode.NEAREST);
770
+ ctx.scene.cameras.main.setRoundPixels(true);
771
+ // If render tileSize differs from source cellSize, scale the layer so
772
+ // each cell renders at the entity's declared tileSize. Skipping this
773
+ // would render at source pixels and visually mismatch the grid sketch.
774
+ if (cellSize.width !== tileW || cellSize.height !== tileH) {
775
+ layerObj.setScale(tileW / cellSize.width, tileH / cellSize.height);
776
+ }
777
+ // Slice 6 Phase C — stash the manifest asset id so live `assetUpdate`
778
+ // can find every layer affected by a Tile Metadata Editor save. The
779
+ // Phaser Tileset object's `name` IS the manifest id (set via
780
+ // addTilesetImage's first arg), but stashing on the layer is cheaper
781
+ // and avoids depending on Phaser internals.
782
+ layerObj.setData('tilemapTilesetId', tilesetId);
783
+ // Note: `applyTilesetTileMetadata` is intentionally NOT called here —
784
+ // the sub-tile body half (`syncSubTileBodies`) needs world coords,
785
+ // which `tile.getLeft()` only reports correctly AFTER the caller sets
786
+ // `layerObj.x/y`. Caller (createTilemap) invokes it post-positioning.
787
+ return layerObj;
788
+ }
789
+ /**
790
+ * Slice 6 Phase C — wire per-tile metadata onto a Phaser tileset + layer.
791
+ *
792
+ * Sources `asset.tileset.tiles` (sparse map keyed by tile index) and:
793
+ * 1. Replaces `tileset.tileProperties` so any FUTURE `putTileAt` call
794
+ * auto-stamps the new tile's `properties` from the lookup. Phaser
795
+ * reads `tileset.tileProperties[index]` at tile-creation time.
796
+ * 2. Walks existing tiles via `layer.forEachTile` and refreshes each
797
+ * tile's `properties` — needed for tiles that were already painted
798
+ * before this fn ran (initial scene load, or `assetUpdate` replay).
799
+ * 3. Calls `layer.setCollisionByProperty({ solid: true })` so any tile
800
+ * whose `properties.solid === true` participates in Arcade collision.
801
+ * Idempotent + safe to call when no tile is solid (no-op).
802
+ *
803
+ * Exported because `EditorBridge.applyTilemapStructureOp` (addLayer) and
804
+ * `EditorBridge.handleAssetUpdate` both need to re-arm collision wiring
805
+ * without going through the full scene load. SDK 0.2.115+.
806
+ */
807
+ export function applyTilesetTileMetadata(tileset, layer, asset) {
808
+ applyTilesetTileMetadataInternal(layer.scene, tileset, layer, asset);
809
+ }
810
+ function applyTilesetTileMetadataInternal(scene, tileset, layer, asset) {
811
+ const tiles = asset.tileset?.tiles;
812
+ // Always REPLACE (not merge) so the live `assetUpdate` path correctly
813
+ // clears properties on tiles that no longer have metadata. Empty {} is
814
+ // the "no per-tile properties" state Phaser expects.
815
+ const tileProperties = {};
816
+ if (tiles) {
817
+ for (const key of Object.keys(tiles)) {
818
+ const idx = Number(key);
819
+ if (!Number.isFinite(idx))
820
+ continue;
821
+ const meta = tiles[idx];
822
+ if (!meta)
823
+ continue;
824
+ const props = {};
825
+ if (meta.solid != null)
826
+ props.solid = meta.solid;
827
+ if (meta.oneWay != null)
828
+ props.oneWay = meta.oneWay;
829
+ if (meta.slope != null)
830
+ props.slope = meta.slope;
831
+ if (meta.damage != null)
832
+ props.damage = meta.damage;
833
+ if (meta.terrainTag != null)
834
+ props.terrainTag = meta.terrainTag;
835
+ if (meta.movement != null)
836
+ props.movement = meta.movement;
837
+ if (meta.groundType != null)
838
+ props.groundType = meta.groundType;
839
+ if (meta.customTag != null)
840
+ props.customTag = meta.customTag;
841
+ if (Object.keys(props).length > 0) {
842
+ tileProperties[idx] = props;
843
+ }
844
+ }
845
+ }
846
+ // Mutate the tileset's lookup so newly-painted tiles inherit properties.
847
+ // Phaser's TS types declare `tileProperties` as readonly object[]; the
848
+ // runtime accepts a plain numeric-keyed Record. Cast through unknown to
849
+ // bypass the structural mismatch without disabling the wider TS surface.
850
+ tileset.tileProperties = tileProperties;
851
+ // Refresh ALREADY-painted tiles. Phaser copies properties from the
852
+ // tileset at tile-creation time; tiles painted BEFORE this call won't
853
+ // see the new properties unless we stamp them directly.
854
+ layer.forEachTile((tile) => {
855
+ // -1 / null tiles are skipped by Phaser internally; only real tiles
856
+ // reach the callback. Use the index as the lookup key.
857
+ const props = tileProperties[tile.index];
858
+ // Always replace tile.properties so removed-metadata also clears.
859
+ // Phaser's collide-by-property re-reads this on each call.
860
+ tile.properties = props ? { ...props } : {};
861
+ });
862
+ // Arm collision. `setCollisionByProperty` walks all tiles, checks each
863
+ // tile's `properties.solid === true`, and flips `tile.collides`. Safe to
864
+ // call when zero tiles are solid (just sets nothing). Phaser's collide-
865
+ // by-property is the standard pattern; behavior code only needs
866
+ // `scene.physics.add.collider(player, layer)` to get the collision.
867
+ layer.setCollisionByProperty({ solid: true });
868
+ // Slice 6 Phase C follow-up — sub-tile collision rects. Godot/Tiled-style
869
+ // per-tile shape, painted ONCE on the tile in the editor + materialized
870
+ // as invisible Arcade static bodies per painted instance at runtime.
871
+ // Phaser's TilemapLayer has no native sub-tile collision; static bodies
872
+ // are the standard escape hatch the Phaser community uses for this case.
873
+ syncSubTileBodies(scene, layer, asset);
874
+ }
875
+ // --- Animated tiles (slice 6 Phase F) -------------------------------------
876
+ const ANIM_CLEANUP_KEY = 'unboxyTilesetAnimCleanup';
877
+ const ANIM_RESET_KEY = 'unboxyTilesetAnimReset';
878
+ /**
879
+ * Arm tile animations on this layer — Phase F (slice 6 — design doc 06 §8).
880
+ *
881
+ * Reads `asset.tileset.animations[]`, finds every painted cell whose source
882
+ * index matches an animation's `rootTileIndex`, installs a per-frame UPDATE
883
+ * listener that swaps each cell's `tile.index` to the current frame.
884
+ * All cells with the same root animate in lockstep (Sprout Lands water
885
+ * lake-cells flow together).
886
+ *
887
+ * Idempotent — re-calling tears down the prior listener and re-scans. Used
888
+ * by:
889
+ * - `createTilemap` at scene boot (initial arm)
890
+ * - `EditorBridge.handleAssetUpdate` (when Animation Editor saves)
891
+ * - `EditorBridge.applyTilemapStructureOp addLayer` (new layer arm)
892
+ * - any subsequent paint op (host's `handleEditTilemap` re-runs metadata)
893
+ *
894
+ * Edit-mode caveat: scenes are `setActive(false)` in edit mode → scene
895
+ * UPDATE doesn't fire → the swap handler stays dormant → animated cells
896
+ * stay on whatever frame they were on when edit was entered. To keep edit
897
+ * mode showing the static "data" view, `EditorBridge.enterEdit` calls
898
+ * `resetTilesetAnimationsToRoot` which walks all animated cells + sets
899
+ * them back to root. exitEdit → next UPDATE tick re-applies current frame.
900
+ *
901
+ * Save-path safety: the iframe's mutated `tile.index` per frame doesn't
902
+ * leak into the host's draft state (home-ui keeps its own copy in
903
+ * `useEditorDraft`'s baseline + commands). Mid-animation Cmd+S saves the
904
+ * root indices, not the displayed frame.
905
+ *
906
+ * Phaser API note: Phaser 3 parses Tiled's `tile.animation` field into
907
+ * `tileset.tileData[id].animation` but its TilemapLayer renderer does NOT
908
+ * consume it. The community `phaser-animated-tiles` plugin bridges that
909
+ * gap but has been bit-rotted since 3.80. This is a 50-line custom impl
910
+ * that does the swap directly — same algorithm, no external dep.
911
+ */
912
+ export function applyTilesetAnimations(layer, asset) {
913
+ // Clear any prior animation arm on this layer (reset cells + uninstall).
914
+ const prevCleanup = layer.getData(ANIM_CLEANUP_KEY);
915
+ prevCleanup?.();
916
+ layer.setData(ANIM_CLEANUP_KEY, undefined);
917
+ const animations = asset.tileset?.animations ?? [];
918
+ if (animations.length === 0)
919
+ return;
920
+ // Index by rootTileIndex for O(1) match. Also collect every frame's
921
+ // tileIndex so re-application (cells already mid-animation) can map back
922
+ // to their root — needed because the previous cleanup happens BEFORE
923
+ // this scan, so cells are at root when we scan, but if some external
924
+ // path mutated indices we still want to catch them.
925
+ const animByRoot = new Map();
926
+ const frameToRoot = new Map();
927
+ for (const anim of animations) {
928
+ if (!anim.frames || anim.frames.length === 0)
929
+ continue;
930
+ animByRoot.set(anim.rootTileIndex, anim);
931
+ frameToRoot.set(anim.rootTileIndex, anim.rootTileIndex);
932
+ for (const f of anim.frames) {
933
+ if (!frameToRoot.has(f.tileIndex)) {
934
+ frameToRoot.set(f.tileIndex, anim.rootTileIndex);
935
+ }
936
+ }
937
+ }
938
+ if (animByRoot.size === 0)
939
+ return;
940
+ // Scan the layer once for cells that match a root (or any frame —
941
+ // defensive against cells left mid-frame from a prior arm). Store the
942
+ // Tile refs + which animation drives them — refs persist for the
943
+ // layer's lifetime (Phaser doesn't recreate Tile objects).
944
+ const cells = [];
945
+ layer.forEachTile((tile) => {
946
+ if (tile.index < 0)
947
+ return;
948
+ const rootIdx = frameToRoot.get(tile.index);
949
+ if (rootIdx === undefined)
950
+ return;
951
+ const anim = animByRoot.get(rootIdx);
952
+ if (!anim)
953
+ return;
954
+ cells.push({ tile, anim });
955
+ });
956
+ if (cells.length === 0)
957
+ return;
958
+ const scene = layer.scene;
959
+ const startTime = scene.time.now;
960
+ // Precompute total durations per animation — avoids re-summing each tick.
961
+ const totalDurByAnim = new Map();
962
+ for (const a of animByRoot.values()) {
963
+ totalDurByAnim.set(a, a.frames.reduce((sum, f) => sum + Math.max(16, f.duration), 0));
964
+ }
965
+ const handler = () => {
966
+ const now = scene.time.now;
967
+ for (const { tile, anim } of cells) {
968
+ const total = totalDurByAnim.get(anim);
969
+ if (!total)
970
+ continue;
971
+ const phase = (now - startTime) % total;
972
+ let acc = 0;
973
+ let frameTileIndex = anim.frames[0].tileIndex;
974
+ for (const f of anim.frames) {
975
+ acc += Math.max(16, f.duration);
976
+ if (phase < acc) {
977
+ frameTileIndex = f.tileIndex;
978
+ break;
979
+ }
980
+ }
981
+ if (tile.index !== frameTileIndex) {
982
+ tile.index = frameTileIndex;
983
+ }
984
+ }
985
+ };
986
+ scene.events.on(Phaser.Scenes.Events.UPDATE, handler);
987
+ // Two helpers stashed on layer.data:
988
+ // - reset: walks cells + sets index back to root WITHOUT touching the
989
+ // handler. Used by enterEdit to freeze the editor view on the
990
+ // authored frame; the scene-pause means the handler doesn't fire,
991
+ // and on exitEdit the next UPDATE tick will re-swap from elapsed
992
+ // time → correct frame seamlessly.
993
+ // - cleanup: full teardown — runs reset() AND uninstalls the UPDATE
994
+ // handler. Used by re-application (assetUpdate / paint-op metadata
995
+ // re-arm) and by scene SHUTDOWN.
996
+ const reset = () => {
997
+ for (const { tile, anim } of cells) {
998
+ if (tile.index !== anim.rootTileIndex) {
999
+ tile.index = anim.rootTileIndex;
1000
+ }
1001
+ }
1002
+ };
1003
+ const cleanup = () => {
1004
+ scene.events.off(Phaser.Scenes.Events.UPDATE, handler);
1005
+ reset();
1006
+ };
1007
+ layer.setData(ANIM_CLEANUP_KEY, cleanup);
1008
+ layer.setData(ANIM_RESET_KEY, reset);
1009
+ // Tear down on scene shutdown so cross-scene navigation doesn't leak the
1010
+ // handler. Scene SHUTDOWN fires before scene START on the next visit.
1011
+ scene.events.once(Phaser.Scenes.Events.SHUTDOWN, () => {
1012
+ const c = layer.getData(ANIM_CLEANUP_KEY);
1013
+ c?.();
1014
+ layer.setData(ANIM_CLEANUP_KEY, undefined);
1015
+ layer.setData(ANIM_RESET_KEY, undefined);
1016
+ });
1017
+ }
1018
+ /**
1019
+ * Reset every animated cell on a tilemap container to its root tile index.
1020
+ * Called by `EditorBridge.enterEdit` so animated cells show the static
1021
+ * authored frame in the editor, not whatever was currently visible at the
1022
+ * moment the user toggled into edit mode. exitEdit doesn't need a paired
1023
+ * call — the next UPDATE tick after scenes resume swaps each cell back to
1024
+ * its current animation frame.
1025
+ */
1026
+ export function resetTilesetAnimationsToRoot(container) {
1027
+ const layers = container.getData('tilemapLayers') ?? [];
1028
+ for (const layer of layers) {
1029
+ const reset = layer.getData(ANIM_RESET_KEY);
1030
+ // reset() ONLY touches tile indices — handler stays installed so
1031
+ // exitEdit picks up where animation left off on next UPDATE tick.
1032
+ reset?.();
1033
+ }
1034
+ }
1035
+ const SUB_TILE_GROUP_KEY = 'unboxySubTileStaticGroup';
1036
+ /**
1037
+ * Rebuild the per-layer `StaticGroup` of invisible sub-tile collision
1038
+ * bodies (Phase C follow-up — sub-tile collision rects).
1039
+ *
1040
+ * Walks the layer's painted tiles, finds tiles whose tileset metadata
1041
+ * carries a `collisionRect`, disables Phaser's native cell-rect collision
1042
+ * for those tiles (`tile.setCollision(false, ...)`), and creates an
1043
+ * invisible Rectangle GameObject with an Arcade static body sized to the
1044
+ * sub-rect. Bodies live in a `Phaser.Physics.Arcade.StaticGroup` stashed
1045
+ * on the layer's data manager so subsequent calls (e.g. painter ops, live
1046
+ * `assetUpdate` replay) can wipe + rebuild cleanly.
1047
+ *
1048
+ * Cheap when no tile in the tileset has a `collisionRect` — single map
1049
+ * scan + early return without touching layer tiles.
1050
+ *
1051
+ * Behavior code: use `addTilemapCollider(scene, entityId, target)` so
1052
+ * both the layer's cell-collision AND the sub-tile group are wired up at
1053
+ * once. Doing only `physics.add.collider(player, layer)` covers the
1054
+ * cell-rect tiles but NOT the sub-rect bodies — a footgun without the
1055
+ * helper.
1056
+ */
1057
+ function syncSubTileBodies(scene, layer, asset) {
1058
+ // Fast-path: no Arcade physics in this scene (e.g. a HUD-only scene)
1059
+ // means no static bodies to create. Bail without touching anything.
1060
+ if (!scene.physics?.add)
1061
+ return;
1062
+ // Get or create the group. Stashed on the layer's data manager so this
1063
+ // function is the SOLE owner — no other code touches it.
1064
+ let group = layer.getData(SUB_TILE_GROUP_KEY);
1065
+ if (!group) {
1066
+ group = scene.physics.add.staticGroup();
1067
+ layer.setData(SUB_TILE_GROUP_KEY, group);
1068
+ }
1069
+ // Clear existing bodies. `clear(true, true)` destroys GOs + their bodies
1070
+ // so we rebuild from a clean slate. Bounded — at most O(painted_cells).
1071
+ group.clear(true, true);
1072
+ // Build a fast index → collisionRects lookup. Most tiles in a tileset
1073
+ // won't have sub-rects, so this is sparse.
1074
+ const tiles = asset.tileset?.tiles;
1075
+ if (!tiles)
1076
+ return;
1077
+ const subRectsByIdx = new Map();
1078
+ for (const key of Object.keys(tiles)) {
1079
+ const idx = Number(key);
1080
+ if (!Number.isFinite(idx))
1081
+ continue;
1082
+ const meta = tiles[idx];
1083
+ if (meta?.collisionRects && meta.collisionRects.length > 0) {
1084
+ subRectsByIdx.set(idx, meta.collisionRects);
1085
+ }
1086
+ }
1087
+ if (subRectsByIdx.size === 0)
1088
+ return;
1089
+ // Walk painted tiles. For each tile with one-or-more sub-rects: disable
1090
+ // its native cell collision (so it doesn't double-collide) + spawn one
1091
+ // invisible Rectangle with a static body per sub-rect. Position uses
1092
+ // `tile.getLeft()/getTop()` which returns WORLD coords accounting for
1093
+ // the layer's transform — correct when cellSize != tileSize and the
1094
+ // layer is scaled. Multi-rect lets one tile carry L/U/frame collision.
1095
+ layer.forEachTile((tile) => {
1096
+ const rects = subRectsByIdx.get(tile.index);
1097
+ if (!rects)
1098
+ return;
1099
+ // Turn OFF native cell collision so collider events fire only against
1100
+ // the sub-rect bodies (avoids double-collision: cell + sub).
1101
+ tile.setCollision(false, false, false, false, false);
1102
+ const worldLeft = tile.getLeft();
1103
+ const worldTop = tile.getTop();
1104
+ const sx = layer.scaleX;
1105
+ const sy = layer.scaleY;
1106
+ for (const rect of rects) {
1107
+ const cx = worldLeft + (rect.x + rect.w / 2) * sx;
1108
+ const cy = worldTop + (rect.y + rect.h / 2) * sy;
1109
+ const w = rect.w * sx;
1110
+ const h = rect.h * sy;
1111
+ const bodyGO = scene.add.rectangle(cx, cy, w, h);
1112
+ bodyGO.setVisible(false);
1113
+ scene.physics.add.existing(bodyGO, true /* static body */);
1114
+ group.add(bodyGO);
1115
+ }
1116
+ });
1117
+ }
1118
+ /**
1119
+ * Wire collision between `target` (a sprite, group, or anything with a
1120
+ * physics body) and all collision surfaces of a tilemap entity — both
1121
+ * the layer's native cell-rect collision AND any sub-tile static bodies
1122
+ * authored via the Tile Metadata Editor's collision-shape mode.
1123
+ *
1124
+ * The one-call alternative to:
1125
+ * ```ts
1126
+ * scene.physics.add.collider(player, layer);
1127
+ * scene.physics.add.collider(player, layer.getData('unboxySubTileStaticGroup'));
1128
+ * ```
1129
+ *
1130
+ * Behavior code in a typical game:
1131
+ * ```ts
1132
+ * import { addTilemapCollider } from '@umicat/phaser-sdk';
1133
+ * create() {
1134
+ * // ... spawn player ...
1135
+ * addTilemapCollider(this, 'world', this.player);
1136
+ * }
1137
+ * ```
1138
+ *
1139
+ * Returns the created Collider instances so callers can `.destroy()` them
1140
+ * if needed (e.g. on level transition).
1141
+ */
1142
+ export function addTilemapCollider(scene, entityId, target, callback) {
1143
+ const out = [];
1144
+ if (!scene.physics?.add)
1145
+ return out;
1146
+ const registry = getEntityRegistry(scene);
1147
+ if (!registry)
1148
+ return out;
1149
+ const go = registry.byId(entityId);
1150
+ if (!go)
1151
+ return out;
1152
+ if (go.getData('entityKind') !== 'tilemap')
1153
+ return out;
1154
+ const container = go;
1155
+ const layers = container.getData('tilemapLayers') ?? [];
1156
+ for (const layer of layers) {
1157
+ out.push(scene.physics.add.collider(target, layer, callback));
1158
+ const group = layer.getData(SUB_TILE_GROUP_KEY);
1159
+ if (group) {
1160
+ out.push(scene.physics.add.collider(target, group, callback));
1161
+ }
1162
+ }
1163
+ return out;
1164
+ }
1165
+ function resolveTilesetCellSize(asset, fallbackW, fallbackH) {
1166
+ if (asset.tileset?.cellSize)
1167
+ return asset.tileset.cellSize;
1168
+ // Spritesheet assets carry frame dims either nested in spriteSheetConfig
1169
+ // or top-level — both shapes appear in real workspaces (see queueAssetLoad
1170
+ // for the matching tolerance).
1171
+ const cfgW = asset.spriteSheetConfig?.frameWidth ?? asset.frameWidth;
1172
+ const cfgH = asset.spriteSheetConfig?.frameHeight ?? asset.frameHeight;
1173
+ if (typeof cfgW === 'number' && typeof cfgH === 'number') {
1174
+ return { width: cfgW, height: cfgH };
1175
+ }
1176
+ return { width: fallbackW, height: fallbackH };
1177
+ }
1178
+ /**
1179
+ * Accepts `'#rrggbb'`, `'rrggbb'`, or `'0xrrggbb'` and returns a number
1180
+ * suitable for Phaser's color APIs.
1181
+ */
1182
+ export function parseColor(input) {
1183
+ const trimmed = input.trim();
1184
+ if (trimmed.startsWith('#'))
1185
+ return parseInt(trimmed.slice(1), 16);
1186
+ if (trimmed.startsWith('0x'))
1187
+ return parseInt(trimmed.slice(2), 16);
1188
+ return parseInt(trimmed, 16);
1189
+ }
1190
+ // --- Slice 8: hitbox application -----------------------------------------
1191
+ const HITBOX_LISTENER_KEY = 'unboxyHitboxListener';
1192
+ /**
1193
+ * Apply the asset's `hitbox` metadata to a sprite's physics body.
1194
+ *
1195
+ * Called automatically at spawn from `createSprite`; ALSO callable by
1196
+ * behavior code that attaches a body later (e.g. the agent's physics skill
1197
+ * does `scene.physics.add.existing(sprite); applyAssetHitbox(sprite, asset);`).
1198
+ *
1199
+ * Semantics:
1200
+ * - **No-op (silent)** when `asset.hitbox` is unset — that's the chat-only
1201
+ * path (primitives, AI-gen images without vision metadata). Workflow A
1202
+ * in design doc 09 §4.4 is unaffected.
1203
+ * - **Dev-warn (NOT throw)** when called on a sprite without a physics body.
1204
+ * The most likely agent mistake is calling this BEFORE
1205
+ * `scene.physics.add.existing(sprite)`; the warning surfaces in the
1206
+ * iframe console for the agent's post-turn diagnostic loop.
1207
+ * - **Per-frame variant** installs an `ANIMATION_UPDATE` listener that
1208
+ * swaps `body.setSize/setOffset` on every frame change during anim
1209
+ * playback. Listener is idempotent — calling `applyAssetHitbox` twice
1210
+ * on the same sprite tears down the prior listener before installing a
1211
+ * new one (handles asset hot-reload + Inspector live-edits).
1212
+ *
1213
+ * Caveat: per-frame swap fires on `Phaser.Animations.Events.ANIMATION_UPDATE`
1214
+ * only, which means manual `sprite.setFrame(idx)` calls outside of animation
1215
+ * playback do NOT swap the body. v1 accepts this — combat sheets are
1216
+ * animation-driven. A `setFrame` wrapper is a v2 candidate.
1217
+ */
1218
+ export function applyAssetHitbox(sprite, asset) {
1219
+ if (!asset.hitbox)
1220
+ return;
1221
+ const body = sprite.body;
1222
+ if (!body) {
1223
+ // eslint-disable-next-line no-console
1224
+ console.warn(`[umicat/scene] applyAssetHitbox called on a sprite with no physics ` +
1225
+ `body (asset=${asset.id}). Call scene.physics.add.existing(sprite) ` +
1226
+ `first, or this is a no-op.`);
1227
+ return;
1228
+ }
1229
+ // Tear down any previously installed per-frame listener so re-applying is
1230
+ // idempotent. The listener reference is stashed on the sprite's data
1231
+ // manager because Phaser's emitter needs the exact function reference to
1232
+ // remove it.
1233
+ const prior = sprite.getData(HITBOX_LISTENER_KEY);
1234
+ if (prior) {
1235
+ sprite.off(Phaser.Animations.Events.ANIMATION_UPDATE, prior);
1236
+ sprite.setData(HITBOX_LISTENER_KEY, undefined);
1237
+ }
1238
+ if (isPerFrameHitbox(asset.hitbox)) {
1239
+ // Apply the default shape immediately — covers the current frame plus
1240
+ // every frame not present in the overrides map.
1241
+ applyShape(body, asset.hitbox.default);
1242
+ const overrides = asset.hitbox.frames;
1243
+ const defaultShape = asset.hitbox.default;
1244
+ const listener = (_anim, frame) => {
1245
+ applyShape(body, overrides[frame.index] ?? defaultShape);
1246
+ };
1247
+ sprite.setData(HITBOX_LISTENER_KEY, listener);
1248
+ sprite.on(Phaser.Animations.Events.ANIMATION_UPDATE, listener);
1249
+ }
1250
+ else {
1251
+ applyShape(body, asset.hitbox);
1252
+ }
1253
+ }
1254
+ /**
1255
+ * Internal helper that maps a HitboxRect to Phaser's body API. v1 handles
1256
+ * `kind: 'rect'` only; v2 will widen the union to `HitboxRect | HitboxCircle`
1257
+ * and branch on `shape.kind` — see design doc 09 §9.2.1.
1258
+ */
1259
+ function applyShape(body, shape) {
1260
+ body.setSize(shape.w, shape.h);
1261
+ body.setOffset(shape.x, shape.y);
1262
+ }
1263
+ /**
1264
+ * Look up the painted tile at a world coordinate inside a tilemap entity.
1265
+ *
1266
+ * Slice 6 Phase C (design doc 06 §5.2). Returns null when:
1267
+ * - the entity doesn't exist / isn't a tilemap;
1268
+ * - no layer in the tilemap has a painted (non-empty) tile at that
1269
+ * world coord;
1270
+ * - the world coord is outside every layer's bounds.
1271
+ *
1272
+ * Layer scan order (when `options.layerId` is omitted) is top-down — the
1273
+ * layer with the highest `z` is queried first, matching the visual stack.
1274
+ * Pass `options.layerId` to scope to one layer (e.g. for a "ground type"
1275
+ * lookup on the floor layer specifically).
1276
+ *
1277
+ * Behavior-code use:
1278
+ * ```ts
1279
+ * const hit = getTilemapAt(this, 'world', player.x, player.y);
1280
+ * if (hit?.metadata.damage) player.hp -= hit.metadata.damage * dt;
1281
+ * if (hit?.metadata.movement === 'swim') player.speed *= 0.5;
1282
+ * ```
1283
+ *
1284
+ * Per-frame perf: each layer query is a constant-time `worldToTileXY` +
1285
+ * `getTileAt` Phaser call. Safe for `update()` loops at typical layer
1286
+ * counts (1–4 layers per tilemap).
1287
+ */
1288
+ export function getTilemapAt(scene, entityId, worldX, worldY, options) {
1289
+ const registry = getEntityRegistry(scene);
1290
+ if (!registry)
1291
+ return null;
1292
+ const go = registry.byId(entityId);
1293
+ if (!go)
1294
+ return null;
1295
+ if (go.getData('entityKind') !== 'tilemap')
1296
+ return null;
1297
+ const container = go;
1298
+ const layers = container.getData('tilemapLayers') ?? [];
1299
+ if (layers.length === 0)
1300
+ return null;
1301
+ // Top-down walk so the visually-front layer wins. Stable sort by the
1302
+ // layer's current depth (set by the per-frame sync hook + initial spawn
1303
+ // → matches container.depth or layer.z). Cheap — typical N=1–4.
1304
+ const candidates = options?.layerId
1305
+ ? layers.filter((l) => l.getData('tilemapLayerId') === options.layerId)
1306
+ : [...layers].sort((a, b) => b.depth - a.depth);
1307
+ for (const layer of candidates) {
1308
+ if (!layer.visible)
1309
+ continue; // hidden layers participate in collision but not lookup; design 06 §5.2
1310
+ const tileXY = layer.worldToTileXY(worldX, worldY, true);
1311
+ if (!tileXY)
1312
+ continue;
1313
+ const tile = layer.getTileAt(tileXY.x, tileXY.y, false);
1314
+ if (!tile || tile.index < 0)
1315
+ continue;
1316
+ const props = tile.properties ?? {};
1317
+ return {
1318
+ layerId: layer.getData('tilemapLayerId') ?? '',
1319
+ tileIndex: tile.index,
1320
+ metadata: { ...props },
1321
+ tileX: tileXY.x,
1322
+ tileY: tileXY.y,
1323
+ };
1324
+ }
1325
+ return null;
1326
+ }