@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,615 @@
1
+ import Phaser from 'phaser';
2
+ import { SCHEMA_VERSION, } from './types.js';
3
+ import { spawnEntity } from './spawnEntity.js';
4
+ import { attachEntityRegistry } from './EntityRegistry.js';
5
+ import { resolveRenderScript } from './renderScripts.js';
6
+ /**
7
+ * Where scene files live in the workspace and at runtime. The Vite build
8
+ * copies `public/` to the served root, so paths are origin-relative
9
+ * (`scenes/manifest.json`). No leading slash — the iframe is sometimes
10
+ * served under a sub-path (`/preview/:gameId/`).
11
+ */
12
+ export const SCENES_BASE = 'scenes/';
13
+ export const MANIFEST_PATH = `${SCENES_BASE}manifest.json`;
14
+ const MANIFEST_CACHE_KEY = '__unboxyManifestState';
15
+ /**
16
+ * Fetches the manifest via Phaser's loader. Must be called from a Phaser
17
+ * scene's `preload()` since it queues a load request. Subsequent calls
18
+ * (e.g. on scene transitions) hit the Phaser JSON cache.
19
+ *
20
+ * Resolves once Phaser fires `complete` for the load batch — callers
21
+ * typically await this in `create()` after `this.load.start()` is done.
22
+ */
23
+ export function preloadManifest(scene) {
24
+ if (!scene.cache.json.exists('umicat:manifest')) {
25
+ scene.load.json('umicat:manifest', MANIFEST_PATH);
26
+ }
27
+ }
28
+ /**
29
+ * Read the manifest from Phaser's JSON cache. Throws if `preloadManifest`
30
+ * hasn't completed first.
31
+ */
32
+ export function getManifest(scene) {
33
+ const raw = scene.cache.json.get('umicat:manifest');
34
+ if (!raw) {
35
+ throw new Error("[umicat/scene] manifest not loaded — call preloadManifest(scene) in BootScene.preload() and wait for the loader's 'complete' event");
36
+ }
37
+ const manifest = raw;
38
+ validateManifest(manifest);
39
+ return manifest;
40
+ }
41
+ function validateManifest(m) {
42
+ if (m.schemaVersion !== SCHEMA_VERSION) {
43
+ throw new Error(`[umicat/scene] manifest schemaVersion ${m.schemaVersion} but SDK expects ${SCHEMA_VERSION}`);
44
+ }
45
+ if (!m.scenes?.length) {
46
+ throw new Error('[umicat/scene] manifest has no scenes');
47
+ }
48
+ if (!m.scenes.find((s) => s.id === m.initialScene)) {
49
+ throw new Error(`[umicat/scene] manifest.initialScene '${m.initialScene}' is not in scenes[]`);
50
+ }
51
+ }
52
+ function getOrInitState(scene, manifest) {
53
+ const game = scene.game;
54
+ const existing = game[MANIFEST_CACHE_KEY];
55
+ if (existing && existing.manifest === manifest)
56
+ return existing;
57
+ const assetsById = new Map();
58
+ for (const a of manifest.assets ?? [])
59
+ assetsById.set(a.id, a);
60
+ const state = {
61
+ manifest,
62
+ assetsById,
63
+ requestedAssetIds: new Set(),
64
+ };
65
+ game[MANIFEST_CACHE_KEY] = state;
66
+ return state;
67
+ }
68
+ // --- Lazy asset preload ----------------------------------------------------
69
+ /**
70
+ * Walk a scene file's entities and queue Phaser loads for any assets that
71
+ * aren't already in the texture cache. Caller is responsible for awaiting
72
+ * the loader (`scene.load.once('complete', ...)` or do it in `preload()`).
73
+ *
74
+ * Idempotent across scenes — once an asset is loaded, switching to another
75
+ * scene that uses it is free.
76
+ */
77
+ export function preloadSceneAssets(scene, sceneFile, manifest) {
78
+ if (sceneFile.type !== 'world')
79
+ return; // HUD slice will add its own walker
80
+ const state = getOrInitState(scene, manifest);
81
+ const ids = collectAssetIds(sceneFile.entities);
82
+ for (const id of ids) {
83
+ if (state.requestedAssetIds.has(id))
84
+ continue;
85
+ const asset = state.assetsById.get(id);
86
+ if (!asset) {
87
+ // Soft-fail: warn, continue. The downstream spawn path renders a
88
+ // magenta "?" placeholder for the offending entity so the user sees
89
+ // *where* a missing asset would have been, without losing every
90
+ // other entity in the scene. Common cause is the editor adding a
91
+ // sprite that references an asset whose manifest entry never got
92
+ // synced (e.g. AssetShelf drag without manifest update).
93
+ // eslint-disable-next-line no-console
94
+ console.warn(`[umicat/scene] scene '${sceneFile.id}' references assetId '${id}' but the manifest has no such asset; entity will render as a placeholder`);
95
+ continue;
96
+ }
97
+ queueAssetLoad(scene, asset);
98
+ state.requestedAssetIds.add(id);
99
+ }
100
+ }
101
+ function collectAssetIds(entities) {
102
+ const ids = new Set();
103
+ function walk(e) {
104
+ if (e.kind === 'sprite') {
105
+ ids.add(e.assetId);
106
+ }
107
+ else if (e.kind === 'group') {
108
+ for (const child of e.children)
109
+ walk(child);
110
+ }
111
+ else if (e.kind === 'tilemap') {
112
+ // Slice 6 Phase A: each layer's tilesetIds reference Asset records
113
+ // the same way sprite.assetId does. Walk every layer and queue each
114
+ // tileset's PNG for load. Phase A consumes one tileset per layer at
115
+ // render time but we collect the whole tilesetIds[] array so later
116
+ // phases (multi-tileset blended layers) need no change here.
117
+ for (const layer of e.layers) {
118
+ for (const id of layer.tilesetIds ?? [])
119
+ ids.add(id);
120
+ }
121
+ }
122
+ // rect / circle / code-rendered / trigger don't reference assets.
123
+ }
124
+ for (const e of entities)
125
+ walk(e);
126
+ return Array.from(ids);
127
+ }
128
+ /**
129
+ * Walk every `kind=spritesheet` asset in the manifest and register each one's
130
+ * `animations[]` as a Phaser animation, so behavior code can call
131
+ * `sprite.play('walk-down')` directly. Idempotent — uses `scene.anims.exists()`
132
+ * to skip duplicates (Phaser's anims.create throws otherwise).
133
+ *
134
+ * <p>Animation keys are scene-global. Two sheets registering the same name
135
+ * (e.g. two characters both with `walk-down`) → first wins, second logs a
136
+ * warning. Sheets that need disambiguation should namespace their keys
137
+ * (e.g. `cow:walk-down`) at metadata time.
138
+ */
139
+ /**
140
+ * Walk every asset in the manifest and apply `Phaser.Textures.FilterMode.NEAREST`
141
+ * to each `pixelArt: true` entry's loaded texture. Without this, pixel-art
142
+ * sprites render bilinear-blurred — visible immediately on imported character
143
+ * sheets when the agent hasn't manually set Phaser game config's `pixelArt`.
144
+ *
145
+ * <p>Idempotent: setFilter is safe to call multiple times. Skip silently when
146
+ * a texture isn't loaded yet (next call after that asset is requested will
147
+ * pick it up). Wrap in try/catch so a single bad asset doesn't block the rest
148
+ * of scene init.
149
+ *
150
+ * <p>Per-asset NEAREST closes the visible blur gap; the game-wide flag
151
+ * (`Phaser.Game.config.pixelArt` + `camera.setRoundPixels`) is a separate
152
+ * concern (framebuffer-stretch crispness, perf) deferred to a follow-up.
153
+ */
154
+ export function applyPixelArtFilters(scene, manifest) {
155
+ for (const asset of manifest.assets ?? []) {
156
+ if (!asset.pixelArt)
157
+ continue;
158
+ if (!scene.textures.exists(asset.textureKey))
159
+ continue;
160
+ try {
161
+ const tex = scene.textures.get(asset.textureKey);
162
+ tex.setFilter(Phaser.Textures.FilterMode.NEAREST);
163
+ }
164
+ catch (e) {
165
+ // eslint-disable-next-line no-console
166
+ console.warn(`[umicat/scene] failed to apply NEAREST filter to '${asset.id}': ${String(e)}`);
167
+ }
168
+ }
169
+ }
170
+ function registerSheetAnimations(scene, manifest) {
171
+ const assets = manifest.assets ?? [];
172
+ for (const asset of assets) {
173
+ if (asset.kind !== 'spritesheet')
174
+ continue;
175
+ if (!asset.animations || asset.animations.length === 0)
176
+ continue;
177
+ if (!scene.textures.exists(asset.textureKey))
178
+ continue; // not loaded — skip
179
+ const defaultFps = asset.fps && asset.fps > 0 ? asset.fps : 8;
180
+ for (const anim of asset.animations) {
181
+ if (!anim.name)
182
+ continue;
183
+ if (scene.anims.exists(anim.name)) {
184
+ // Don't error out on collision — first registration wins.
185
+ // eslint-disable-next-line no-console
186
+ console.warn(`[umicat/scene] animation key '${anim.name}' already registered; skipping (asset '${asset.id}'). Disambiguate by renaming in the asset's animations metadata.`);
187
+ continue;
188
+ }
189
+ const fps = anim.fps && anim.fps > 0 ? anim.fps : defaultFps;
190
+ const loop = anim.loop !== false; // default true
191
+ try {
192
+ scene.anims.create({
193
+ key: anim.name,
194
+ frames: scene.anims.generateFrameNumbers(asset.textureKey, {
195
+ start: anim.from,
196
+ end: anim.to,
197
+ }),
198
+ frameRate: fps,
199
+ repeat: loop ? -1 : 0,
200
+ });
201
+ }
202
+ catch (e) {
203
+ // eslint-disable-next-line no-console
204
+ console.warn(`[umicat/scene] failed to register animation '${anim.name}' on '${asset.id}': ${String(e)}`);
205
+ }
206
+ }
207
+ }
208
+ }
209
+ /**
210
+ * Atlas-json side-cache key. Phaser's `scene.load.atlas()` loads + parses the
211
+ * JSON but drops unknown per-frame fields (e.g. `ninePatch`) — they don't
212
+ * survive into the Phaser Frame object. To consume them at runtime we ALSO
213
+ * fetch the raw JSON via `scene.load.json()` and stash it under this key so
214
+ * `getAtlasFrameNinePatch` can read it back.
215
+ *
216
+ * Used by region-atlas assets (visual editor slice 10) where each tagged
217
+ * region can carry its own 9-slice cuts.
218
+ */
219
+ function atlasNinePatchCacheKey(textureKey) {
220
+ return `umicat:atlas-ninepatch:${textureKey}`;
221
+ }
222
+ function queueAtlasNinePatchSidecar(scene, asset) {
223
+ if (!asset.atlasPath)
224
+ return;
225
+ const cacheKey = atlasNinePatchCacheKey(asset.textureKey);
226
+ if (scene.cache.json.exists(cacheKey))
227
+ return;
228
+ scene.load.json(cacheKey, asset.atlasPath);
229
+ }
230
+ /**
231
+ * Look up per-frame 9-slice metadata for an atlas-format asset. Returns the
232
+ * `NinePatchConfig` declared inline on the named frame in the atlas JSON, or
233
+ * `undefined` if the asset isn't an atlas, the cache miss happened, or the
234
+ * frame has no `ninePatch` entry (plain stretched draw).
235
+ */
236
+ export function getAtlasFrameNinePatch(scene, textureKey, frameName) {
237
+ const cacheKey = atlasNinePatchCacheKey(textureKey);
238
+ if (!scene.cache.json.exists(cacheKey))
239
+ return undefined;
240
+ const raw = scene.cache.json.get(cacheKey);
241
+ const entry = raw?.frames?.[frameName];
242
+ const np = entry?.ninePatch;
243
+ if (!np)
244
+ return undefined;
245
+ if (typeof np.leftWidth !== 'number' ||
246
+ typeof np.rightWidth !== 'number' ||
247
+ typeof np.topHeight !== 'number' ||
248
+ typeof np.bottomHeight !== 'number') {
249
+ return undefined;
250
+ }
251
+ return {
252
+ leftWidth: np.leftWidth,
253
+ rightWidth: np.rightWidth,
254
+ topHeight: np.topHeight,
255
+ bottomHeight: np.bottomHeight,
256
+ };
257
+ }
258
+ function queueAssetLoad(scene, asset) {
259
+ if (scene.textures.exists(asset.textureKey))
260
+ return;
261
+ switch (asset.kind) {
262
+ case 'image':
263
+ scene.load.image(asset.textureKey, asset.path);
264
+ return;
265
+ case 'spritesheet': {
266
+ // Tolerant config resolution: prefer nested `spriteSheetConfig`, fall
267
+ // back to top-level `frameWidth/frameHeight/margin/spacing` if the
268
+ // agent put them there (common typo — both shapes appear in real
269
+ // workspaces). Soft-fail with warning when neither shape provides
270
+ // frame dims so the rest of the scene still boots.
271
+ const cfg = asset.spriteSheetConfig
272
+ ?? (asset.frameWidth && asset.frameHeight
273
+ ? {
274
+ frameWidth: asset.frameWidth,
275
+ frameHeight: asset.frameHeight,
276
+ margin: asset.margin,
277
+ spacing: asset.spacing,
278
+ }
279
+ : null);
280
+ if (!cfg) {
281
+ // eslint-disable-next-line no-console
282
+ console.warn(`[umicat/scene] asset '${asset.id}' kind=spritesheet missing spriteSheetConfig (and no top-level frameWidth/frameHeight); skipping load`);
283
+ return;
284
+ }
285
+ scene.load.spritesheet(asset.textureKey, asset.path, cfg);
286
+ return;
287
+ }
288
+ case 'atlas':
289
+ if (!asset.atlasPath || !asset.atlasFormat) {
290
+ throw new Error(`[umicat/scene] asset '${asset.id}' kind=atlas missing atlasPath/atlasFormat`);
291
+ }
292
+ if (asset.atlasFormat === 'xml') {
293
+ scene.load.atlasXML(asset.textureKey, asset.path, asset.atlasPath);
294
+ }
295
+ else {
296
+ scene.load.atlas(asset.textureKey, asset.path, asset.atlasPath);
297
+ queueAtlasNinePatchSidecar(scene, asset);
298
+ }
299
+ return;
300
+ case 'audio':
301
+ scene.load.audio(asset.textureKey, asset.path);
302
+ return;
303
+ case 'json':
304
+ scene.load.json(asset.textureKey, asset.path);
305
+ return;
306
+ default: {
307
+ const exhaustive = asset.kind;
308
+ throw new Error(`[umicat/scene] unknown asset kind: ${JSON.stringify(exhaustive)}`);
309
+ }
310
+ }
311
+ }
312
+ /**
313
+ * Suspend the scene's update/physics/tween processing while async work
314
+ * runs inside `create()`. Phaser does not await an async `create()` —
315
+ * the scene transitions to RUNNING as soon as the call returns its
316
+ * Promise, and `update()` starts ticking on the next frame regardless of
317
+ * whether the awaited load has settled. Without this guard, any
318
+ * `update()` code that reads class fields populated AFTER the await
319
+ * (e.g. `this.player.body`) crashes on frame 1 with
320
+ * `Cannot read properties of undefined (reading 'body')`.
321
+ *
322
+ * Idempotent: if the scene is already paused (e.g. the editor has
323
+ * paused everything via `setupEditorModeListener`), we leave it that
324
+ * way and the returned release is a no-op.
325
+ */
326
+ export function suspendSceneUpdates(scene) {
327
+ const wasActive = scene.sys.settings.active;
328
+ if (!wasActive)
329
+ return () => { };
330
+ scene.scene.pause();
331
+ return () => {
332
+ scene.scene.resume();
333
+ };
334
+ }
335
+ /**
336
+ * One-shot async loader: fetch scene JSON (if not already cached), lazy
337
+ * preload its assets, spawn entities, configure camera, and return the
338
+ * EntityRegistry. Idempotent re-loads of the same scene id reuse the
339
+ * Phaser JSON cache.
340
+ *
341
+ * Phaser's update loop is suspended for the duration of the await so
342
+ * `update()` can safely read fields the agent assigns AFTER `await
343
+ * loadWorldScene(...)` — no manual `if (!this.player) return;` guard
344
+ * required.
345
+ *
346
+ * Pattern (in `GameScene.create()`):
347
+ *
348
+ * ```ts
349
+ * const result = await loadWorldScene(this, sceneId);
350
+ * // entities live; behavior code can use result.registry.byRole('player')
351
+ * ```
352
+ */
353
+ export async function loadWorldScene(scene, sceneId, options = {}) {
354
+ const release = suspendSceneUpdates(scene);
355
+ try {
356
+ return await loadWorldSceneImpl(scene, sceneId, options);
357
+ }
358
+ finally {
359
+ release();
360
+ }
361
+ }
362
+ async function loadWorldSceneImpl(scene, sceneId, options) {
363
+ const manifest = getManifest(scene);
364
+ const ref = manifest.scenes.find((s) => s.id === sceneId);
365
+ if (!ref)
366
+ throw new Error(`[umicat/scene] manifest has no scene with id '${sceneId}'`);
367
+ if (ref.type !== 'world') {
368
+ throw new Error(`[umicat/scene] scene '${sceneId}' is type=${ref.type}; loadWorldScene expects type=world`);
369
+ }
370
+ const sceneFile = (await loadSceneJson(scene, ref));
371
+ if (sceneFile.schemaVersion !== SCHEMA_VERSION) {
372
+ throw new Error(`[umicat/scene] scene '${sceneId}' schemaVersion ${sceneFile.schemaVersion} but SDK expects ${SCHEMA_VERSION}`);
373
+ }
374
+ if (sceneFile.type !== 'world') {
375
+ throw new Error(`[umicat/scene] scene '${sceneId}' has type=${sceneFile.type} in file body`);
376
+ }
377
+ // Apply gravity declared in scene data to the Arcade physics world before
378
+ // entities spawn. Scene-level `world.physics.gravity` wins over the
379
+ // manifest-wide `globals.physics.gravity`.
380
+ applyWorldGravity(scene, sceneFile, manifest);
381
+ // Lazy preload of any new assets this scene needs, then await loader.
382
+ preloadSceneAssets(scene, sceneFile, manifest);
383
+ await runLoader(scene);
384
+ // Per-asset NEAREST filter for `pixelArt: true` entries. Must run after
385
+ // textures are loaded — setFilter on a missing texture is a no-op.
386
+ applyPixelArtFilters(scene, manifest);
387
+ // Register Phaser animations declared in `manifest.assets[].animations`.
388
+ // Done after texture load + before entity spawn so behavior code can call
389
+ // `sprite.play('walk-down')` from frame 1 without manual `anims.create()`.
390
+ registerSheetAnimations(scene, manifest);
391
+ // Spawn entities.
392
+ const registry = attachEntityRegistry(scene);
393
+ const ctx = {
394
+ scene,
395
+ registry,
396
+ resolveAsset: (id) => resolveAsset(manifest, id),
397
+ resolveRenderScript: options.resolveRenderScript ??
398
+ ((path) => resolveRenderScript(scene.game, path)),
399
+ };
400
+ for (const entity of sceneFile.entities)
401
+ spawnEntity(ctx, entity);
402
+ // Configure camera.
403
+ applyCamera(scene, sceneFile, registry);
404
+ // Slice 8: install per-frame Y-sort hook when the scene opts in. Walks
405
+ // the entity registry every frame and sets `sprite.depth = sprite.y` so
406
+ // closer-to-camera sprites overlap farther ones. Two opt-out signals
407
+ // (explicit transform.depth or properties.skipYSort) keep specific
408
+ // entities on their constant layer.
409
+ if (sceneFile.ySort) {
410
+ installYSortHook(scene, registry);
411
+ }
412
+ // Slice 6 Phase B fix — keep TilemapLayers visually anchored to their
413
+ // tilemap entity's container.x/y. Layers can't be Container children due
414
+ // to a Phaser render quirk (see createTilemap), so we sync world position
415
+ // every frame. **Edit-mode sync** runs from EditorOverlayScene.update()
416
+ // (which is always active during edit, unlike the world scene which is
417
+ // paused — `setActive(false)` per Phase A camera architecture suspends
418
+ // scene.events.UPDATE → a hook registered here wouldn't fire during edit
419
+ // mode, which is exactly when the user drags entities). **Play-mode
420
+ // sync** runs from scene.events.UPDATE (active scene fires events
421
+ // normally) — required for any future behavior code that moves a
422
+ // tilemap entity dynamically.
423
+ installTilemapLayerSync(scene, registry);
424
+ // Auto-launch the HUD scene attached to this world (slice 5). Imported
425
+ // lazily to avoid pulling HudRuntime into worlds that don't need it.
426
+ if (ref.hud) {
427
+ const { UMICAT_HUD_SCENE_KEY } = await import('./HudRuntime.js');
428
+ if (scene.scene.isActive(UMICAT_HUD_SCENE_KEY)) {
429
+ scene.scene.stop(UMICAT_HUD_SCENE_KEY);
430
+ }
431
+ scene.scene.launch(UMICAT_HUD_SCENE_KEY, { hudId: ref.hud });
432
+ }
433
+ return { sceneFile, registry };
434
+ }
435
+ async function loadSceneJson(scene, ref) {
436
+ const cacheKey = `umicat:scene:${ref.id}`;
437
+ if (scene.cache.json.exists(cacheKey)) {
438
+ return scene.cache.json.get(cacheKey);
439
+ }
440
+ scene.load.json(cacheKey, `${SCENES_BASE}${ref.file}`);
441
+ await runLoader(scene);
442
+ return scene.cache.json.get(cacheKey);
443
+ }
444
+ /**
445
+ * Phaser's `load` queue is fire-and-forget; this wraps it in a promise.
446
+ * If the loader is already idle and has nothing queued, resolves
447
+ * immediately on next tick.
448
+ */
449
+ function runLoader(scene) {
450
+ return new Promise((resolve, reject) => {
451
+ if (!scene.load.isLoading() && scene.load.totalToLoad === 0) {
452
+ // Nothing to do — but `totalToLoad` resets to 0 on each `start()`
453
+ // so check whether anything was queued since last start.
454
+ if (scene.load.list.size === 0) {
455
+ queueMicrotask(resolve);
456
+ return;
457
+ }
458
+ }
459
+ scene.load.once(Phaser.Loader.Events.COMPLETE, () => resolve());
460
+ scene.load.once(Phaser.Loader.Events.FILE_LOAD_ERROR, (file) => {
461
+ reject(new Error(`[umicat/scene] loader failed: ${file.key} (${file.url})`));
462
+ });
463
+ scene.load.start();
464
+ });
465
+ }
466
+ function resolveAsset(manifest, assetId) {
467
+ const asset = manifest.assets?.find((a) => a.id === assetId);
468
+ if (!asset) {
469
+ throw new Error(`[umicat/scene] manifest has no asset with id '${assetId}'`);
470
+ }
471
+ return asset;
472
+ }
473
+ /**
474
+ * Slice 8: per-frame Y-sort. Walks the entity registry and assigns
475
+ * `setDepth(y)` to every sprite/primitive/code-rendered entity that hasn't
476
+ * opted out via explicit `transform.depth` or `properties.skipYSort`.
477
+ *
478
+ * Iteration scope is the registry, not `scene.children.list` — registry
479
+ * holds slice-1 entities only (no editor overlay graphics, no transient
480
+ * Graphics drawn by behavior code). Cost is O(n) per frame where n =
481
+ * entity count; well within budget for a 200-entity top-down scene at
482
+ * 60 FPS.
483
+ *
484
+ * Opt-out priority:
485
+ * 1. `entity.transform.depth` is a number → that constant wins permanently;
486
+ * setDepth never overwrites. For "cloud always on top," "tilemap at
487
+ * -1000," etc.
488
+ * 2. `entity.properties.skipYSort: true` → behavior code manages depth;
489
+ * ySort doesn't touch the entity.
490
+ *
491
+ * Both checks read from the data manager (stashed by `tagGameObject` in
492
+ * spawnEntity.ts) to avoid round-tripping to the scene file every frame.
493
+ */
494
+ function installYSortHook(scene, registry) {
495
+ scene.events.on(Phaser.Scenes.Events.UPDATE, () => {
496
+ for (const go of registry.all()) {
497
+ const transform = go.getData('entityTransform');
498
+ if (typeof transform?.depth === 'number')
499
+ continue;
500
+ const props = go.getData('entityProperties');
501
+ if (props?.skipYSort)
502
+ continue;
503
+ const target = go;
504
+ if (typeof target.y === 'number' && target.setDepth) {
505
+ target.setDepth(target.y);
506
+ }
507
+ }
508
+ });
509
+ }
510
+ /**
511
+ * Slice 6 Phase B — per-frame sync of TilemapLayer world positions to
512
+ * their parent tilemap container's transform. TilemapLayer can't be a
513
+ * Container child due to a Phaser rendering quirk (parent transform isn't
514
+ * applied to tilemap render), so layers live in scene root and we manually
515
+ * keep their world position in step with the container's. Each layer's
516
+ * `tilemapLocalOffsetX/Y` stash records the centered offset relative to
517
+ * the container; world position = container.x/y + offset.
518
+ *
519
+ * Cheap loop — only iterates entities tagged `entityKind === 'tilemap'`.
520
+ * Skips silently when no layers stashed (e.g. tilemap with no tilesets).
521
+ *
522
+ * Without this: dragging a painted tilemap in the editor would visually
523
+ * separate the grid bounds (which DO move with the container) from the
524
+ * painted tiles (which would stay frozen at their initial world position).
525
+ */
526
+ function installTilemapLayerSync(scene, registry) {
527
+ scene.events.on(Phaser.Scenes.Events.UPDATE, () => {
528
+ for (const go of registry.all()) {
529
+ if (go.getData('entityKind') !== 'tilemap')
530
+ continue;
531
+ const container = go;
532
+ const layers = go.getData('tilemapLayers');
533
+ if (!layers)
534
+ continue;
535
+ const containerDepth = container.depth ?? 0;
536
+ for (const layer of layers) {
537
+ const offX = layer.getData('tilemapLocalOffsetX') ?? 0;
538
+ const offY = layer.getData('tilemapLocalOffsetY') ?? 0;
539
+ const targetX = container.x + offX;
540
+ const targetY = container.y + offY;
541
+ if (layer.x !== targetX)
542
+ layer.x = targetX;
543
+ if (layer.y !== targetY)
544
+ layer.y = targetY;
545
+ // FB.10 — sync depth from container so user-driven z-order
546
+ // changes (Hierarchy drag → patchEntity → setDepth on container)
547
+ // actually move the layer's tiles. Without this, container.depth
548
+ // changes but layers stay at depth=0, so tilemap A "above" B
549
+ // doesn't actually render on top of B. Multiple layers within
550
+ // one tilemap share the container's depth; their relative order
551
+ // falls back to scene add order (= layer.z from createTilemap).
552
+ if (layer.depth !== containerDepth)
553
+ layer.setDepth(containerDepth);
554
+ }
555
+ }
556
+ });
557
+ }
558
+ /**
559
+ * Apply gravity declared in scene data to the scene's Arcade physics world.
560
+ *
561
+ * Precedence: the scene's own `world.physics.gravity` wins over the
562
+ * manifest-wide `globals.physics.gravity`. When neither is set, the world's
563
+ * gravity is left untouched — whatever `createUmicatGame` (or a game.json
564
+ * `GameConfig` wired through it) already established.
565
+ *
566
+ * Before this, both fields were typed-but-dead: a game could declare gravity
567
+ * in scene data, the SDK would silently ignore it, and e.g. a platformer
568
+ * would run at zero gravity. Applied per scene-load so each world scene can
569
+ * carry its own gravity. Components are applied individually so a partial
570
+ * `{ y }` leaves the existing `x` (and vice versa) intact.
571
+ */
572
+ function applyWorldGravity(scene, sceneFile, manifest) {
573
+ const gravity = sceneFile.world.physics?.gravity ?? manifest.globals?.physics?.gravity;
574
+ if (!gravity)
575
+ return;
576
+ const world = scene.physics?.world;
577
+ if (!world)
578
+ return; // Arcade physics not enabled on this scene — nothing to do.
579
+ if (typeof gravity.x === 'number')
580
+ world.gravity.x = gravity.x;
581
+ if (typeof gravity.y === 'number')
582
+ world.gravity.y = gravity.y;
583
+ }
584
+ function applyCamera(scene, sceneFile, registry) {
585
+ const cam = scene.cameras.main;
586
+ const cfg = sceneFile.camera ?? {};
587
+ // World bounds — default to scene world dims if not specified.
588
+ const bounds = cfg.bounds ?? { x: 0, y: 0, width: sceneFile.world.width, height: sceneFile.world.height };
589
+ cam.setBounds(bounds.x, bounds.y, bounds.width, bounds.height);
590
+ if (typeof cfg.zoom === 'number')
591
+ cam.setZoom(cfg.zoom);
592
+ if (cfg.pixelPerfect)
593
+ cam.setRoundPixels(true);
594
+ if (sceneFile.world.background?.color) {
595
+ cam.setBackgroundColor(sceneFile.world.background.color);
596
+ }
597
+ if (cfg.follow) {
598
+ const target = registry.byId(cfg.follow);
599
+ if (target) {
600
+ const lerp = typeof cfg.smoothing === 'number' ? cfg.smoothing : 1;
601
+ cam.startFollow(target, true, lerp, lerp);
602
+ if (cfg.deadzone)
603
+ cam.setDeadzone(cfg.deadzone.x * 2, cfg.deadzone.y * 2);
604
+ if (typeof cfg.lookahead === 'number') {
605
+ cam.setFollowOffset(-cfg.lookahead, 0);
606
+ }
607
+ }
608
+ else {
609
+ // Soft warning — behavior code might create the follow target later.
610
+ // Surfacing a console.warn keeps the runtime running while flagging
611
+ // the data inconsistency to whoever is debugging.
612
+ console.warn(`[umicat/scene] camera.follow id='${cfg.follow}' has no matching entity in scene '${sceneFile.id}'`);
613
+ }
614
+ }
615
+ }