@unboxy/phaser-sdk 0.2.16 → 0.2.18

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,49 @@
1
+ /**
2
+ * Per-game editor state, shared between EditorBridge (postMessage handler)
3
+ * and EditorOverlayScene (canvas renderer + pointer source).
4
+ *
5
+ * Design 03 §13.5: in Edit mode the editor overlay captures clicks before
6
+ * they reach game systems; this state carries the selection + drag-in-progress
7
+ * info both sides read.
8
+ */
9
+ interface EditorStateShape {
10
+ active: boolean;
11
+ selectedId: string | null;
12
+ /**
13
+ * Drag-in-progress info. While set, the overlay scene mutates the entity's
14
+ * x/y directly per pointermove without going through the host. On
15
+ * pointerup it posts dragEnd with before/after, and the host pushes a
16
+ * single command. This is the "SDK holds short-lived visual state" path
17
+ * from the slice-2 design discussion.
18
+ */
19
+ drag: {
20
+ entityId: string;
21
+ startWorld: {
22
+ x: number;
23
+ y: number;
24
+ };
25
+ startEntity: {
26
+ x: number;
27
+ y: number;
28
+ };
29
+ } | null;
30
+ }
31
+ /**
32
+ * Accept any object — we only use a single internal symbol-style key, so
33
+ * there's no risk of collision with Phaser's own props.
34
+ */
35
+ type AnyObject = object;
36
+ export declare function getEditorState(game: AnyObject): EditorStateShape;
37
+ export declare function setEditorActive(game: AnyObject, active: boolean): void;
38
+ export declare function setSelection(game: AnyObject, id: string | null): void;
39
+ export declare function getSelection(game: AnyObject): string | null;
40
+ export declare function startDrag(game: AnyObject, entityId: string, startWorld: {
41
+ x: number;
42
+ y: number;
43
+ }, startEntity: {
44
+ x: number;
45
+ y: number;
46
+ }): void;
47
+ export declare function clearDrag(game: AnyObject): void;
48
+ export declare function getDrag(game: AnyObject): EditorStateShape['drag'];
49
+ export {};
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Per-game editor state, shared between EditorBridge (postMessage handler)
3
+ * and EditorOverlayScene (canvas renderer + pointer source).
4
+ *
5
+ * Design 03 §13.5: in Edit mode the editor overlay captures clicks before
6
+ * they reach game systems; this state carries the selection + drag-in-progress
7
+ * info both sides read.
8
+ */
9
+ const KEY = '__unboxyEditorState';
10
+ function bag(game) {
11
+ return game;
12
+ }
13
+ export function getEditorState(game) {
14
+ const b = bag(game);
15
+ const existing = b[KEY];
16
+ if (existing)
17
+ return existing;
18
+ const fresh = { active: false, selectedId: null, drag: null };
19
+ b[KEY] = fresh;
20
+ return fresh;
21
+ }
22
+ export function setEditorActive(game, active) {
23
+ getEditorState(game).active = active;
24
+ }
25
+ export function setSelection(game, id) {
26
+ getEditorState(game).selectedId = id;
27
+ }
28
+ export function getSelection(game) {
29
+ return getEditorState(game).selectedId;
30
+ }
31
+ export function startDrag(game, entityId, startWorld, startEntity) {
32
+ getEditorState(game).drag = { entityId, startWorld, startEntity };
33
+ }
34
+ export function clearDrag(game) {
35
+ getEditorState(game).drag = null;
36
+ }
37
+ export function getDrag(game) {
38
+ return getEditorState(game).drag;
39
+ }
package/dist/index.d.ts CHANGED
@@ -16,4 +16,15 @@ export type { ChatMessage, ChatMessageKind } from './realtime/UnboxyRoom.js';
16
16
  export { RpcError } from './core/Transport.js';
17
17
  export type { Transport, TransportKind } from './core/Transport.js';
18
18
  export type { UnboxyUser } from './protocol.js';
19
+ export { loadWorldScene, preloadManifest, preloadSceneAssets, getManifest, SCENES_BASE, MANIFEST_PATH, } from './scene/SceneLoader.js';
20
+ export type { LoadWorldSceneOptions } from './scene/SceneLoader.js';
21
+ export { spawnEntity, parseColor } from './scene/spawnEntity.js';
22
+ export type { SpawnContext, AssetResolver, RenderScriptResolver, } from './scene/spawnEntity.js';
23
+ export { EntityRegistry, attachEntityRegistry, getEntityRegistry, } from './scene/EntityRegistry.js';
24
+ export { setupEditorModeListener, isEditMode } from './scene/EditorMode.js';
25
+ export { setupEditorBridge } from './editor/EditorBridge.js';
26
+ export { EditorOverlayScene, EDITOR_OVERLAY_KEY } from './editor/EditorOverlayScene.js';
27
+ export type { EditorEntityPatch, EditorEnterMessage, EditorExitMessage, EditorGetSceneMessage, EditorApplyEditMessage, EditorSetSelectionMessage, EditorPanZoomMessage, EditorHostToSdkMessage, EditorSceneLoadedMessage, EditorSelectionPickedMessage, EditorDragEndMessage, EditorSdkToHostMessage, } from './protocol.js';
28
+ export { SCHEMA_VERSION, } from './scene/types.js';
29
+ export type { Manifest, SceneRef, HudRef, AssetRecord, AssetKind, SceneType, SceneFile, WorldScene, HudScene, WorldSceneConfig, CameraConfig, WorldEntity, WorldEntityKind, NonGroupWorldEntity, SpriteEntity, PrimitiveEntity, CodeRenderedEntity, GroupEntity, TilemapEntity, TriggerEntity, WorldVisual, SpriteVisual, PrimitiveVisual, PrimitiveRectVisual, PrimitiveCircleVisual, CodeRenderedVisual, Transform, Anchor, AnchorSide, } from './scene/types.js';
19
30
  export { PROTOCOL_VERSION, type HelloMessage, type InitMessage, type RpcRequestMessage, type RpcResultOk, type RpcResultError, type HostToSdkMessage, type SdkToHostMessage, type RpcErrorPayload, type RpcMethod, type SavesGetParams, type SavesGetResult, type SavesSetParams, type SavesSetResult, type SavesDeleteParams, type SavesDeleteResult, type SavesListResult, type GameDataGetParams, type GameDataGetResult, type GameDataSetParams, type GameDataSetResult, type GameDataDeleteParams, type GameDataDeleteResult, type GameDataListResult, type RealtimeGetTokenParams, type RealtimeGetTokenResult, } from './protocol.js';
package/dist/index.js CHANGED
@@ -13,4 +13,12 @@ export { GameDataModule } from './gamedata/GameDataModule.js';
13
13
  export { RealtimeModule } from './realtime/RealtimeModule.js';
14
14
  export { UnboxyRoom, PlayerDataFacade, RoomDataFacade, ChatFacade, MAX_CHAT_TEXT_LEN, } from './realtime/UnboxyRoom.js';
15
15
  export { RpcError } from './core/Transport.js';
16
+ // Scene-as-data (visual editor foundation, slice 1)
17
+ export { loadWorldScene, preloadManifest, preloadSceneAssets, getManifest, SCENES_BASE, MANIFEST_PATH, } from './scene/SceneLoader.js';
18
+ export { spawnEntity, parseColor } from './scene/spawnEntity.js';
19
+ export { EntityRegistry, attachEntityRegistry, getEntityRegistry, } from './scene/EntityRegistry.js';
20
+ export { setupEditorModeListener, isEditMode } from './scene/EditorMode.js';
21
+ export { setupEditorBridge } from './editor/EditorBridge.js';
22
+ export { EditorOverlayScene, EDITOR_OVERLAY_KEY } from './editor/EditorOverlayScene.js';
23
+ export { SCHEMA_VERSION, } from './scene/types.js';
16
24
  export { PROTOCOL_VERSION, } from './protocol.js';
@@ -46,6 +46,102 @@ export interface RpcResultError {
46
46
  error: RpcErrorPayload;
47
47
  }
48
48
  export type HostToSdkMessage = InitMessage | RpcResultOk | RpcResultError;
49
+ /**
50
+ * Patch shape applied to a live entity. Only the fields present are updated;
51
+ * `null` is the explicit clear (drops the field). The patch mirrors the slice
52
+ * of the entity schema the editor edits in slice 2 — transform + simple
53
+ * visual fields. Properties / behavior wiring extend this in later slices.
54
+ */
55
+ export interface EditorEntityPatch {
56
+ transform?: Partial<{
57
+ x: number;
58
+ y: number;
59
+ rotation: number;
60
+ scaleX: number;
61
+ scaleY: number;
62
+ depth: number;
63
+ }>;
64
+ visual?: {
65
+ tint?: string | null;
66
+ alpha?: number;
67
+ flipX?: boolean;
68
+ flipY?: boolean;
69
+ frame?: string | number;
70
+ /** For primitives. */
71
+ width?: number;
72
+ height?: number;
73
+ radius?: number;
74
+ fillColor?: string;
75
+ strokeColor?: string | null;
76
+ strokeWidth?: number;
77
+ };
78
+ role?: string | null;
79
+ properties?: Record<string, unknown>;
80
+ }
81
+ export interface EditorEnterMessage {
82
+ type: 'unboxy:editor:enter';
83
+ }
84
+ export interface EditorExitMessage {
85
+ type: 'unboxy:editor:exit';
86
+ }
87
+ export interface EditorGetSceneMessage {
88
+ /** Host requests a snapshot of what's currently loaded in the iframe. */
89
+ type: 'unboxy:editor:getScene';
90
+ }
91
+ export interface EditorApplyEditMessage {
92
+ type: 'unboxy:editor:applyEdit';
93
+ entityId: string;
94
+ patch: EditorEntityPatch;
95
+ }
96
+ export interface EditorSetSelectionMessage {
97
+ type: 'unboxy:editor:setSelection';
98
+ /** Empty array deselects. v1: max length 1 (multi-select is slice 2.5). */
99
+ entityIds: string[];
100
+ }
101
+ export interface EditorPanZoomMessage {
102
+ /** Editor camera control — separate from in-game camera. */
103
+ type: 'unboxy:editor:panZoom';
104
+ scrollX?: number;
105
+ scrollY?: number;
106
+ /** Absolute zoom (1 = 100%). */
107
+ zoom?: number;
108
+ /** If true, deltas are added to current scroll. */
109
+ relative?: boolean;
110
+ }
111
+ export type EditorHostToSdkMessage = EditorEnterMessage | EditorExitMessage | EditorGetSceneMessage | EditorApplyEditMessage | EditorSetSelectionMessage | EditorPanZoomMessage;
112
+ export interface EditorSceneLoadedMessage {
113
+ type: 'unboxy:editor:sceneLoaded';
114
+ sceneId: string;
115
+ /** Snapshot of the world scene file's current entities + camera. */
116
+ sceneFile: unknown;
117
+ }
118
+ export interface EditorSelectionPickedMessage {
119
+ /** Pointer-down on an entity in the canvas — host should update selection. */
120
+ type: 'unboxy:editor:pickEntity';
121
+ entityId: string | null;
122
+ /** Modifier keys at pointer-down so host can decide single/toggle/range. */
123
+ modifiers: {
124
+ shift: boolean;
125
+ cmdOrCtrl: boolean;
126
+ alt: boolean;
127
+ };
128
+ }
129
+ export interface EditorDragEndMessage {
130
+ /** Pointer-up after dragging an entity. delta is total movement in world coords. */
131
+ type: 'unboxy:editor:dragEnd';
132
+ entityId: string;
133
+ /** Position before drag started. */
134
+ before: {
135
+ x: number;
136
+ y: number;
137
+ };
138
+ /** Position at release. */
139
+ after: {
140
+ x: number;
141
+ y: number;
142
+ };
143
+ }
144
+ export type EditorSdkToHostMessage = EditorSceneLoadedMessage | EditorSelectionPickedMessage | EditorDragEndMessage;
49
145
  export type RpcMethod = 'saves.get' | 'saves.set' | 'saves.delete' | 'saves.list' | 'gameData.get' | 'gameData.set' | 'gameData.delete' | 'gameData.list' | 'realtime.getToken';
50
146
  export interface SavesGetParams {
51
147
  key: string;
@@ -0,0 +1,17 @@
1
+ import Phaser from 'phaser';
2
+ /**
3
+ * Slice-1 entry point that became a thin wrapper over the slice-2 editor
4
+ * bridge. The bridge owns the full edit-mode lifecycle (pause world scenes,
5
+ * launch overlay, handle pointer events, route postMessage commands).
6
+ *
7
+ * Wire shape (host → SDK):
8
+ * { type: 'unboxy:editor:enter' } - enter edit mode
9
+ * { type: 'unboxy:editor:exit' } - exit edit mode
10
+ * ...plus the rest of the editor protocol — see protocol.ts
11
+ *
12
+ * The slice-1 `unboxy:setEditMode` message is no longer accepted; home-ui
13
+ * has been updated to send the editor:enter / editor:exit pair as part of
14
+ * the slice-2 work.
15
+ */
16
+ export declare function setupEditorModeListener(game: Phaser.Game): void;
17
+ export declare function isEditMode(game: Phaser.Game): boolean;
@@ -0,0 +1,22 @@
1
+ import { setupEditorBridge } from '../editor/EditorBridge.js';
2
+ import { getEditorState } from '../editor/EditorState.js';
3
+ /**
4
+ * Slice-1 entry point that became a thin wrapper over the slice-2 editor
5
+ * bridge. The bridge owns the full edit-mode lifecycle (pause world scenes,
6
+ * launch overlay, handle pointer events, route postMessage commands).
7
+ *
8
+ * Wire shape (host → SDK):
9
+ * { type: 'unboxy:editor:enter' } - enter edit mode
10
+ * { type: 'unboxy:editor:exit' } - exit edit mode
11
+ * ...plus the rest of the editor protocol — see protocol.ts
12
+ *
13
+ * The slice-1 `unboxy:setEditMode` message is no longer accepted; home-ui
14
+ * has been updated to send the editor:enter / editor:exit pair as part of
15
+ * the slice-2 work.
16
+ */
17
+ export function setupEditorModeListener(game) {
18
+ setupEditorBridge(game);
19
+ }
20
+ export function isEditMode(game) {
21
+ return getEditorState(game).active;
22
+ }
@@ -0,0 +1,20 @@
1
+ import Phaser from 'phaser';
2
+ /**
3
+ * Per-scene lookup of spawned entities. Behavior code uses this to find
4
+ * the player, enemies, doors, etc. by id or role without coupling to
5
+ * scene-file structure.
6
+ *
7
+ * Created by `loadWorldScene` and stashed on `scene.data` under the key
8
+ * `'unboxyEntityRegistry'`. Access via `getEntityRegistry(scene)`.
9
+ */
10
+ export declare class EntityRegistry {
11
+ private byIdMap;
12
+ private byRoleMap;
13
+ register(id: string, role: string | undefined, go: Phaser.GameObjects.GameObject): void;
14
+ byId(id: string): Phaser.GameObjects.GameObject | undefined;
15
+ byRole(role: string): Phaser.GameObjects.GameObject[];
16
+ all(): Phaser.GameObjects.GameObject[];
17
+ clear(): void;
18
+ }
19
+ export declare function attachEntityRegistry(scene: Phaser.Scene): EntityRegistry;
20
+ export declare function getEntityRegistry(scene: Phaser.Scene): EntityRegistry | undefined;
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Per-scene lookup of spawned entities. Behavior code uses this to find
3
+ * the player, enemies, doors, etc. by id or role without coupling to
4
+ * scene-file structure.
5
+ *
6
+ * Created by `loadWorldScene` and stashed on `scene.data` under the key
7
+ * `'unboxyEntityRegistry'`. Access via `getEntityRegistry(scene)`.
8
+ */
9
+ export class EntityRegistry {
10
+ constructor() {
11
+ this.byIdMap = new Map();
12
+ this.byRoleMap = new Map();
13
+ }
14
+ register(id, role, go) {
15
+ this.byIdMap.set(id, go);
16
+ if (role) {
17
+ const list = this.byRoleMap.get(role) ?? [];
18
+ list.push(go);
19
+ this.byRoleMap.set(role, list);
20
+ }
21
+ }
22
+ byId(id) {
23
+ return this.byIdMap.get(id);
24
+ }
25
+ byRole(role) {
26
+ return this.byRoleMap.get(role) ?? [];
27
+ }
28
+ all() {
29
+ return Array.from(this.byIdMap.values());
30
+ }
31
+ clear() {
32
+ this.byIdMap.clear();
33
+ this.byRoleMap.clear();
34
+ }
35
+ }
36
+ const REGISTRY_KEY = 'unboxyEntityRegistry';
37
+ export function attachEntityRegistry(scene) {
38
+ const existing = scene.data?.get(REGISTRY_KEY);
39
+ if (existing) {
40
+ existing.clear();
41
+ return existing;
42
+ }
43
+ const registry = new EntityRegistry();
44
+ // Phaser auto-creates DataManager on first .data access if absent.
45
+ scene.data.set(REGISTRY_KEY, registry);
46
+ return registry;
47
+ }
48
+ export function getEntityRegistry(scene) {
49
+ return scene.data?.get(REGISTRY_KEY);
50
+ }
@@ -0,0 +1,59 @@
1
+ import Phaser from 'phaser';
2
+ import { Manifest, SceneFile, WorldScene } from './types.js';
3
+ import { RenderScriptResolver } from './spawnEntity.js';
4
+ import { EntityRegistry } from './EntityRegistry.js';
5
+ /**
6
+ * Where scene files live in the workspace and at runtime. The Vite build
7
+ * copies `public/` to the served root, so paths are origin-relative
8
+ * (`scenes/manifest.json`). No leading slash — the iframe is sometimes
9
+ * served under a sub-path (`/preview/:gameId/`).
10
+ */
11
+ export declare const SCENES_BASE = "scenes/";
12
+ export declare const MANIFEST_PATH = "scenes/manifest.json";
13
+ /**
14
+ * Fetches the manifest via Phaser's loader. Must be called from a Phaser
15
+ * scene's `preload()` since it queues a load request. Subsequent calls
16
+ * (e.g. on scene transitions) hit the Phaser JSON cache.
17
+ *
18
+ * Resolves once Phaser fires `complete` for the load batch — callers
19
+ * typically await this in `create()` after `this.load.start()` is done.
20
+ */
21
+ export declare function preloadManifest(scene: Phaser.Scene): void;
22
+ /**
23
+ * Read the manifest from Phaser's JSON cache. Throws if `preloadManifest`
24
+ * hasn't completed first.
25
+ */
26
+ export declare function getManifest(scene: Phaser.Scene): Manifest;
27
+ /**
28
+ * Walk a scene file's entities and queue Phaser loads for any assets that
29
+ * aren't already in the texture cache. Caller is responsible for awaiting
30
+ * the loader (`scene.load.once('complete', ...)` or do it in `preload()`).
31
+ *
32
+ * Idempotent across scenes — once an asset is loaded, switching to another
33
+ * scene that uses it is free.
34
+ */
35
+ export declare function preloadSceneAssets(scene: Phaser.Scene, sceneFile: SceneFile, manifest: Manifest): void;
36
+ export interface LoadWorldSceneOptions {
37
+ /**
38
+ * Optional render-script resolver for `code-rendered` entities. When
39
+ * absent, the loader throws on code-rendered entities (slice 1).
40
+ */
41
+ resolveRenderScript?: RenderScriptResolver;
42
+ }
43
+ /**
44
+ * One-shot async loader: fetch scene JSON (if not already cached), lazy
45
+ * preload its assets, spawn entities, configure camera, and return the
46
+ * EntityRegistry. Idempotent re-loads of the same scene id reuse the
47
+ * Phaser JSON cache.
48
+ *
49
+ * Pattern (in `GameScene.create()`):
50
+ *
51
+ * ```ts
52
+ * const result = await loadWorldScene(this, sceneId);
53
+ * // entities live; behavior code can use result.registry.byRole('player')
54
+ * ```
55
+ */
56
+ export declare function loadWorldScene(scene: Phaser.Scene, sceneId: string, options?: LoadWorldSceneOptions): Promise<{
57
+ sceneFile: WorldScene;
58
+ registry: EntityRegistry;
59
+ }>;
@@ -0,0 +1,259 @@
1
+ import Phaser from 'phaser';
2
+ import { SCHEMA_VERSION, } from './types.js';
3
+ import { spawnEntity } from './spawnEntity.js';
4
+ import { attachEntityRegistry } from './EntityRegistry.js';
5
+ /**
6
+ * Where scene files live in the workspace and at runtime. The Vite build
7
+ * copies `public/` to the served root, so paths are origin-relative
8
+ * (`scenes/manifest.json`). No leading slash — the iframe is sometimes
9
+ * served under a sub-path (`/preview/:gameId/`).
10
+ */
11
+ export const SCENES_BASE = 'scenes/';
12
+ export const MANIFEST_PATH = `${SCENES_BASE}manifest.json`;
13
+ const MANIFEST_CACHE_KEY = '__unboxyManifestState';
14
+ /**
15
+ * Fetches the manifest via Phaser's loader. Must be called from a Phaser
16
+ * scene's `preload()` since it queues a load request. Subsequent calls
17
+ * (e.g. on scene transitions) hit the Phaser JSON cache.
18
+ *
19
+ * Resolves once Phaser fires `complete` for the load batch — callers
20
+ * typically await this in `create()` after `this.load.start()` is done.
21
+ */
22
+ export function preloadManifest(scene) {
23
+ if (!scene.cache.json.exists('unboxy:manifest')) {
24
+ scene.load.json('unboxy:manifest', MANIFEST_PATH);
25
+ }
26
+ }
27
+ /**
28
+ * Read the manifest from Phaser's JSON cache. Throws if `preloadManifest`
29
+ * hasn't completed first.
30
+ */
31
+ export function getManifest(scene) {
32
+ const raw = scene.cache.json.get('unboxy:manifest');
33
+ if (!raw) {
34
+ throw new Error("[unboxy/scene] manifest not loaded — call preloadManifest(scene) in BootScene.preload() and wait for the loader's 'complete' event");
35
+ }
36
+ const manifest = raw;
37
+ validateManifest(manifest);
38
+ return manifest;
39
+ }
40
+ function validateManifest(m) {
41
+ if (m.schemaVersion !== SCHEMA_VERSION) {
42
+ throw new Error(`[unboxy/scene] manifest schemaVersion ${m.schemaVersion} but SDK expects ${SCHEMA_VERSION}`);
43
+ }
44
+ if (!m.scenes?.length) {
45
+ throw new Error('[unboxy/scene] manifest has no scenes');
46
+ }
47
+ if (!m.scenes.find((s) => s.id === m.initialScene)) {
48
+ throw new Error(`[unboxy/scene] manifest.initialScene '${m.initialScene}' is not in scenes[]`);
49
+ }
50
+ }
51
+ function getOrInitState(scene, manifest) {
52
+ const game = scene.game;
53
+ const existing = game[MANIFEST_CACHE_KEY];
54
+ if (existing && existing.manifest === manifest)
55
+ return existing;
56
+ const assetsById = new Map();
57
+ for (const a of manifest.assets ?? [])
58
+ assetsById.set(a.id, a);
59
+ const state = {
60
+ manifest,
61
+ assetsById,
62
+ requestedAssetIds: new Set(),
63
+ };
64
+ game[MANIFEST_CACHE_KEY] = state;
65
+ return state;
66
+ }
67
+ // --- Lazy asset preload ----------------------------------------------------
68
+ /**
69
+ * Walk a scene file's entities and queue Phaser loads for any assets that
70
+ * aren't already in the texture cache. Caller is responsible for awaiting
71
+ * the loader (`scene.load.once('complete', ...)` or do it in `preload()`).
72
+ *
73
+ * Idempotent across scenes — once an asset is loaded, switching to another
74
+ * scene that uses it is free.
75
+ */
76
+ export function preloadSceneAssets(scene, sceneFile, manifest) {
77
+ if (sceneFile.type !== 'world')
78
+ return; // HUD slice will add its own walker
79
+ const state = getOrInitState(scene, manifest);
80
+ const ids = collectAssetIds(sceneFile.entities);
81
+ for (const id of ids) {
82
+ if (state.requestedAssetIds.has(id))
83
+ continue;
84
+ const asset = state.assetsById.get(id);
85
+ if (!asset) {
86
+ throw new Error(`[unboxy/scene] scene '${sceneFile.id}' references assetId '${id}' but the manifest has no such asset`);
87
+ }
88
+ queueAssetLoad(scene, asset);
89
+ state.requestedAssetIds.add(id);
90
+ }
91
+ }
92
+ function collectAssetIds(entities) {
93
+ const ids = new Set();
94
+ function walk(e) {
95
+ if (e.kind === 'sprite') {
96
+ ids.add(e.visual.assetId);
97
+ }
98
+ else if (e.kind === 'group') {
99
+ for (const child of e.children)
100
+ walk(child);
101
+ }
102
+ // primitive / code-rendered / tilemap / trigger don't reference assets
103
+ // (yet — tilemap/trigger are deferred slices).
104
+ }
105
+ for (const e of entities)
106
+ walk(e);
107
+ return Array.from(ids);
108
+ }
109
+ function queueAssetLoad(scene, asset) {
110
+ if (scene.textures.exists(asset.textureKey))
111
+ return;
112
+ switch (asset.kind) {
113
+ case 'image':
114
+ scene.load.image(asset.textureKey, asset.path);
115
+ return;
116
+ case 'spritesheet':
117
+ if (!asset.spriteSheetConfig) {
118
+ throw new Error(`[unboxy/scene] asset '${asset.id}' kind=spritesheet missing spriteSheetConfig`);
119
+ }
120
+ scene.load.spritesheet(asset.textureKey, asset.path, asset.spriteSheetConfig);
121
+ return;
122
+ case 'atlas':
123
+ if (!asset.atlasPath || !asset.atlasFormat) {
124
+ throw new Error(`[unboxy/scene] asset '${asset.id}' kind=atlas missing atlasPath/atlasFormat`);
125
+ }
126
+ if (asset.atlasFormat === 'xml') {
127
+ scene.load.atlasXML(asset.textureKey, asset.path, asset.atlasPath);
128
+ }
129
+ else {
130
+ scene.load.atlas(asset.textureKey, asset.path, asset.atlasPath);
131
+ }
132
+ return;
133
+ case 'audio':
134
+ scene.load.audio(asset.textureKey, asset.path);
135
+ return;
136
+ case 'json':
137
+ scene.load.json(asset.textureKey, asset.path);
138
+ return;
139
+ default: {
140
+ const exhaustive = asset.kind;
141
+ throw new Error(`[unboxy/scene] unknown asset kind: ${JSON.stringify(exhaustive)}`);
142
+ }
143
+ }
144
+ }
145
+ /**
146
+ * One-shot async loader: fetch scene JSON (if not already cached), lazy
147
+ * preload its assets, spawn entities, configure camera, and return the
148
+ * EntityRegistry. Idempotent re-loads of the same scene id reuse the
149
+ * Phaser JSON cache.
150
+ *
151
+ * Pattern (in `GameScene.create()`):
152
+ *
153
+ * ```ts
154
+ * const result = await loadWorldScene(this, sceneId);
155
+ * // entities live; behavior code can use result.registry.byRole('player')
156
+ * ```
157
+ */
158
+ export async function loadWorldScene(scene, sceneId, options = {}) {
159
+ const manifest = getManifest(scene);
160
+ const ref = manifest.scenes.find((s) => s.id === sceneId);
161
+ if (!ref)
162
+ throw new Error(`[unboxy/scene] manifest has no scene with id '${sceneId}'`);
163
+ if (ref.type !== 'world') {
164
+ throw new Error(`[unboxy/scene] scene '${sceneId}' is type=${ref.type}; loadWorldScene expects type=world`);
165
+ }
166
+ const sceneFile = (await loadSceneJson(scene, ref));
167
+ if (sceneFile.schemaVersion !== SCHEMA_VERSION) {
168
+ throw new Error(`[unboxy/scene] scene '${sceneId}' schemaVersion ${sceneFile.schemaVersion} but SDK expects ${SCHEMA_VERSION}`);
169
+ }
170
+ if (sceneFile.type !== 'world') {
171
+ throw new Error(`[unboxy/scene] scene '${sceneId}' has type=${sceneFile.type} in file body`);
172
+ }
173
+ // Lazy preload of any new assets this scene needs, then await loader.
174
+ preloadSceneAssets(scene, sceneFile, manifest);
175
+ await runLoader(scene);
176
+ // Spawn entities.
177
+ const registry = attachEntityRegistry(scene);
178
+ const ctx = {
179
+ scene,
180
+ registry,
181
+ resolveAsset: (id) => resolveAsset(manifest, id),
182
+ resolveRenderScript: options.resolveRenderScript,
183
+ };
184
+ for (const entity of sceneFile.entities)
185
+ spawnEntity(ctx, entity);
186
+ // Configure camera.
187
+ applyCamera(scene, sceneFile, registry);
188
+ return { sceneFile, registry };
189
+ }
190
+ async function loadSceneJson(scene, ref) {
191
+ const cacheKey = `unboxy:scene:${ref.id}`;
192
+ if (scene.cache.json.exists(cacheKey)) {
193
+ return scene.cache.json.get(cacheKey);
194
+ }
195
+ scene.load.json(cacheKey, `${SCENES_BASE}${ref.file}`);
196
+ await runLoader(scene);
197
+ return scene.cache.json.get(cacheKey);
198
+ }
199
+ /**
200
+ * Phaser's `load` queue is fire-and-forget; this wraps it in a promise.
201
+ * If the loader is already idle and has nothing queued, resolves
202
+ * immediately on next tick.
203
+ */
204
+ function runLoader(scene) {
205
+ return new Promise((resolve, reject) => {
206
+ if (!scene.load.isLoading() && scene.load.totalToLoad === 0) {
207
+ // Nothing to do — but `totalToLoad` resets to 0 on each `start()`
208
+ // so check whether anything was queued since last start.
209
+ if (scene.load.list.size === 0) {
210
+ queueMicrotask(resolve);
211
+ return;
212
+ }
213
+ }
214
+ scene.load.once(Phaser.Loader.Events.COMPLETE, () => resolve());
215
+ scene.load.once(Phaser.Loader.Events.FILE_LOAD_ERROR, (file) => {
216
+ reject(new Error(`[unboxy/scene] loader failed: ${file.key} (${file.url})`));
217
+ });
218
+ scene.load.start();
219
+ });
220
+ }
221
+ function resolveAsset(manifest, assetId) {
222
+ const asset = manifest.assets?.find((a) => a.id === assetId);
223
+ if (!asset) {
224
+ throw new Error(`[unboxy/scene] manifest has no asset with id '${assetId}'`);
225
+ }
226
+ return asset;
227
+ }
228
+ function applyCamera(scene, sceneFile, registry) {
229
+ const cam = scene.cameras.main;
230
+ const cfg = sceneFile.camera ?? {};
231
+ // World bounds — default to scene world dims if not specified.
232
+ const bounds = cfg.bounds ?? { x: 0, y: 0, width: sceneFile.world.width, height: sceneFile.world.height };
233
+ cam.setBounds(bounds.x, bounds.y, bounds.width, bounds.height);
234
+ if (typeof cfg.zoom === 'number')
235
+ cam.setZoom(cfg.zoom);
236
+ if (cfg.pixelPerfect)
237
+ cam.setRoundPixels(true);
238
+ if (sceneFile.world.background?.color) {
239
+ cam.setBackgroundColor(sceneFile.world.background.color);
240
+ }
241
+ if (cfg.follow) {
242
+ const target = registry.byId(cfg.follow);
243
+ if (target) {
244
+ const lerp = typeof cfg.smoothing === 'number' ? cfg.smoothing : 1;
245
+ cam.startFollow(target, true, lerp, lerp);
246
+ if (cfg.deadzone)
247
+ cam.setDeadzone(cfg.deadzone.x * 2, cfg.deadzone.y * 2);
248
+ if (typeof cfg.lookahead === 'number') {
249
+ cam.setFollowOffset(-cfg.lookahead, 0);
250
+ }
251
+ }
252
+ else {
253
+ // Soft warning — behavior code might create the follow target later.
254
+ // Surfacing a console.warn keeps the runtime running while flagging
255
+ // the data inconsistency to whoever is debugging.
256
+ console.warn(`[unboxy/scene] camera.follow id='${cfg.follow}' has no matching entity in scene '${sceneFile.id}'`);
257
+ }
258
+ }
259
+ }