@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.
- package/dist/core/UnboxyGame.js +6 -1
- package/dist/editor/EditorBridge.js +113 -44
- package/dist/editor/EditorOverlayScene.d.ts +7 -2
- package/dist/editor/EditorOverlayScene.js +67 -16
- package/dist/editor/EditorState.d.ts +9 -0
- package/dist/editor/EditorState.js +7 -1
- package/dist/index.d.ts +2 -2
- package/dist/protocol.d.ts +55 -3
- package/dist/scene/EntityRegistry.d.ts +3 -0
- package/dist/scene/EntityRegistry.js +16 -0
- package/dist/scene/HudRuntime.d.ts +125 -0
- package/dist/scene/HudRuntime.js +717 -0
- package/dist/scene/SceneLoader.js +9 -0
- package/dist/scene/types.d.ts +101 -4
- package/package.json +1 -1
|
@@ -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
|
+
}
|