@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,1224 @@
1
+ import Phaser from 'phaser';
2
+ import { SCHEMA_VERSION, isPerFrameNinePatch, } from './types.js';
3
+ import { attachEntityRegistry, getEntityRegistry, } from './EntityRegistry.js';
4
+ import { parseColor } from './spawnEntity.js';
5
+ import { applyPixelArtFilters, getAtlasFrameNinePatch, getManifest, SCENES_BASE, suspendSceneUpdates, } from './SceneLoader.js';
6
+ /**
7
+ * HUD runtime — slice 5.
8
+ *
9
+ * `UmicatHudScene` is a Phaser scene class that runs in parallel with the
10
+ * world scene, draws anchor-positioned widgets above the gameplay, and
11
+ * subscribes dynamic-text widgets to the game registry so they live-update
12
+ * when the agent's behavior code calls `this.registry.set(key, value)`.
13
+ *
14
+ * Agent contract (taught by the `hud-dynamic-binding` skill):
15
+ * - Use `scene.registry.set(key, value)` to drive HUD bindings. The HUD
16
+ * scene picks up the change via `registry.events.on('changedata-<key>')`.
17
+ * - HUD text widgets reference the registry key via `source.binding` (flat,
18
+ * at the widget root — SDK 0.3.0).
19
+ */
20
+ export const UMICAT_HUD_SCENE_KEY = 'UmicatHud';
21
+ /**
22
+ * Default safe area when the HUD scene file omits one. Matches design 04 §2.1.
23
+ */
24
+ const DEFAULT_SAFE_AREA = { top: 16, right: 16, bottom: 16, left: 16 };
25
+ /**
26
+ * Resolve a HUD anchor + offset against the current canvas size to a
27
+ * concrete pixel position. Called per widget on (a) initial spawn and (b)
28
+ * scale resize so widgets re-anchor when the viewport changes.
29
+ */
30
+ export function resolveAnchor(scene, anchor, safeArea = DEFAULT_SAFE_AREA) {
31
+ // In edit mode the canvas is EXPANDED to fill the iframe, so
32
+ // `scene.scale.width/height` is the expanded dim — NOT the game's
33
+ // intrinsic viewport. HUD widgets should anchor to the GAME'S
34
+ // intrinsic dims (the camera viewport rect) so that "top-right" =
35
+ // top-right of where the player will actually see the HUD at
36
+ // runtime. EditorBridge stores the intrinsic dims as a snapshot
37
+ // when expanding the canvas; we read them back here. Outside edit
38
+ // mode (no snapshot), `scene.scale.width/height` IS the game dims.
39
+ const snap = scene.game['__unboxyEditorScaleSnapshot'];
40
+ const w = snap?.gameWidth ?? scene.scale.width;
41
+ const h = snap?.gameHeight ?? scene.scale.height;
42
+ let baseX = 0;
43
+ let baseY = 0;
44
+ switch (anchor.side) {
45
+ case 'top-left':
46
+ baseX = safeArea.left;
47
+ baseY = safeArea.top;
48
+ break;
49
+ case 'top':
50
+ baseX = w / 2;
51
+ baseY = safeArea.top;
52
+ break;
53
+ case 'top-right':
54
+ baseX = w - safeArea.right;
55
+ baseY = safeArea.top;
56
+ break;
57
+ case 'left':
58
+ baseX = safeArea.left;
59
+ baseY = h / 2;
60
+ break;
61
+ case 'center':
62
+ baseX = w / 2;
63
+ baseY = h / 2;
64
+ break;
65
+ case 'right':
66
+ baseX = w - safeArea.right;
67
+ baseY = h / 2;
68
+ break;
69
+ case 'bottom-left':
70
+ baseX = safeArea.left;
71
+ baseY = h - safeArea.bottom;
72
+ break;
73
+ case 'bottom':
74
+ baseX = w / 2;
75
+ baseY = h - safeArea.bottom;
76
+ break;
77
+ case 'bottom-right':
78
+ baseX = w - safeArea.right;
79
+ baseY = h - safeArea.bottom;
80
+ break;
81
+ }
82
+ return {
83
+ x: baseX + (anchor.offsetX ?? 0),
84
+ y: baseY + (anchor.offsetY ?? 0),
85
+ };
86
+ }
87
+ const LAYER_DEPTH = {
88
+ base: 100,
89
+ overlay: 200,
90
+ modal: 300,
91
+ };
92
+ /**
93
+ * Render an image (or one cell of a uniform-grid spritesheet) at (x, y) sized
94
+ * to (width, height). When the asset carries `ninePatch` metadata, uses
95
+ * `phaser3-rex-plugins`' NinePatch (corners fixed, edges + center stretch);
96
+ * otherwise falls back to a regular Image scaled with `setDisplaySize`.
97
+ *
98
+ * Two ninePatch shapes accepted:
99
+ * - Single image (slice 7.5): `asset.ninePatch` is a `NinePatchConfig`. The
100
+ * whole texture slices into 9 regions.
101
+ * - Per-frame on a uniform-grid sheet (slice 7.6): `asset.ninePatch` is
102
+ * `{ perFrame: NinePatchConfig }`. The shared cuts apply to whichever
103
+ * frame the caller picks via `frame`. Common case: itch.io button packs
104
+ * where every cell is a different button color/state with the same
105
+ * corner radius.
106
+ *
107
+ * Caller owns origin/anchor — both Image and NinePatch (which extends
108
+ * RenderTexture) expose `setOrigin`. Returns the created GameObject already
109
+ * added to the scene's display list.
110
+ */
111
+ function createImageOrNinePatch(scene, x, y, width, height, asset, frame) {
112
+ // Region-atlas path (slice 10) — when the asset is an atlas-format texture
113
+ // AND the caller picks a frame by string name, per-region ninePatch
114
+ // metadata may live in the atlas JSON. Phaser drops unknown per-frame
115
+ // fields on parse, so we read from the side-cache stashed at preload.
116
+ if (asset.kind === 'atlas' && asset.atlasFormat === 'json' && typeof frame === 'string') {
117
+ const cuts = getAtlasFrameNinePatch(scene, asset.textureKey, frame);
118
+ if (cuts) {
119
+ return createCustomNinePatch(scene, x, y, width, height, asset, cuts, frame);
120
+ }
121
+ // Atlas frame without 9-slice metadata → plain stretched Image of that
122
+ // region. Falls through to the generic fallback below.
123
+ }
124
+ if (asset.ninePatch) {
125
+ const np = asset.ninePatch;
126
+ const cuts = isPerFrameNinePatch(np) ? np.perFrame : np;
127
+ return createCustomNinePatch(scene, x, y, width, height, asset, cuts, frame);
128
+ }
129
+ // Fallback: plain Image. Frame (number index or string atlas-name) threaded
130
+ // through for spritesheet / atlas assets so per-cell rendering still works
131
+ // without 9-slice.
132
+ const image = frame !== undefined
133
+ ? scene.add.image(x, y, asset.textureKey, frame)
134
+ : scene.add.image(x, y, asset.textureKey);
135
+ image.setDisplaySize(width, height);
136
+ return image;
137
+ }
138
+ /**
139
+ * 9-slice renderer built directly on Phaser primitives. We tried
140
+ * `phaser3-rex-plugins`' NinePatch first (slice 7.5/7.6) but ran into a
141
+ * RenderTexture sizing bug in Phaser 3.60+ that's hard to work around
142
+ * without monkey-patching: rex's constructor calls `super(scene)` with no
143
+ * dims (defaulting the DynamicTexture to 32×32), then `setSize(w, h)` —
144
+ * which in Phaser 3.60+ only sets display dimensions, not the underlying
145
+ * texture. The 9-slice gets drawn into the small default canvas and
146
+ * displayed stretched. Reproducing this without rex is faster than the
147
+ * monkey-patch dance.
148
+ *
149
+ * <p>Algorithm: split the source texture into 9 sub-frames via
150
+ * `texture.add()` (idempotent — Phaser ignores re-adds), spawn one Phaser
151
+ * Image per sub-frame, position + stretch them inside a Container. Corners
152
+ * keep their native dimensions; edges stretch in one axis; the middle
153
+ * stretches in both. Returns the container, sized to the target dims with
154
+ * an explicit hit area so `setInteractive` works as expected on the
155
+ * icon-button widget.
156
+ *
157
+ * <p>Source layout: `(sx, sy)` is the source-image origin within the
158
+ * texture; for plain Images it's (0,0). For per-frame on a uniform-grid
159
+ * sheet, `frame` selects which cell — the corresponding frame's `cutX/Y`
160
+ * gives the origin.
161
+ */
162
+ function createCustomNinePatch(scene, x, y, width, height, asset, cuts, frame) {
163
+ const texKey = asset.textureKey;
164
+ const texture = scene.textures.get(texKey);
165
+ // Per-frame variant: look up the specified cell's source rect (number for
166
+ // uniform-grid sheets, atlas-name string for region atlases). Single image
167
+ // uses the texture's `__BASE` frame covering the whole image.
168
+ const baseFrame = frame !== undefined
169
+ ? texture.get(frame)
170
+ : texture.get('__BASE');
171
+ const sx = baseFrame.cutX;
172
+ const sy = baseFrame.cutY;
173
+ const sw = baseFrame.width;
174
+ const sh = baseFrame.height;
175
+ const L = cuts.leftWidth;
176
+ const R = cuts.rightWidth;
177
+ const T = cuts.topHeight;
178
+ const B = cuts.bottomHeight;
179
+ // Source middle widths/heights (the part that stretches).
180
+ const midSrcW = Math.max(0, sw - L - R);
181
+ const midSrcH = Math.max(0, sh - T - B);
182
+ // Target middle widths/heights — what remains after the four corners are
183
+ // placed at their fixed sizes. When the widget is sized smaller than
184
+ // L+R / T+B, the corners overlap; rare but handled by clamping to 0
185
+ // rather than producing negative-size sprites.
186
+ const midDstW = Math.max(0, width - L - R);
187
+ const midDstH = Math.max(0, height - T - B);
188
+ const container = scene.add.container(x, y);
189
+ // Register a sub-frame on the source texture (idempotent) and add a
190
+ // Phaser Image rendering that frame at the target position + size.
191
+ // Position is relative to container's origin; Image origin (0, 0) makes
192
+ // (dx, dy) the top-left of the sub-region.
193
+ const frameSuffix = frame !== undefined ? `__f${frame}` : '';
194
+ const addSubFrame = (suffix, fx, fy, fw, fh, dx, dy, dw, dh) => {
195
+ if (fw <= 0 || fh <= 0 || dw <= 0 || dh <= 0)
196
+ return;
197
+ const subKey = `${texKey}${frameSuffix}_${suffix}`;
198
+ if (!texture.has(subKey)) {
199
+ texture.add(subKey, 0, sx + fx, sy + fy, fw, fh);
200
+ }
201
+ const img = scene.add.image(dx, dy, texKey, subKey);
202
+ img.setOrigin(0, 0);
203
+ img.setDisplaySize(dw, dh);
204
+ container.add(img);
205
+ };
206
+ // Layout offsets — container's local origin is at its center, so the
207
+ // 9-slice patch spans (-width/2, -height/2) to (width/2, height/2).
208
+ const x0 = -width / 2;
209
+ const y0 = -height / 2;
210
+ const xL = x0 + L;
211
+ const xR = x0 + width - R;
212
+ const yT = y0 + T;
213
+ const yB = y0 + height - B;
214
+ // Source frame offsets within the base frame.
215
+ const fxL = 0;
216
+ const fxM = L;
217
+ const fxR = sw - R;
218
+ const fyT = 0;
219
+ const fyM = T;
220
+ const fyB = sh - B;
221
+ // Top row
222
+ addSubFrame('tl', fxL, fyT, L, T, x0, y0, L, T);
223
+ addSubFrame('tm', fxM, fyT, midSrcW, T, xL, y0, midDstW, T);
224
+ addSubFrame('tr', fxR, fyT, R, T, xR, y0, R, T);
225
+ // Middle row
226
+ addSubFrame('ml', fxL, fyM, L, midSrcH, x0, yT, L, midDstH);
227
+ addSubFrame('mm', fxM, fyM, midSrcW, midSrcH, xL, yT, midDstW, midDstH);
228
+ addSubFrame('mr', fxR, fyM, R, midSrcH, xR, yT, R, midDstH);
229
+ // Bottom row
230
+ addSubFrame('bl', fxL, fyB, L, B, x0, yB, L, B);
231
+ addSubFrame('bm', fxM, fyB, midSrcW, B, xL, yB, midDstW, B);
232
+ addSubFrame('br', fxR, fyB, R, B, xR, yB, R, B);
233
+ // Explicit hit area so setInteractive works (Phaser containers have no
234
+ // intrinsic bounds; the icon-button widget calls setInteractive on this).
235
+ container.setSize(width, height);
236
+ return container;
237
+ }
238
+ /**
239
+ * Spawn a HUD widget into the scene. The returned GameObject is recorded in
240
+ * the entity registry so the editor + behavior code can find it by id.
241
+ */
242
+ export function spawnHudEntity(ctx, entity) {
243
+ const pos = resolveAnchor(ctx.scene, entity.anchor, ctx.safeArea);
244
+ let go;
245
+ switch (entity.kind) {
246
+ case 'text':
247
+ go = createText(ctx, entity, pos);
248
+ break;
249
+ case 'image':
250
+ go = createImage(ctx, entity, pos);
251
+ break;
252
+ case 'icon-button':
253
+ go = createIconButton(ctx, entity, pos);
254
+ break;
255
+ case 'progress-bar':
256
+ go = createProgressBar(ctx, entity, pos);
257
+ break;
258
+ case 'panel':
259
+ go = createPanel(ctx, entity, pos);
260
+ break;
261
+ default: {
262
+ const exhaustive = entity;
263
+ throw new Error(`[umicat/hud] unknown HUD entity kind: ${JSON.stringify(exhaustive)}`);
264
+ }
265
+ }
266
+ go.setData('entityId', entity.id);
267
+ // Z-order. Layer first (base < overlay < modal), then explicit z within layer.
268
+ const layerDepth = LAYER_DEPTH[entity.layer ?? 'base'];
269
+ const z = typeof entity.z === 'number' ? entity.z : 0;
270
+ go.setDepth?.(layerDepth + z);
271
+ if (entity.visible === false) {
272
+ go.setVisible?.(false);
273
+ }
274
+ ctx.registry.register(entity.id, entity.role, go);
275
+ return go;
276
+ }
277
+ function createText(ctx, entity, pos) {
278
+ const initialText = formatText(entity.source, ctx.scene);
279
+ const text = ctx.scene.add.text(pos.x, pos.y, initialText, {
280
+ fontFamily: entity.fontFamily ?? 'sans-serif',
281
+ fontSize: `${entity.fontSize ?? 18}px`,
282
+ color: entity.color ?? '#ffffff',
283
+ align: entity.align ?? 'left',
284
+ });
285
+ // Anchor origin matches the anchor side so position lands correctly under
286
+ // the resolved coordinate (e.g. top-right anchored text grows leftward).
287
+ applyOriginFromAnchor(text, entity.anchor.side);
288
+ // Wire dynamic binding subscription if needed. Stored on the GameObject so
289
+ // the editor's applyEdit (changing source) can rewire it.
290
+ const cleanup = subscribeText(text, entity.source, ctx.scene);
291
+ text.setData('hudCleanup', cleanup);
292
+ text.once(Phaser.GameObjects.Events.DESTROY, () => {
293
+ const fn = text.getData('hudCleanup');
294
+ fn?.();
295
+ });
296
+ return text;
297
+ }
298
+ function createImage(ctx, entity, pos) {
299
+ let asset;
300
+ try {
301
+ asset = ctx.resolveAsset(entity.assetId);
302
+ }
303
+ catch {
304
+ // Soft-fail when the assetId isn't in the manifest. Mirrors the world
305
+ // scene's createSprite missing-asset path. Wraps a Graphics in a
306
+ // Container so the editor's bounds-based hit-test has a sized target.
307
+ const w = entity.width ?? 64;
308
+ const h = entity.height ?? 64;
309
+ const g = ctx.scene.add.graphics();
310
+ g.fillStyle(0x4d2244, 0.4);
311
+ g.fillRect(-w / 2, -h / 2, w, h);
312
+ g.lineStyle(2, 0xff00ff, 0.9);
313
+ g.strokeRect(-w / 2, -h / 2, w, h);
314
+ g.lineBetween(-w / 2, -h / 2, w / 2, h / 2);
315
+ g.lineBetween(w / 2, -h / 2, -w / 2, h / 2);
316
+ const container = ctx.scene.add.container(pos.x, pos.y, [g]);
317
+ container.setSize(w, h);
318
+ return container;
319
+ }
320
+ // 9-slice path. When the asset has `ninePatch` metadata, the SDK renders
321
+ // via our custom Container-based NinePatch (corners fixed; edges + center
322
+ // stretch). Missing width/height falls back to the texture's native source
323
+ // dims so existing image-without-dims data still works.
324
+ if (asset.ninePatch) {
325
+ const tex = ctx.scene.textures.get(asset.textureKey);
326
+ const src = tex?.getSourceImage();
327
+ const w = entity.width ?? src?.width ?? 64;
328
+ const h = entity.height ?? src?.height ?? 64;
329
+ const np = createImageOrNinePatch(ctx.scene, pos.x, pos.y, w, h, asset);
330
+ if (typeof entity.alpha === 'number') {
331
+ np.setAlpha?.(entity.alpha);
332
+ }
333
+ // The custom NinePatch is a Container with children centered around
334
+ // (0, 0). Use the same origin-shift trick the container widgets use so
335
+ // the anchor side resolves correctly. Tint isn't supported on
336
+ // Container; image widgets needing tint should bypass ninePatch.
337
+ if (np instanceof Phaser.GameObjects.Container) {
338
+ applyContainerOriginShift(np, entity.anchor.side, w, h);
339
+ }
340
+ else {
341
+ applyOriginFromAnchor(np, entity.anchor.side);
342
+ }
343
+ return np;
344
+ }
345
+ const image = entity.frame !== undefined
346
+ ? ctx.scene.add.image(pos.x, pos.y, asset.textureKey, entity.frame)
347
+ : ctx.scene.add.image(pos.x, pos.y, asset.textureKey);
348
+ if (typeof entity.width === 'number' && typeof entity.height === 'number') {
349
+ image.setDisplaySize(entity.width, entity.height);
350
+ }
351
+ if (entity.tint)
352
+ image.setTint(parseColor(entity.tint));
353
+ if (typeof entity.alpha === 'number')
354
+ image.setAlpha(entity.alpha);
355
+ applyOriginFromAnchor(image, entity.anchor.side);
356
+ return image;
357
+ }
358
+ /**
359
+ * Build the v0 colored-rect background for an icon-button (rectangle or
360
+ * circle). Used when `backgroundAssetId` is unset OR the referenced asset
361
+ * couldn't be resolved.
362
+ */
363
+ function makeColoredRectBg(scene, v, w, h, fillColor, strokeColor, strokeW) {
364
+ const bg = v.shape === 'circle'
365
+ ? scene.add.circle(0, 0, Math.max(w, h) / 2, fillColor)
366
+ : (() => {
367
+ const r = scene.add.rectangle(0, 0, w, h, fillColor);
368
+ r.setOrigin(0.5, 0.5);
369
+ return r;
370
+ })();
371
+ if (strokeColor !== null && strokeW > 0) {
372
+ if (v.shape === 'circle') {
373
+ bg.setStrokeStyle(strokeW, strokeColor);
374
+ }
375
+ else {
376
+ bg.setStrokeStyle(strokeW, strokeColor);
377
+ }
378
+ }
379
+ return bg;
380
+ }
381
+ function createIconButton(ctx, entity, pos) {
382
+ const v = entity;
383
+ const w = v.width ?? 96;
384
+ const h = v.height ?? 48;
385
+ const fillColor = parseColor(v.fillColor ?? '#3b82f6');
386
+ const strokeColor = v.strokeColor === null ? null : parseColor(v.strokeColor ?? '#1e40af');
387
+ const strokeW = v.strokeWidth ?? 0;
388
+ // Container at anchor pos. Children drawn with their own origins.
389
+ const container = ctx.scene.add.container(pos.x, pos.y);
390
+ // Background source (textured vs. colored-rect). When `backgroundAssetId`
391
+ // is set we render the asset image (NinePatch when the asset has 9-slice
392
+ // metadata; stretched Image otherwise); colored-rect bg is the v0 fallback.
393
+ // The interactive surface is always the `bg` GameObject — POINTER_DOWN/UP
394
+ // events fire on whichever variant is active.
395
+ let bg;
396
+ let texturedBg = false;
397
+ if (v.backgroundAssetId) {
398
+ try {
399
+ const bgAsset = ctx.resolveAsset(v.backgroundAssetId);
400
+ // Region-atlas name (string) wins when both are set; uniform-grid index
401
+ // (number) is the fallback for slice 7.6 button packs.
402
+ const bgFrame = v.backgroundRegion ?? v.backgroundFrame;
403
+ const np = createImageOrNinePatch(ctx.scene, 0, 0, w, h, bgAsset, bgFrame);
404
+ np.setOrigin?.(0.5, 0.5);
405
+ bg = np;
406
+ texturedBg = true;
407
+ }
408
+ catch {
409
+ // Background asset missing — fall through to colored-rect.
410
+ bg = makeColoredRectBg(ctx.scene, v, w, h, fillColor, strokeColor, strokeW);
411
+ }
412
+ }
413
+ else {
414
+ bg = makeColoredRectBg(ctx.scene, v, w, h, fillColor, strokeColor, strokeW);
415
+ }
416
+ container.add(bg);
417
+ if (v.iconAssetId) {
418
+ try {
419
+ const asset = ctx.resolveAsset(v.iconAssetId);
420
+ const icon = ctx.scene.add.image(0, 0, asset.textureKey);
421
+ const iconSize = Math.min(w, h) * 0.6;
422
+ icon.setDisplaySize(iconSize, iconSize);
423
+ container.add(icon);
424
+ }
425
+ catch {
426
+ // Asset missing — fall through to label or nothing.
427
+ }
428
+ }
429
+ if (v.label) {
430
+ const label = ctx.scene.add.text(0, 0, v.label, {
431
+ fontFamily: 'sans-serif',
432
+ fontSize: `${v.fontSize ?? 16}px`,
433
+ color: v.textColor ?? '#ffffff',
434
+ });
435
+ label.setOrigin(0.5, 0.5);
436
+ container.add(label);
437
+ }
438
+ // Container has no intrinsic size — set hit area + size for the editor.
439
+ container.setSize(w, h);
440
+ // Anchor origin: containers don't have setOrigin, but offset children
441
+ // already centered at 0,0 means the container's "visual center" is its
442
+ // position. Apply the anchor-equivalent offset on the container's x/y.
443
+ applyContainerOriginShift(container, entity.anchor.side, w, h);
444
+ // Click → emit `hud:press` on the scene events bus. Agent's behavior
445
+ // code subscribes by entity id (or role) to wire its onPress action.
446
+ // The editor mode ignores this — input is captured by the EditorOverlay.
447
+ bg.setInteractive({ useHandCursor: true });
448
+ // pressedFillColor only applies to the colored-rect bg (Rectangle/Arc have
449
+ // setFillStyle). Textured backgrounds render the asset's pixels directly —
450
+ // pressed-state visual feedback for those is v2.
451
+ const supportsFillSwap = !texturedBg && !!v.pressedFillColor;
452
+ bg.on(Phaser.Input.Events.POINTER_DOWN, () => {
453
+ if (supportsFillSwap) {
454
+ bg.setFillStyle?.(parseColor(v.pressedFillColor));
455
+ }
456
+ });
457
+ bg.on(Phaser.Input.Events.POINTER_UP, () => {
458
+ if (supportsFillSwap) {
459
+ bg.setFillStyle?.(fillColor);
460
+ }
461
+ ctx.scene.events.emit('hud:press', entity.id, entity);
462
+ });
463
+ bg.on(Phaser.Input.Events.POINTER_OUT, () => {
464
+ if (supportsFillSwap) {
465
+ bg.setFillStyle?.(fillColor);
466
+ }
467
+ });
468
+ // Provide editor hit-test bounds on the container (Phaser containers don't
469
+ // give getBounds the way game objects do) — same convention as
470
+ // code-rendered visuals (see SDK 0.2.23).
471
+ container.setData('editorHitWidth', w);
472
+ container.setData('editorHitHeight', h);
473
+ return container;
474
+ }
475
+ /**
476
+ * Progress bar — Graphics-rendered (no Phaser primitive matches "fill that
477
+ * shrinks/grows from one edge"). Subscribes to value + max bindings if
478
+ * either is dynamic; redraws whenever a relevant registry key changes.
479
+ */
480
+ function createProgressBar(ctx, entity, pos) {
481
+ const v = entity;
482
+ const w = v.width ?? 200;
483
+ const h = v.height ?? 16;
484
+ const container = ctx.scene.add.container(pos.x, pos.y);
485
+ // Background + fill drawn into a Graphics so width-stretching doesn't
486
+ // distort corners (no setDisplaySize stretching, no extra dependencies).
487
+ const g = ctx.scene.add.graphics();
488
+ container.add(g);
489
+ const draw = (frac) => {
490
+ const f = Math.max(0, Math.min(1, frac));
491
+ g.clear();
492
+ const radius = v.borderRadius ?? 0;
493
+ // Centered draw: the container is positioned by applyContainerOriginShift
494
+ // so child visuals at (0,0) sit at the resolved anchor; drawing the rect
495
+ // from (-w/2, -h/2) keeps the visual aligned with the editor's
496
+ // editorHit{Width,Height} bounds (which are computed center-based).
497
+ const x0 = -w / 2;
498
+ const y0 = -h / 2;
499
+ // Background.
500
+ if (v.backgroundColor) {
501
+ g.fillStyle(parseColor(v.backgroundColor), 1);
502
+ if (radius > 0)
503
+ g.fillRoundedRect(x0, y0, w, h, radius);
504
+ else
505
+ g.fillRect(x0, y0, w, h);
506
+ }
507
+ // Fill.
508
+ const fillColor = v.fillColor ?? '#22c55e';
509
+ g.fillStyle(parseColor(fillColor), 1);
510
+ const fillW = w * f;
511
+ if (fillW > 0) {
512
+ if (radius > 0)
513
+ g.fillRoundedRect(x0, y0, fillW, h, Math.min(radius, fillW / 2));
514
+ else
515
+ g.fillRect(x0, y0, fillW, h);
516
+ }
517
+ // Border.
518
+ if (v.borderColor && (v.borderWidth ?? 0) > 0) {
519
+ g.lineStyle(v.borderWidth ?? 1, parseColor(v.borderColor), 1);
520
+ if (radius > 0)
521
+ g.strokeRoundedRect(x0, y0, w, h, radius);
522
+ else
523
+ g.strokeRect(x0, y0, w, h);
524
+ }
525
+ };
526
+ const computeFrac = () => {
527
+ const value = readNumberSource(v.value, ctx.scene);
528
+ const max = readNumberSource(v.max, ctx.scene);
529
+ if (max <= 0)
530
+ return 0;
531
+ return value / max;
532
+ };
533
+ draw(computeFrac());
534
+ // Subscribe to dynamic bindings. Either value or max can be dynamic;
535
+ // each pushes a redraw on registry change.
536
+ const cleanups = [];
537
+ if (v.value.mode === 'dynamic') {
538
+ const key = `changedata-${v.value.binding}`;
539
+ const handler = () => draw(computeFrac());
540
+ ctx.scene.game.registry.events.on(key, handler);
541
+ cleanups.push(() => ctx.scene.game.registry.events.off(key, handler));
542
+ }
543
+ if (v.max.mode === 'dynamic') {
544
+ const key = `changedata-${v.max.binding}`;
545
+ const handler = () => draw(computeFrac());
546
+ ctx.scene.game.registry.events.on(key, handler);
547
+ cleanups.push(() => ctx.scene.game.registry.events.off(key, handler));
548
+ }
549
+ // setSize so the editor's bounds-based hit-test works on the container.
550
+ container.setSize(w, h);
551
+ container.setData('editorHitWidth', w);
552
+ container.setData('editorHitHeight', h);
553
+ applyContainerOriginShift(container, entity.anchor.side, w, h);
554
+ // Persist redraw + cleanup on the GameObject for applyHudPatch to reach.
555
+ container.setData('hudRedraw', () => draw(computeFrac()));
556
+ container.setData('hudCleanup', () => cleanups.forEach((fn) => fn()));
557
+ container.once(Phaser.GameObjects.Events.DESTROY, () => {
558
+ const fn = container.getData('hudCleanup');
559
+ fn?.();
560
+ });
561
+ return container;
562
+ }
563
+ /**
564
+ * Panel — a colored rectangle with optional border + rounded corners. Used
565
+ * as a visual frame behind other HUD widgets (no children layout in v1;
566
+ * children live alongside the panel as separate entities).
567
+ */
568
+ function createPanel(ctx, entity, pos) {
569
+ const v = entity;
570
+ const w = v.width ?? 200;
571
+ const h = v.height ?? 100;
572
+ const container = ctx.scene.add.container(pos.x, pos.y);
573
+ // Textured-background path: when `backgroundAssetId` is set we render the
574
+ // asset image (NinePatch when it carries 9-slice metadata; stretched Image
575
+ // otherwise). The colored Graphics path below is the fallback when the
576
+ // asset is missing or no background asset is configured.
577
+ let bgRendered = false;
578
+ if (v.backgroundAssetId) {
579
+ try {
580
+ const bgAsset = ctx.resolveAsset(v.backgroundAssetId);
581
+ const bgFrame = v.backgroundRegion ?? v.backgroundFrame;
582
+ const np = createImageOrNinePatch(ctx.scene, 0, 0, w, h, bgAsset, bgFrame);
583
+ np.setOrigin?.(0.5, 0.5);
584
+ container.add(np);
585
+ bgRendered = true;
586
+ }
587
+ catch {
588
+ // Background asset missing — fall through to Graphics rect.
589
+ }
590
+ }
591
+ if (!bgRendered) {
592
+ const g = ctx.scene.add.graphics();
593
+ container.add(g);
594
+ const draw = () => {
595
+ g.clear();
596
+ const radius = v.borderRadius ?? 0;
597
+ // Centered draw — see createProgressBar for the rationale.
598
+ const x0 = -w / 2;
599
+ const y0 = -h / 2;
600
+ if (v.backgroundColor) {
601
+ g.fillStyle(parseColor(v.backgroundColor), v.backgroundAlpha ?? 1);
602
+ if (radius > 0)
603
+ g.fillRoundedRect(x0, y0, w, h, radius);
604
+ else
605
+ g.fillRect(x0, y0, w, h);
606
+ }
607
+ if (v.borderColor && (v.borderWidth ?? 0) > 0) {
608
+ g.lineStyle(v.borderWidth ?? 1, parseColor(v.borderColor), 1);
609
+ if (radius > 0)
610
+ g.strokeRoundedRect(x0, y0, w, h, radius);
611
+ else
612
+ g.strokeRect(x0, y0, w, h);
613
+ }
614
+ };
615
+ draw();
616
+ container.setData('hudRedraw', draw);
617
+ }
618
+ container.setSize(w, h);
619
+ container.setData('editorHitWidth', w);
620
+ container.setData('editorHitHeight', h);
621
+ applyContainerOriginShift(container, entity.anchor.side, w, h);
622
+ return container;
623
+ }
624
+ /** Read a numeric source (static or dynamic from registry). */
625
+ function readNumberSource(source, scene) {
626
+ if (source.mode === 'static')
627
+ return source.value;
628
+ const raw = scene.game.registry.get(source.binding);
629
+ if (typeof raw === 'number' && Number.isFinite(raw))
630
+ return raw;
631
+ return source.fallback ?? 0;
632
+ }
633
+ /**
634
+ * Map a 9-grid anchor side to a Phaser origin pair so the rendered widget
635
+ * is positioned correctly relative to the resolved anchor coordinate.
636
+ *
637
+ * Example: side='top-right' → origin (1, 0) so the widget's top-right
638
+ * corner lands ON the anchor coordinate (which itself is offset inward
639
+ * from the canvas's actual top-right corner by the safe-area inset).
640
+ */
641
+ function applyOriginFromAnchor(go, side) {
642
+ const map = {
643
+ 'top-left': [0, 0],
644
+ 'top': [0.5, 0],
645
+ 'top-right': [1, 0],
646
+ 'left': [0, 0.5],
647
+ 'center': [0.5, 0.5],
648
+ 'right': [1, 0.5],
649
+ 'bottom-left': [0, 1],
650
+ 'bottom': [0.5, 1],
651
+ 'bottom-right': [1, 1],
652
+ };
653
+ const [ox, oy] = map[side];
654
+ go.setOrigin(ox, oy);
655
+ }
656
+ /**
657
+ * Compute the per-side origin-shift offset for a container widget. Containers
658
+ * don't support setOrigin, so we shift the container's position so its
659
+ * (centred) child visuals appear at the right place relative to the
660
+ * resolved anchor coord. Used both at spawn and during applyHudPatch when
661
+ * the anchor side changes.
662
+ */
663
+ /** Default container widths/heights — must mirror the spawners' fallbacks. */
664
+ function defaultContainerWidth(entity) {
665
+ if (entity.kind === 'icon-button')
666
+ return entity.width ?? 96;
667
+ if (entity.kind === 'progress-bar')
668
+ return entity.width ?? 200;
669
+ if (entity.kind === 'panel')
670
+ return entity.width ?? 200;
671
+ return 0;
672
+ }
673
+ function defaultContainerHeight(entity) {
674
+ if (entity.kind === 'icon-button')
675
+ return entity.height ?? 48;
676
+ if (entity.kind === 'progress-bar')
677
+ return entity.height ?? 16;
678
+ if (entity.kind === 'panel')
679
+ return entity.height ?? 100;
680
+ return 0;
681
+ }
682
+ function originShiftFor(side, w, h) {
683
+ const map = {
684
+ 'top-left': [0, 0],
685
+ 'top': [0.5, 0],
686
+ 'top-right': [1, 0],
687
+ 'left': [0, 0.5],
688
+ 'center': [0.5, 0.5],
689
+ 'right': [1, 0.5],
690
+ 'bottom-left': [0, 1],
691
+ 'bottom': [0.5, 1],
692
+ 'bottom-right': [1, 1],
693
+ };
694
+ const [ox, oy] = map[side];
695
+ return { x: (0.5 - ox) * w, y: (0.5 - oy) * h };
696
+ }
697
+ function applyContainerOriginShift(container, side, w, h) {
698
+ const s = originShiftFor(side, w, h);
699
+ container.x += s.x;
700
+ container.y += s.y;
701
+ }
702
+ /**
703
+ * Compute the rendered string for a text source. Static returns the literal;
704
+ * dynamic reads the current registry value, applies prefix/suffix.
705
+ */
706
+ function formatText(source, scene) {
707
+ if (source.mode === 'static')
708
+ return source.text;
709
+ const value = scene.game.registry.get(source.binding);
710
+ const display = value === undefined || value === null ? (source.fallback ?? '') : String(value);
711
+ return `${source.prefix ?? ''}${display}${source.suffix ?? ''}`;
712
+ }
713
+ /**
714
+ * Subscribe a Phaser.Text to the registry key named by a dynamic source.
715
+ * Returns a cleanup function that detaches the listener.
716
+ */
717
+ function subscribeText(text, source, scene) {
718
+ if (source.mode !== 'dynamic')
719
+ return () => undefined;
720
+ const key = source.binding;
721
+ const handler = () => {
722
+ if (!text.scene)
723
+ return;
724
+ text.setText(formatText(source, scene));
725
+ };
726
+ scene.game.registry.events.on(`changedata-${key}`, handler);
727
+ return () => scene.game.registry.events.off(`changedata-${key}`, handler);
728
+ }
729
+ // --- Loader ----------------------------------------------------------------
730
+ /**
731
+ * Walk a HudScene's entities and queue Phaser loads for any image / icon
732
+ * assets it references. Idempotent — relies on `manifestState.requestedAssetIds`
733
+ * if SceneLoader's preload helpers have already touched this scene.
734
+ */
735
+ export function preloadHudAssets(scene, hudScene, manifest) {
736
+ const ids = collectHudAssetIds(hudScene.entities);
737
+ for (const id of ids) {
738
+ const asset = manifest.assets?.find((a) => a.id === id);
739
+ if (!asset) {
740
+ // Soft-fail: warn + continue. createImage / createIconButton render
741
+ // a placeholder when the asset is unresolvable. Mirrors the world
742
+ // scene's preloadSceneAssets soft-fail (SceneLoader.ts).
743
+ // eslint-disable-next-line no-console
744
+ console.warn(`[umicat/hud] HUD scene '${hudScene.id}' references assetId '${id}' but the manifest has no such asset; widget will render as a placeholder`);
745
+ continue;
746
+ }
747
+ if (scene.textures.exists(asset.textureKey))
748
+ continue;
749
+ if (asset.kind === 'image') {
750
+ scene.load.image(asset.textureKey, asset.path);
751
+ }
752
+ else if (asset.kind === 'spritesheet') {
753
+ if (asset.spriteSheetConfig) {
754
+ scene.load.spritesheet(asset.textureKey, asset.path, asset.spriteSheetConfig);
755
+ }
756
+ }
757
+ else if (asset.kind === 'atlas') {
758
+ if (asset.atlasPath && asset.atlasFormat === 'xml') {
759
+ scene.load.atlasXML(asset.textureKey, asset.path, asset.atlasPath);
760
+ }
761
+ else if (asset.atlasPath && asset.atlasFormat === 'json') {
762
+ scene.load.atlas(asset.textureKey, asset.path, asset.atlasPath);
763
+ // Also fetch the raw JSON into the side-cache so per-frame
764
+ // `ninePatch` metadata (which Phaser drops on parse) is readable at
765
+ // spawn time via `getAtlasFrameNinePatch`. See SceneLoader.ts.
766
+ const sidecarKey = `umicat:atlas-ninepatch:${asset.textureKey}`;
767
+ if (!scene.cache.json.exists(sidecarKey)) {
768
+ scene.load.json(sidecarKey, asset.atlasPath);
769
+ }
770
+ }
771
+ }
772
+ }
773
+ }
774
+ function collectHudAssetIds(entities) {
775
+ const ids = new Set();
776
+ for (const e of entities) {
777
+ if (e.kind === 'image') {
778
+ ids.add(e.assetId);
779
+ }
780
+ else if (e.kind === 'icon-button') {
781
+ if (e.iconAssetId)
782
+ ids.add(e.iconAssetId);
783
+ if (e.backgroundAssetId)
784
+ ids.add(e.backgroundAssetId);
785
+ }
786
+ else if (e.kind === 'panel' && e.backgroundAssetId) {
787
+ ids.add(e.backgroundAssetId);
788
+ }
789
+ }
790
+ return Array.from(ids);
791
+ }
792
+ /**
793
+ * Load and spawn a HUD scene into the given Phaser scene. Symmetric to
794
+ * `loadWorldScene`. Returns the parsed scene file + entity registry.
795
+ *
796
+ * Pattern (in `UmicatHudScene.create`):
797
+ * const result = await loadHudScene(this, hudId);
798
+ * // widgets live; agent's behavior code can subscribe to events via
799
+ * // `this.events.on('hud:press', id => ...)`.
800
+ */
801
+ export async function loadHudScene(scene, hudId) {
802
+ const release = suspendSceneUpdates(scene);
803
+ try {
804
+ return await loadHudSceneImpl(scene, hudId);
805
+ }
806
+ finally {
807
+ release();
808
+ }
809
+ }
810
+ async function loadHudSceneImpl(scene, hudId) {
811
+ const manifest = getManifest(scene);
812
+ const ref = manifest.huds?.find((h) => h.id === hudId);
813
+ if (!ref)
814
+ throw new Error(`[umicat/hud] manifest has no hud with id '${hudId}'`);
815
+ const hudScene = await loadHudJson(scene, hudId, ref.file);
816
+ if (hudScene.schemaVersion !== SCHEMA_VERSION) {
817
+ throw new Error(`[umicat/hud] HUD scene '${hudId}' schemaVersion ${hudScene.schemaVersion} but SDK expects ${SCHEMA_VERSION}`);
818
+ }
819
+ if (hudScene.type !== 'hud') {
820
+ throw new Error(`[umicat/hud] HUD scene '${hudId}' has type=${hudScene.type} in file body`);
821
+ }
822
+ preloadHudAssets(scene, hudScene, manifest);
823
+ await runLoader(scene);
824
+ // Mirrors loadWorldScene: NEAREST filter on every `pixelArt: true` asset.
825
+ // HUD scene runs in its own Phaser scene with its own texture references
826
+ // (textures are game-global so this is mostly idempotent with the world
827
+ // pass, but the HUD might load image assets the world doesn't reference).
828
+ applyPixelArtFilters(scene, manifest);
829
+ const registry = attachEntityRegistry(scene);
830
+ const safeArea = hudScene.design?.safeArea ?? DEFAULT_SAFE_AREA;
831
+ const ctx = {
832
+ scene,
833
+ registry,
834
+ safeArea,
835
+ resolveAsset: (id) => {
836
+ const a = manifest.assets?.find((x) => x.id === id);
837
+ if (!a)
838
+ throw new Error(`[umicat/hud] manifest has no asset with id '${id}'`);
839
+ return a;
840
+ },
841
+ };
842
+ for (const entity of hudScene.entities)
843
+ spawnHudEntity(ctx, entity);
844
+ // Re-anchor on canvas resize. Fired by Phaser's scale manager.
845
+ const onResize = () => reanchorAll(scene, hudScene, safeArea);
846
+ scene.scale.on(Phaser.Scale.Events.RESIZE, onResize);
847
+ scene.events.once(Phaser.Scenes.Events.SHUTDOWN, () => {
848
+ scene.scale.off(Phaser.Scale.Events.RESIZE, onResize);
849
+ });
850
+ return { hudScene, registry };
851
+ }
852
+ function reanchorAll(scene, hudScene, safeArea) {
853
+ const reg = getEntityRegistry(scene);
854
+ if (!reg)
855
+ return;
856
+ for (const entity of hudScene.entities) {
857
+ const go = reg.byId(entity.id);
858
+ if (!go)
859
+ continue;
860
+ const pos = resolveAnchor(scene, entity.anchor, safeArea);
861
+ go.x = pos.x;
862
+ go.y = pos.y;
863
+ // Re-apply origin shift for container-based widgets — initial
864
+ // spawn does this via `applyContainerOriginShift`, but the
865
+ // resize path was setting `go.x = pos.x` without the shift,
866
+ // leaving the container at the raw anchor pos and visually
867
+ // misaligning its children (drawn centered on container) by
868
+ // (w/2, h/2). Visible as the editor's selection rect being
869
+ // slightly off the widget's bg (the rect uses
870
+ // editorHitWidth/Height centered on the post-shift container.x,
871
+ // while the bg renders centered on the now-not-shifted
872
+ // container.x → mismatch). Text widgets (no Container) weren't
873
+ // affected, which matches the user's observation.
874
+ const hitW = go.getData?.('editorHitWidth');
875
+ const hitH = go.getData?.('editorHitHeight');
876
+ if (typeof hitW === 'number' && typeof hitH === 'number') {
877
+ const shift = originShiftFor(entity.anchor.side, hitW, hitH);
878
+ go.x += shift.x;
879
+ go.y += shift.y;
880
+ }
881
+ }
882
+ }
883
+ async function loadHudJson(scene, hudId, file) {
884
+ const cacheKey = `umicat:hud:${hudId}`;
885
+ if (scene.cache.json.exists(cacheKey)) {
886
+ return scene.cache.json.get(cacheKey);
887
+ }
888
+ scene.load.json(cacheKey, `${SCENES_BASE}${file}`);
889
+ await runLoader(scene);
890
+ return scene.cache.json.get(cacheKey);
891
+ }
892
+ function runLoader(scene) {
893
+ return new Promise((resolve, reject) => {
894
+ if (!scene.load.isLoading() && scene.load.list.size === 0) {
895
+ queueMicrotask(resolve);
896
+ return;
897
+ }
898
+ scene.load.once(Phaser.Loader.Events.COMPLETE, () => resolve());
899
+ scene.load.once(Phaser.Loader.Events.FILE_LOAD_ERROR, (file) => {
900
+ reject(new Error(`[umicat/hud] loader failed: ${file.key} (${file.url})`));
901
+ });
902
+ scene.load.start();
903
+ });
904
+ }
905
+ // --- Editor bridge helpers (slice 5) --------------------------------------
906
+ //
907
+ // These are called by EditorBridge.ts when the editor is in HUD mode. They
908
+ // keep the bridge file small (no HUD-specific surface there beyond a mode
909
+ // dispatch) and let HUD logic live alongside the runtime that originally
910
+ // spawned the entities.
911
+ /** Find the HUD scene's entity registry, if a HUD scene is currently active. */
912
+ export function findHudRegistry(game) {
913
+ const hud = game.scene.getScene(UMICAT_HUD_SCENE_KEY);
914
+ if (!hud)
915
+ return undefined;
916
+ return getEntityRegistry(hud);
917
+ }
918
+ /** Find the HUD scene file from the JSON cache, if loaded. */
919
+ export function findHudSceneFile(game) {
920
+ const hud = game.scene.getScene(UMICAT_HUD_SCENE_KEY);
921
+ if (!hud)
922
+ return undefined;
923
+ const cache = hud.cache.json;
924
+ const entries = cache.entries?.entries ?? {};
925
+ for (const [k, v] of Object.entries(entries)) {
926
+ if (k.startsWith('umicat:hud:'))
927
+ return v;
928
+ }
929
+ return undefined;
930
+ }
931
+ /**
932
+ * Look up a HUD entity record from the cached scene file. Used by the bridge
933
+ * during drag (to read anchor side / current offset) and applyEdit (to
934
+ * resolve the new anchor when side changes).
935
+ */
936
+ export function findHudEntity(game, entityId) {
937
+ const hud = findHudSceneFile(game);
938
+ if (!hud)
939
+ return undefined;
940
+ return hud.entities.find((e) => e.id === entityId);
941
+ }
942
+ /**
943
+ * Compute the canvas-pixel base position for an entity's anchor side
944
+ * (without offset). Used on drag-end to derive the new offset from the
945
+ * GameObject's final position.
946
+ */
947
+ export function getHudAnchorBase(game, entity) {
948
+ const hud = game.scene.getScene(UMICAT_HUD_SCENE_KEY);
949
+ if (!hud)
950
+ return { x: 0, y: 0 };
951
+ const sceneFile = findHudSceneFile(game);
952
+ const safeArea = sceneFile?.design?.safeArea ?? DEFAULT_SAFE_AREA;
953
+ return resolveAnchor(hud, { side: entity.anchor.side, offsetX: 0, offsetY: 0 }, safeArea);
954
+ }
955
+ /**
956
+ * Apply an editor patch to a live HUD widget. Mirrors the spawn function's
957
+ * logic but operates on the existing GameObject — anchor changes
958
+ * re-resolve position; text source changes rewire the registry subscription;
959
+ * visual style changes setText / setColor / etc. directly.
960
+ *
961
+ * Returns true if a known field was patched, false if nothing applied.
962
+ */
963
+ export function applyHudPatch(game, entityId, patch) {
964
+ const hud = game.scene.getScene(UMICAT_HUD_SCENE_KEY);
965
+ if (!hud)
966
+ return false;
967
+ const reg = getEntityRegistry(hud);
968
+ if (!reg)
969
+ return false;
970
+ const go = reg.byId(entityId);
971
+ if (!go)
972
+ return false;
973
+ const entity = findHudEntity(game, entityId);
974
+ if (!entity)
975
+ return false;
976
+ const sceneFile = findHudSceneFile(game);
977
+ const safeArea = sceneFile?.design?.safeArea ?? DEFAULT_SAFE_AREA;
978
+ // Anchor side / offset → re-resolve position. Mutates `entity` so further
979
+ // applyEdit calls see the latest values (the host owns persistence; the
980
+ // bridge cache is here for runtime correctness during the editing session).
981
+ if (patch.anchor) {
982
+ if (typeof patch.anchor.side === 'string') {
983
+ entity.anchor.side = patch.anchor.side;
984
+ }
985
+ if (typeof patch.anchor.offsetX === 'number')
986
+ entity.anchor.offsetX = patch.anchor.offsetX;
987
+ if (typeof patch.anchor.offsetY === 'number')
988
+ entity.anchor.offsetY = patch.anchor.offsetY;
989
+ const pos = resolveAnchor(hud, entity.anchor, safeArea);
990
+ // Container-based widgets (icon-button, progress-bar, panel) have no
991
+ // setOrigin — they were shifted at spawn by `applyContainerOriginShift`
992
+ // so their centred child visuals appear at the right place. The same
993
+ // shift must be re-applied on every anchor change, otherwise the
994
+ // container ends up centred on the raw anchor coord and the visual is
995
+ // off by ±(w/2, h/2). Text + image have setOrigin so we use that path.
996
+ const isContainer = entity.kind === 'icon-button' || entity.kind === 'progress-bar' || entity.kind === 'panel';
997
+ if (isContainer) {
998
+ const sized = entity;
999
+ const w = sized.width ?? defaultContainerWidth(entity);
1000
+ const h = sized.height ?? defaultContainerHeight(entity);
1001
+ const shift = originShiftFor(entity.anchor.side, w, h);
1002
+ go.x = pos.x + shift.x;
1003
+ go.y = pos.y + shift.y;
1004
+ }
1005
+ else {
1006
+ go.x = pos.x;
1007
+ go.y = pos.y;
1008
+ // Re-pivot the origin for setOrigin-based widgets (text, image).
1009
+ if (typeof patch.anchor.side === 'string') {
1010
+ const sideMap = {
1011
+ 'top-left': [0, 0],
1012
+ 'top': [0.5, 0],
1013
+ 'top-right': [1, 0],
1014
+ 'left': [0, 0.5],
1015
+ 'center': [0.5, 0.5],
1016
+ 'right': [1, 0.5],
1017
+ 'bottom-left': [0, 1],
1018
+ 'bottom': [0.5, 1],
1019
+ 'bottom-right': [1, 1],
1020
+ };
1021
+ const o = sideMap[patch.anchor.side];
1022
+ if (o) {
1023
+ const setOrigin = go.setOrigin;
1024
+ setOrigin?.call(go, o[0], o[1]);
1025
+ }
1026
+ }
1027
+ }
1028
+ }
1029
+ if (typeof patch.layer === 'string') {
1030
+ entity.layer = patch.layer;
1031
+ const layerDepth = LAYER_DEPTH[entity.layer ?? 'base'];
1032
+ const z = typeof entity.z === 'number' ? entity.z : 0;
1033
+ go.setDepth?.(layerDepth + z);
1034
+ }
1035
+ if (typeof patch.z === 'number') {
1036
+ entity.z = patch.z;
1037
+ const layerDepth = LAYER_DEPTH[entity.layer ?? 'base'];
1038
+ go.setDepth?.(layerDepth + patch.z);
1039
+ }
1040
+ // Per-kind render-field patches. Field names match the flat entity root.
1041
+ const fields = patch.fields;
1042
+ if (fields && entity.kind === 'text') {
1043
+ const v = entity;
1044
+ const text = go;
1045
+ if (typeof fields.fontFamily === 'string')
1046
+ v.fontFamily = fields.fontFamily;
1047
+ if (typeof fields.fontSize === 'number')
1048
+ v.fontSize = fields.fontSize;
1049
+ if (typeof fields.color === 'string')
1050
+ v.color = fields.color;
1051
+ if (typeof fields.align === 'string')
1052
+ v.align = fields.align;
1053
+ if (fields.source && typeof fields.source === 'object') {
1054
+ // Wholesale replacement of `source`. Re-subscribe registry binding.
1055
+ v.source = fields.source;
1056
+ const oldCleanup = text.getData('hudCleanup');
1057
+ oldCleanup?.();
1058
+ const cleanup = subscribeText(text, v.source, hud);
1059
+ text.setData('hudCleanup', cleanup);
1060
+ }
1061
+ text.setStyle({
1062
+ fontFamily: v.fontFamily ?? 'sans-serif',
1063
+ fontSize: `${v.fontSize ?? 18}px`,
1064
+ color: v.color ?? '#ffffff',
1065
+ align: v.align ?? 'left',
1066
+ });
1067
+ text.setText(formatText(v.source, hud));
1068
+ }
1069
+ if (fields && entity.kind === 'image') {
1070
+ const v = entity;
1071
+ // 9-slice live objects are RenderTextures, not Images — setDisplaySize /
1072
+ // setTint don't behave the same. Mirror the panel/icon-button approach:
1073
+ // mutate the entity record + re-spawn in place.
1074
+ const manifest = getManifest(hud);
1075
+ const asset = manifest.assets?.find((a) => a.id === v.assetId);
1076
+ if (asset?.ninePatch) {
1077
+ for (const [k, val] of Object.entries(fields)) {
1078
+ if (val !== undefined)
1079
+ v[k] = val;
1080
+ }
1081
+ respawnHudWidget(hud, reg, safeArea, entity, go, entityId);
1082
+ }
1083
+ else {
1084
+ const image = go;
1085
+ if (typeof fields.tint === 'string') {
1086
+ v.tint = fields.tint;
1087
+ image.setTint(parseColor(v.tint));
1088
+ }
1089
+ else if (fields.tint === null) {
1090
+ v.tint = undefined;
1091
+ image.clearTint();
1092
+ }
1093
+ if (typeof fields.alpha === 'number') {
1094
+ v.alpha = fields.alpha;
1095
+ image.setAlpha(fields.alpha);
1096
+ }
1097
+ if (typeof fields.width === 'number' && typeof fields.height === 'number') {
1098
+ v.width = fields.width;
1099
+ v.height = fields.height;
1100
+ image.setDisplaySize(fields.width, fields.height);
1101
+ }
1102
+ }
1103
+ }
1104
+ if (fields && (entity.kind === 'progress-bar' || entity.kind === 'panel' || entity.kind === 'icon-button')) {
1105
+ // All three are containers — flatten the patch onto the entity record
1106
+ // then re-spawn in place. Same pattern across the three kinds.
1107
+ const target = entity;
1108
+ for (const [k, val] of Object.entries(fields)) {
1109
+ if (val === null)
1110
+ delete target[k];
1111
+ else if (val !== undefined)
1112
+ target[k] = val;
1113
+ }
1114
+ respawnHudWidget(hud, reg, safeArea, entity, go, entityId);
1115
+ }
1116
+ if (patch.role !== undefined) {
1117
+ if (patch.role === null)
1118
+ entity.role = undefined;
1119
+ else
1120
+ entity.role = patch.role;
1121
+ }
1122
+ if (patch.properties)
1123
+ entity.properties = { ...(entity.properties ?? {}), ...patch.properties };
1124
+ return true;
1125
+ }
1126
+ /** Spawn a fresh HUD entity into the active HUD scene. Used by editor's create-entity path. */
1127
+ export function createHudEntityInScene(game, entity) {
1128
+ const hud = game.scene.getScene(UMICAT_HUD_SCENE_KEY);
1129
+ if (!hud)
1130
+ return false;
1131
+ const reg = getEntityRegistry(hud) ?? attachEntityRegistry(hud);
1132
+ const sceneFile = findHudSceneFile(game);
1133
+ const safeArea = sceneFile?.design?.safeArea ?? DEFAULT_SAFE_AREA;
1134
+ // Mutate the cached scene file so subsequent enter-edit snapshots include
1135
+ // the new entity. Persistence is the host's job.
1136
+ if (sceneFile)
1137
+ sceneFile.entities.push(entity);
1138
+ const ctx = {
1139
+ scene: hud,
1140
+ registry: reg,
1141
+ safeArea,
1142
+ resolveAsset: (id) => {
1143
+ const m = getManifest(hud);
1144
+ const a = m.assets?.find((x) => x.id === id);
1145
+ if (!a)
1146
+ throw new Error(`[umicat/hud] manifest has no asset with id '${id}'`);
1147
+ return a;
1148
+ },
1149
+ };
1150
+ spawnHudEntity(ctx, entity);
1151
+ return true;
1152
+ }
1153
+ /**
1154
+ * Re-spawn a HUD widget in place after a field mutation. Shared by
1155
+ * applyHudPatch for image-with-9-slice / progress-bar / panel / icon-button
1156
+ * — kinds whose live re-render is too field-conditional to do incrementally.
1157
+ */
1158
+ function respawnHudWidget(hud, reg, safeArea, entity, go, entityId) {
1159
+ const oldDepth = go.depth;
1160
+ go.destroy();
1161
+ reg.unregister(entityId);
1162
+ const ctx = {
1163
+ scene: hud,
1164
+ registry: reg,
1165
+ safeArea,
1166
+ resolveAsset: (id) => {
1167
+ const m = getManifest(hud);
1168
+ const a = m.assets?.find((x) => x.id === id);
1169
+ if (!a)
1170
+ throw new Error(`[umicat/hud] manifest has no asset with id '${id}'`);
1171
+ return a;
1172
+ },
1173
+ };
1174
+ const fresh = spawnHudEntity(ctx, entity);
1175
+ if (typeof oldDepth === 'number') {
1176
+ fresh.setDepth?.(oldDepth);
1177
+ }
1178
+ }
1179
+ /** Destroy a HUD entity. */
1180
+ export function deleteHudEntityFromScene(game, entityId) {
1181
+ const hud = game.scene.getScene(UMICAT_HUD_SCENE_KEY);
1182
+ if (!hud)
1183
+ return false;
1184
+ const reg = getEntityRegistry(hud);
1185
+ if (!reg)
1186
+ return false;
1187
+ const go = reg.byId(entityId);
1188
+ if (!go)
1189
+ return false;
1190
+ go.destroy();
1191
+ reg.unregister(entityId);
1192
+ const sceneFile = findHudSceneFile(game);
1193
+ if (sceneFile) {
1194
+ const idx = sceneFile.entities.findIndex((e) => e.id === entityId);
1195
+ if (idx >= 0)
1196
+ sceneFile.entities.splice(idx, 1);
1197
+ }
1198
+ return true;
1199
+ }
1200
+ // --- Phaser scene class ----------------------------------------------------
1201
+ /**
1202
+ * Per-game HUD scene. Auto-launched by `createUmicatGame` when the world
1203
+ * scene's manifest entry sets `hud: '<id>'`. Renders above the world via
1204
+ * Phaser's scene rendering order; takes input independently.
1205
+ */
1206
+ export class UmicatHudScene extends Phaser.Scene {
1207
+ constructor() {
1208
+ super({ key: UMICAT_HUD_SCENE_KEY });
1209
+ this.hudId = null;
1210
+ }
1211
+ init(data) {
1212
+ this.hudId = data.hudId ?? null;
1213
+ }
1214
+ async create() {
1215
+ if (!this.hudId)
1216
+ return;
1217
+ try {
1218
+ await loadHudScene(this, this.hudId);
1219
+ }
1220
+ catch (e) {
1221
+ console.warn('[umicat/hud] loadHudScene failed:', e);
1222
+ }
1223
+ }
1224
+ }