@unboxy/phaser-sdk 0.2.28 → 0.2.29

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.
@@ -0,0 +1,717 @@
1
+ import Phaser from 'phaser';
2
+ import { SCHEMA_VERSION, } from './types.js';
3
+ import { attachEntityRegistry, getEntityRegistry, } from './EntityRegistry.js';
4
+ import { parseColor } from './spawnEntity.js';
5
+ import { getManifest, SCENES_BASE } from './SceneLoader.js';
6
+ /**
7
+ * HUD runtime — slice 5.
8
+ *
9
+ * `UnboxyHudScene` 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
+ * - The HUD widget JSON references the key via `visual.source.binding`.
18
+ */
19
+ export const UNBOXY_HUD_SCENE_KEY = 'UnboxyHud';
20
+ /**
21
+ * Default safe area when the HUD scene file omits one. Matches design 04 §2.1.
22
+ */
23
+ const DEFAULT_SAFE_AREA = { top: 16, right: 16, bottom: 16, left: 16 };
24
+ /**
25
+ * Resolve a HUD anchor + offset against the current canvas size to a
26
+ * concrete pixel position. Called per widget on (a) initial spawn and (b)
27
+ * scale resize so widgets re-anchor when the viewport changes.
28
+ */
29
+ export function resolveAnchor(scene, anchor, safeArea = DEFAULT_SAFE_AREA) {
30
+ const w = scene.scale.width;
31
+ const h = scene.scale.height;
32
+ let baseX = 0;
33
+ let baseY = 0;
34
+ switch (anchor.side) {
35
+ case 'top-left':
36
+ baseX = safeArea.left;
37
+ baseY = safeArea.top;
38
+ break;
39
+ case 'top':
40
+ baseX = w / 2;
41
+ baseY = safeArea.top;
42
+ break;
43
+ case 'top-right':
44
+ baseX = w - safeArea.right;
45
+ baseY = safeArea.top;
46
+ break;
47
+ case 'left':
48
+ baseX = safeArea.left;
49
+ baseY = h / 2;
50
+ break;
51
+ case 'center':
52
+ baseX = w / 2;
53
+ baseY = h / 2;
54
+ break;
55
+ case 'right':
56
+ baseX = w - safeArea.right;
57
+ baseY = h / 2;
58
+ break;
59
+ case 'bottom-left':
60
+ baseX = safeArea.left;
61
+ baseY = h - safeArea.bottom;
62
+ break;
63
+ case 'bottom':
64
+ baseX = w / 2;
65
+ baseY = h - safeArea.bottom;
66
+ break;
67
+ case 'bottom-right':
68
+ baseX = w - safeArea.right;
69
+ baseY = h - safeArea.bottom;
70
+ break;
71
+ }
72
+ return {
73
+ x: baseX + (anchor.offsetX ?? 0),
74
+ y: baseY + (anchor.offsetY ?? 0),
75
+ };
76
+ }
77
+ const LAYER_DEPTH = {
78
+ base: 100,
79
+ overlay: 200,
80
+ modal: 300,
81
+ };
82
+ /**
83
+ * Spawn a HUD widget into the scene. The returned GameObject is recorded in
84
+ * the entity registry so the editor + behavior code can find it by id.
85
+ */
86
+ export function spawnHudEntity(ctx, entity) {
87
+ const pos = resolveAnchor(ctx.scene, entity.anchor, ctx.safeArea);
88
+ let go;
89
+ switch (entity.kind) {
90
+ case 'text':
91
+ go = createText(ctx, entity, pos);
92
+ break;
93
+ case 'image':
94
+ go = createImage(ctx, entity, pos);
95
+ break;
96
+ case 'icon-button':
97
+ go = createIconButton(ctx, entity, pos);
98
+ break;
99
+ default: {
100
+ const exhaustive = entity;
101
+ throw new Error(`[unboxy/hud] unknown HUD entity kind: ${JSON.stringify(exhaustive)}`);
102
+ }
103
+ }
104
+ go.setData('entityId', entity.id);
105
+ // Z-order. Layer first (base < overlay < modal), then explicit z within layer.
106
+ const layerDepth = LAYER_DEPTH[entity.layer ?? 'base'];
107
+ const z = typeof entity.z === 'number' ? entity.z : 0;
108
+ go.setDepth?.(layerDepth + z);
109
+ if (entity.visible === false) {
110
+ go.setVisible?.(false);
111
+ }
112
+ ctx.registry.register(entity.id, entity.role, go);
113
+ return go;
114
+ }
115
+ function createText(ctx, entity, pos) {
116
+ const initialText = formatText(entity.visual.source, ctx.scene);
117
+ const text = ctx.scene.add.text(pos.x, pos.y, initialText, {
118
+ fontFamily: entity.visual.fontFamily ?? 'sans-serif',
119
+ fontSize: `${entity.visual.fontSize ?? 18}px`,
120
+ color: entity.visual.color ?? '#ffffff',
121
+ align: entity.visual.align ?? 'left',
122
+ });
123
+ // Anchor origin matches the anchor side so position lands correctly under
124
+ // the resolved coordinate (e.g. top-right anchored text grows leftward).
125
+ applyOriginFromAnchor(text, entity.anchor.side);
126
+ // Wire dynamic binding subscription if needed. Stored on the GameObject so
127
+ // the editor's applyEdit (changing source) can rewire it.
128
+ const cleanup = subscribeText(text, entity.visual.source, ctx.scene);
129
+ text.setData('hudCleanup', cleanup);
130
+ text.once(Phaser.GameObjects.Events.DESTROY, () => {
131
+ const fn = text.getData('hudCleanup');
132
+ fn?.();
133
+ });
134
+ return text;
135
+ }
136
+ function createImage(ctx, entity, pos) {
137
+ const asset = ctx.resolveAsset(entity.visual.assetId);
138
+ const image = entity.visual.frame !== undefined
139
+ ? ctx.scene.add.image(pos.x, pos.y, asset.textureKey, entity.visual.frame)
140
+ : ctx.scene.add.image(pos.x, pos.y, asset.textureKey);
141
+ if (typeof entity.visual.width === 'number' && typeof entity.visual.height === 'number') {
142
+ image.setDisplaySize(entity.visual.width, entity.visual.height);
143
+ }
144
+ if (entity.visual.tint)
145
+ image.setTint(parseColor(entity.visual.tint));
146
+ if (typeof entity.visual.alpha === 'number')
147
+ image.setAlpha(entity.visual.alpha);
148
+ applyOriginFromAnchor(image, entity.anchor.side);
149
+ return image;
150
+ }
151
+ function createIconButton(ctx, entity, pos) {
152
+ const v = entity.visual;
153
+ const w = v.width ?? 96;
154
+ const h = v.height ?? 48;
155
+ const fillColor = parseColor(v.fillColor ?? '#3b82f6');
156
+ const strokeColor = v.strokeColor === null ? null : parseColor(v.strokeColor ?? '#1e40af');
157
+ const strokeW = v.strokeWidth ?? 0;
158
+ // Container at anchor pos. Children drawn with their own origins.
159
+ const container = ctx.scene.add.container(pos.x, pos.y);
160
+ const bg = v.shape === 'circle'
161
+ ? ctx.scene.add.circle(0, 0, Math.max(w, h) / 2, fillColor)
162
+ : (() => {
163
+ const r = ctx.scene.add.rectangle(0, 0, w, h, fillColor);
164
+ r.setOrigin(0.5, 0.5);
165
+ return r;
166
+ })();
167
+ if (strokeColor !== null && strokeW > 0) {
168
+ if (v.shape === 'circle') {
169
+ bg.setStrokeStyle(strokeW, strokeColor);
170
+ }
171
+ else {
172
+ bg.setStrokeStyle(strokeW, strokeColor);
173
+ }
174
+ }
175
+ container.add(bg);
176
+ if (v.iconAssetId) {
177
+ try {
178
+ const asset = ctx.resolveAsset(v.iconAssetId);
179
+ const icon = ctx.scene.add.image(0, 0, asset.textureKey);
180
+ const iconSize = Math.min(w, h) * 0.6;
181
+ icon.setDisplaySize(iconSize, iconSize);
182
+ container.add(icon);
183
+ }
184
+ catch {
185
+ // Asset missing — fall through to label or nothing.
186
+ }
187
+ }
188
+ if (v.label) {
189
+ const label = ctx.scene.add.text(0, 0, v.label, {
190
+ fontFamily: 'sans-serif',
191
+ fontSize: `${v.fontSize ?? 16}px`,
192
+ color: v.textColor ?? '#ffffff',
193
+ });
194
+ label.setOrigin(0.5, 0.5);
195
+ container.add(label);
196
+ }
197
+ // Container has no intrinsic size — set hit area + size for the editor.
198
+ container.setSize(w, h);
199
+ // Anchor origin: containers don't have setOrigin, but offset children
200
+ // already centered at 0,0 means the container's "visual center" is its
201
+ // position. Apply the anchor-equivalent offset on the container's x/y.
202
+ applyContainerOriginShift(container, entity.anchor.side, w, h);
203
+ // Click → emit `hud:press` on the scene events bus. Agent's behavior
204
+ // code subscribes by entity id (or role) to wire its onPress action.
205
+ // The editor mode ignores this — input is captured by the EditorOverlay.
206
+ bg.setInteractive({ useHandCursor: true });
207
+ bg.on(Phaser.Input.Events.POINTER_DOWN, () => {
208
+ if (v.pressedFillColor) {
209
+ bg.setFillStyle?.(parseColor(v.pressedFillColor));
210
+ }
211
+ });
212
+ bg.on(Phaser.Input.Events.POINTER_UP, () => {
213
+ if (v.pressedFillColor) {
214
+ bg.setFillStyle?.(fillColor);
215
+ }
216
+ ctx.scene.events.emit('hud:press', entity.id, entity);
217
+ });
218
+ bg.on(Phaser.Input.Events.POINTER_OUT, () => {
219
+ if (v.pressedFillColor) {
220
+ bg.setFillStyle?.(fillColor);
221
+ }
222
+ });
223
+ // Provide editor hit-test bounds on the container (Phaser containers don't
224
+ // give getBounds the way game objects do) — same convention as
225
+ // code-rendered visuals (see SDK 0.2.23).
226
+ container.setData('editorHitWidth', w);
227
+ container.setData('editorHitHeight', h);
228
+ return container;
229
+ }
230
+ /**
231
+ * Map a 9-grid anchor side to a Phaser origin pair so the rendered widget
232
+ * is positioned correctly relative to the resolved anchor coordinate.
233
+ *
234
+ * Example: side='top-right' → origin (1, 0) so the widget's top-right
235
+ * corner lands ON the anchor coordinate (which itself is offset inward
236
+ * from the canvas's actual top-right corner by the safe-area inset).
237
+ */
238
+ function applyOriginFromAnchor(go, side) {
239
+ const map = {
240
+ 'top-left': [0, 0],
241
+ 'top': [0.5, 0],
242
+ 'top-right': [1, 0],
243
+ 'left': [0, 0.5],
244
+ 'center': [0.5, 0.5],
245
+ 'right': [1, 0.5],
246
+ 'bottom-left': [0, 1],
247
+ 'bottom': [0.5, 1],
248
+ 'bottom-right': [1, 1],
249
+ };
250
+ const [ox, oy] = map[side];
251
+ go.setOrigin(ox, oy);
252
+ }
253
+ /**
254
+ * Containers don't support setOrigin; instead nudge their position by half
255
+ * of (origin × size) so they end up positioned consistently with widgets
256
+ * that DO have setOrigin. Called once at spawn.
257
+ */
258
+ function applyContainerOriginShift(container, side, w, h) {
259
+ const map = {
260
+ 'top-left': [0, 0],
261
+ 'top': [0.5, 0],
262
+ 'top-right': [1, 0],
263
+ 'left': [0, 0.5],
264
+ 'center': [0.5, 0.5],
265
+ 'right': [1, 0.5],
266
+ 'bottom-left': [0, 1],
267
+ 'bottom': [0.5, 1],
268
+ 'bottom-right': [1, 1],
269
+ };
270
+ const [ox, oy] = map[side];
271
+ // Container's children are drawn relative to (0,0) inside the container,
272
+ // and we drew bg at (0,0) (its setOrigin is 0.5/0.5). To shift the
273
+ // container so the desired anchor corner lands at the resolved coord:
274
+ container.x += (0.5 - ox) * w;
275
+ container.y += (0.5 - oy) * h;
276
+ }
277
+ /**
278
+ * Compute the rendered string for a text source. Static returns the literal;
279
+ * dynamic reads the current registry value, applies prefix/suffix.
280
+ */
281
+ function formatText(source, scene) {
282
+ if (source.mode === 'static')
283
+ return source.text;
284
+ const value = scene.game.registry.get(source.binding);
285
+ const display = value === undefined || value === null ? (source.fallback ?? '') : String(value);
286
+ return `${source.prefix ?? ''}${display}${source.suffix ?? ''}`;
287
+ }
288
+ /**
289
+ * Subscribe a Phaser.Text to the registry key named by a dynamic source.
290
+ * Returns a cleanup function that detaches the listener.
291
+ */
292
+ function subscribeText(text, source, scene) {
293
+ if (source.mode !== 'dynamic')
294
+ return () => undefined;
295
+ const key = source.binding;
296
+ const handler = () => {
297
+ if (!text.scene)
298
+ return;
299
+ text.setText(formatText(source, scene));
300
+ };
301
+ scene.game.registry.events.on(`changedata-${key}`, handler);
302
+ return () => scene.game.registry.events.off(`changedata-${key}`, handler);
303
+ }
304
+ // --- Loader ----------------------------------------------------------------
305
+ /**
306
+ * Walk a HudScene's entities and queue Phaser loads for any image / icon
307
+ * assets it references. Idempotent — relies on `manifestState.requestedAssetIds`
308
+ * if SceneLoader's preload helpers have already touched this scene.
309
+ */
310
+ export function preloadHudAssets(scene, hudScene, manifest) {
311
+ const ids = collectHudAssetIds(hudScene.entities);
312
+ for (const id of ids) {
313
+ const asset = manifest.assets?.find((a) => a.id === id);
314
+ if (!asset) {
315
+ throw new Error(`[unboxy/hud] HUD scene '${hudScene.id}' references assetId '${id}' but the manifest has no such asset`);
316
+ }
317
+ if (scene.textures.exists(asset.textureKey))
318
+ continue;
319
+ if (asset.kind === 'image') {
320
+ scene.load.image(asset.textureKey, asset.path);
321
+ }
322
+ else if (asset.kind === 'spritesheet') {
323
+ if (asset.spriteSheetConfig) {
324
+ scene.load.spritesheet(asset.textureKey, asset.path, asset.spriteSheetConfig);
325
+ }
326
+ }
327
+ else if (asset.kind === 'atlas') {
328
+ if (asset.atlasPath && asset.atlasFormat === 'xml') {
329
+ scene.load.atlasXML(asset.textureKey, asset.path, asset.atlasPath);
330
+ }
331
+ else if (asset.atlasPath && asset.atlasFormat === 'json') {
332
+ scene.load.atlas(asset.textureKey, asset.path, asset.atlasPath);
333
+ }
334
+ }
335
+ }
336
+ }
337
+ function collectHudAssetIds(entities) {
338
+ const ids = new Set();
339
+ for (const e of entities) {
340
+ if (e.kind === 'image')
341
+ ids.add(e.visual.assetId);
342
+ else if (e.kind === 'icon-button' && e.visual.iconAssetId)
343
+ ids.add(e.visual.iconAssetId);
344
+ }
345
+ return Array.from(ids);
346
+ }
347
+ /**
348
+ * Load and spawn a HUD scene into the given Phaser scene. Symmetric to
349
+ * `loadWorldScene`. Returns the parsed scene file + entity registry.
350
+ *
351
+ * Pattern (in `UnboxyHudScene.create`):
352
+ * const result = await loadHudScene(this, hudId);
353
+ * // widgets live; agent's behavior code can subscribe to events via
354
+ * // `this.events.on('hud:press', id => ...)`.
355
+ */
356
+ export async function loadHudScene(scene, hudId) {
357
+ const manifest = getManifest(scene);
358
+ const ref = manifest.huds?.find((h) => h.id === hudId);
359
+ if (!ref)
360
+ throw new Error(`[unboxy/hud] manifest has no hud with id '${hudId}'`);
361
+ const hudScene = await loadHudJson(scene, hudId, ref.file);
362
+ if (hudScene.schemaVersion !== SCHEMA_VERSION) {
363
+ throw new Error(`[unboxy/hud] HUD scene '${hudId}' schemaVersion ${hudScene.schemaVersion} but SDK expects ${SCHEMA_VERSION}`);
364
+ }
365
+ if (hudScene.type !== 'hud') {
366
+ throw new Error(`[unboxy/hud] HUD scene '${hudId}' has type=${hudScene.type} in file body`);
367
+ }
368
+ preloadHudAssets(scene, hudScene, manifest);
369
+ await runLoader(scene);
370
+ const registry = attachEntityRegistry(scene);
371
+ const safeArea = hudScene.design?.safeArea ?? DEFAULT_SAFE_AREA;
372
+ const ctx = {
373
+ scene,
374
+ registry,
375
+ safeArea,
376
+ resolveAsset: (id) => {
377
+ const a = manifest.assets?.find((x) => x.id === id);
378
+ if (!a)
379
+ throw new Error(`[unboxy/hud] manifest has no asset with id '${id}'`);
380
+ return a;
381
+ },
382
+ };
383
+ for (const entity of hudScene.entities)
384
+ spawnHudEntity(ctx, entity);
385
+ // Re-anchor on canvas resize. Fired by Phaser's scale manager.
386
+ const onResize = () => reanchorAll(scene, hudScene, safeArea);
387
+ scene.scale.on(Phaser.Scale.Events.RESIZE, onResize);
388
+ scene.events.once(Phaser.Scenes.Events.SHUTDOWN, () => {
389
+ scene.scale.off(Phaser.Scale.Events.RESIZE, onResize);
390
+ });
391
+ return { hudScene, registry };
392
+ }
393
+ function reanchorAll(scene, hudScene, safeArea) {
394
+ const reg = getEntityRegistry(scene);
395
+ if (!reg)
396
+ return;
397
+ for (const entity of hudScene.entities) {
398
+ const go = reg.byId(entity.id);
399
+ if (!go)
400
+ continue;
401
+ const pos = resolveAnchor(scene, entity.anchor, safeArea);
402
+ go.x = pos.x;
403
+ go.y = pos.y;
404
+ }
405
+ }
406
+ async function loadHudJson(scene, hudId, file) {
407
+ const cacheKey = `unboxy:hud:${hudId}`;
408
+ if (scene.cache.json.exists(cacheKey)) {
409
+ return scene.cache.json.get(cacheKey);
410
+ }
411
+ scene.load.json(cacheKey, `${SCENES_BASE}${file}`);
412
+ await runLoader(scene);
413
+ return scene.cache.json.get(cacheKey);
414
+ }
415
+ function runLoader(scene) {
416
+ return new Promise((resolve, reject) => {
417
+ if (!scene.load.isLoading() && scene.load.list.size === 0) {
418
+ queueMicrotask(resolve);
419
+ return;
420
+ }
421
+ scene.load.once(Phaser.Loader.Events.COMPLETE, () => resolve());
422
+ scene.load.once(Phaser.Loader.Events.FILE_LOAD_ERROR, (file) => {
423
+ reject(new Error(`[unboxy/hud] loader failed: ${file.key} (${file.url})`));
424
+ });
425
+ scene.load.start();
426
+ });
427
+ }
428
+ // --- Editor bridge helpers (slice 5) --------------------------------------
429
+ //
430
+ // These are called by EditorBridge.ts when the editor is in HUD mode. They
431
+ // keep the bridge file small (no HUD-specific surface there beyond a mode
432
+ // dispatch) and let HUD logic live alongside the runtime that originally
433
+ // spawned the entities.
434
+ /** Find the HUD scene's entity registry, if a HUD scene is currently active. */
435
+ export function findHudRegistry(game) {
436
+ const hud = game.scene.getScene(UNBOXY_HUD_SCENE_KEY);
437
+ if (!hud)
438
+ return undefined;
439
+ return getEntityRegistry(hud);
440
+ }
441
+ /** Find the HUD scene file from the JSON cache, if loaded. */
442
+ export function findHudSceneFile(game) {
443
+ const hud = game.scene.getScene(UNBOXY_HUD_SCENE_KEY);
444
+ if (!hud)
445
+ return undefined;
446
+ const cache = hud.cache.json;
447
+ const entries = cache.entries?.entries ?? {};
448
+ for (const [k, v] of Object.entries(entries)) {
449
+ if (k.startsWith('unboxy:hud:'))
450
+ return v;
451
+ }
452
+ return undefined;
453
+ }
454
+ /**
455
+ * Look up a HUD entity record from the cached scene file. Used by the bridge
456
+ * during drag (to read anchor side / current offset) and applyEdit (to
457
+ * resolve the new anchor when side changes).
458
+ */
459
+ export function findHudEntity(game, entityId) {
460
+ const hud = findHudSceneFile(game);
461
+ if (!hud)
462
+ return undefined;
463
+ return hud.entities.find((e) => e.id === entityId);
464
+ }
465
+ /**
466
+ * Compute the canvas-pixel base position for an entity's anchor side
467
+ * (without offset). Used on drag-end to derive the new offset from the
468
+ * GameObject's final position.
469
+ */
470
+ export function getHudAnchorBase(game, entity) {
471
+ const hud = game.scene.getScene(UNBOXY_HUD_SCENE_KEY);
472
+ if (!hud)
473
+ return { x: 0, y: 0 };
474
+ const sceneFile = findHudSceneFile(game);
475
+ const safeArea = sceneFile?.design?.safeArea ?? DEFAULT_SAFE_AREA;
476
+ return resolveAnchor(hud, { side: entity.anchor.side, offsetX: 0, offsetY: 0 }, safeArea);
477
+ }
478
+ /**
479
+ * Apply an editor patch to a live HUD widget. Mirrors the spawn function's
480
+ * logic but operates on the existing GameObject — anchor changes
481
+ * re-resolve position; text source changes rewire the registry subscription;
482
+ * visual style changes setText / setColor / etc. directly.
483
+ *
484
+ * Returns true if a known field was patched, false if nothing applied.
485
+ */
486
+ export function applyHudPatch(game, entityId, patch) {
487
+ const hud = game.scene.getScene(UNBOXY_HUD_SCENE_KEY);
488
+ if (!hud)
489
+ return false;
490
+ const reg = getEntityRegistry(hud);
491
+ if (!reg)
492
+ return false;
493
+ const go = reg.byId(entityId);
494
+ if (!go)
495
+ return false;
496
+ const entity = findHudEntity(game, entityId);
497
+ if (!entity)
498
+ return false;
499
+ const sceneFile = findHudSceneFile(game);
500
+ const safeArea = sceneFile?.design?.safeArea ?? DEFAULT_SAFE_AREA;
501
+ // Anchor side / offset → re-resolve position. Mutates `entity` so further
502
+ // applyEdit calls see the latest values (the host owns persistence; the
503
+ // bridge cache is here for runtime correctness during the editing session).
504
+ if (patch.anchor) {
505
+ if (typeof patch.anchor.side === 'string') {
506
+ entity.anchor.side = patch.anchor.side;
507
+ }
508
+ if (typeof patch.anchor.offsetX === 'number')
509
+ entity.anchor.offsetX = patch.anchor.offsetX;
510
+ if (typeof patch.anchor.offsetY === 'number')
511
+ entity.anchor.offsetY = patch.anchor.offsetY;
512
+ const pos = resolveAnchor(hud, entity.anchor, safeArea);
513
+ go.x = pos.x;
514
+ go.y = pos.y;
515
+ // Text + image origins were set per anchor.side at spawn — re-apply on
516
+ // side change so e.g. switching top-left → top-right re-pivots the
517
+ // origin and the widget grows in the correct direction.
518
+ if (typeof patch.anchor.side === 'string') {
519
+ const sideMap = {
520
+ 'top-left': [0, 0],
521
+ 'top': [0.5, 0],
522
+ 'top-right': [1, 0],
523
+ 'left': [0, 0.5],
524
+ 'center': [0.5, 0.5],
525
+ 'right': [1, 0.5],
526
+ 'bottom-left': [0, 1],
527
+ 'bottom': [0.5, 1],
528
+ 'bottom-right': [1, 1],
529
+ };
530
+ const o = sideMap[patch.anchor.side];
531
+ if (o) {
532
+ const setOrigin = go.setOrigin;
533
+ setOrigin?.call(go, o[0], o[1]);
534
+ }
535
+ }
536
+ }
537
+ if (typeof patch.layer === 'string') {
538
+ entity.layer = patch.layer;
539
+ const layerDepth = LAYER_DEPTH[entity.layer ?? 'base'];
540
+ const z = typeof entity.z === 'number' ? entity.z : 0;
541
+ go.setDepth?.(layerDepth + z);
542
+ }
543
+ if (typeof patch.z === 'number') {
544
+ entity.z = patch.z;
545
+ const layerDepth = LAYER_DEPTH[entity.layer ?? 'base'];
546
+ go.setDepth?.(layerDepth + patch.z);
547
+ }
548
+ // Per-kind visual patches.
549
+ if (patch.visual && entity.kind === 'text') {
550
+ const v = entity.visual;
551
+ const p = patch.visual;
552
+ const text = go;
553
+ if (typeof p.fontFamily === 'string')
554
+ v.fontFamily = p.fontFamily;
555
+ if (typeof p.fontSize === 'number')
556
+ v.fontSize = p.fontSize;
557
+ if (typeof p.color === 'string')
558
+ v.color = p.color;
559
+ if (typeof p.align === 'string')
560
+ v.align = p.align;
561
+ if (p.source && typeof p.source === 'object') {
562
+ // Wholesale replacement of `source`. Re-subscribe registry binding.
563
+ v.source = p.source;
564
+ const oldCleanup = text.getData('hudCleanup');
565
+ oldCleanup?.();
566
+ const cleanup = subscribeText(text, v.source, hud);
567
+ text.setData('hudCleanup', cleanup);
568
+ }
569
+ text.setStyle({
570
+ fontFamily: v.fontFamily ?? 'sans-serif',
571
+ fontSize: `${v.fontSize ?? 18}px`,
572
+ color: v.color ?? '#ffffff',
573
+ align: v.align ?? 'left',
574
+ });
575
+ text.setText(formatText(v.source, hud));
576
+ }
577
+ if (patch.visual && entity.kind === 'image') {
578
+ const v = entity.visual;
579
+ const p = patch.visual;
580
+ const image = go;
581
+ if (typeof p.tint === 'string') {
582
+ v.tint = p.tint;
583
+ image.setTint(parseColor(v.tint));
584
+ }
585
+ else if (p.tint === null) {
586
+ v.tint = undefined;
587
+ image.clearTint();
588
+ }
589
+ if (typeof p.alpha === 'number') {
590
+ v.alpha = p.alpha;
591
+ image.setAlpha(p.alpha);
592
+ }
593
+ if (typeof p.width === 'number' && typeof p.height === 'number') {
594
+ v.width = p.width;
595
+ v.height = p.height;
596
+ image.setDisplaySize(p.width, p.height);
597
+ }
598
+ }
599
+ if (patch.visual && entity.kind === 'icon-button') {
600
+ // Icon-button is a Container — visual patches require a small refresh.
601
+ // For v1 we just patch the entity record + label/colour fields cheaply;
602
+ // wholesale re-spawn lands in slice 5.5 if we add live re-styling.
603
+ const v = entity.visual;
604
+ const p = patch.visual;
605
+ if (typeof p.label === 'string')
606
+ v.label = p.label;
607
+ if (typeof p.fillColor === 'string')
608
+ v.fillColor = p.fillColor;
609
+ if (typeof p.iconAssetId === 'string')
610
+ v.iconAssetId = p.iconAssetId;
611
+ // Patches that change the rendered visual deeply (label, colour, icon)
612
+ // re-render by destroying + re-spawning the container in place.
613
+ const reg2 = reg;
614
+ const safeArea2 = safeArea;
615
+ const oldDepth = go.depth;
616
+ go.destroy();
617
+ reg2.unregister(entityId);
618
+ const ctx = {
619
+ scene: hud,
620
+ registry: reg2,
621
+ safeArea: safeArea2,
622
+ resolveAsset: (id) => {
623
+ const m = getManifest(hud);
624
+ const a = m.assets?.find((x) => x.id === id);
625
+ if (!a)
626
+ throw new Error(`[unboxy/hud] manifest has no asset with id '${id}'`);
627
+ return a;
628
+ },
629
+ };
630
+ const fresh = spawnHudEntity(ctx, entity);
631
+ if (typeof oldDepth === 'number') {
632
+ fresh.setDepth?.(oldDepth);
633
+ }
634
+ }
635
+ if (patch.role !== undefined) {
636
+ if (patch.role === null)
637
+ entity.role = undefined;
638
+ else
639
+ entity.role = patch.role;
640
+ }
641
+ if (patch.properties)
642
+ entity.properties = { ...(entity.properties ?? {}), ...patch.properties };
643
+ return true;
644
+ }
645
+ /** Spawn a fresh HUD entity into the active HUD scene. Used by editor's create-entity path. */
646
+ export function createHudEntityInScene(game, entity) {
647
+ const hud = game.scene.getScene(UNBOXY_HUD_SCENE_KEY);
648
+ if (!hud)
649
+ return false;
650
+ const reg = getEntityRegistry(hud) ?? attachEntityRegistry(hud);
651
+ const sceneFile = findHudSceneFile(game);
652
+ const safeArea = sceneFile?.design?.safeArea ?? DEFAULT_SAFE_AREA;
653
+ // Mutate the cached scene file so subsequent enter-edit snapshots include
654
+ // the new entity. Persistence is the host's job.
655
+ if (sceneFile)
656
+ sceneFile.entities.push(entity);
657
+ const ctx = {
658
+ scene: hud,
659
+ registry: reg,
660
+ safeArea,
661
+ resolveAsset: (id) => {
662
+ const m = getManifest(hud);
663
+ const a = m.assets?.find((x) => x.id === id);
664
+ if (!a)
665
+ throw new Error(`[unboxy/hud] manifest has no asset with id '${id}'`);
666
+ return a;
667
+ },
668
+ };
669
+ spawnHudEntity(ctx, entity);
670
+ return true;
671
+ }
672
+ /** Destroy a HUD entity. */
673
+ export function deleteHudEntityFromScene(game, entityId) {
674
+ const hud = game.scene.getScene(UNBOXY_HUD_SCENE_KEY);
675
+ if (!hud)
676
+ return false;
677
+ const reg = getEntityRegistry(hud);
678
+ if (!reg)
679
+ return false;
680
+ const go = reg.byId(entityId);
681
+ if (!go)
682
+ return false;
683
+ go.destroy();
684
+ reg.unregister(entityId);
685
+ const sceneFile = findHudSceneFile(game);
686
+ if (sceneFile) {
687
+ const idx = sceneFile.entities.findIndex((e) => e.id === entityId);
688
+ if (idx >= 0)
689
+ sceneFile.entities.splice(idx, 1);
690
+ }
691
+ return true;
692
+ }
693
+ // --- Phaser scene class ----------------------------------------------------
694
+ /**
695
+ * Per-game HUD scene. Auto-launched by `createUnboxyGame` when the world
696
+ * scene's manifest entry sets `hud: '<id>'`. Renders above the world via
697
+ * Phaser's scene rendering order; takes input independently.
698
+ */
699
+ export class UnboxyHudScene extends Phaser.Scene {
700
+ constructor() {
701
+ super({ key: UNBOXY_HUD_SCENE_KEY });
702
+ this.hudId = null;
703
+ }
704
+ init(data) {
705
+ this.hudId = data.hudId ?? null;
706
+ }
707
+ async create() {
708
+ if (!this.hudId)
709
+ return;
710
+ try {
711
+ await loadHudScene(this, this.hudId);
712
+ }
713
+ catch (e) {
714
+ console.warn('[unboxy/hud] loadHudScene failed:', e);
715
+ }
716
+ }
717
+ }