@unboxy/phaser-sdk 0.2.16 → 0.2.17
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/SDK-GUIDE.md +83 -0
- package/dist/core/UnboxyGame.js +2 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +6 -0
- package/dist/scene/EditorMode.d.ts +3 -0
- package/dist/scene/EditorMode.js +57 -0
- package/dist/scene/EntityRegistry.d.ts +20 -0
- package/dist/scene/EntityRegistry.js +50 -0
- package/dist/scene/SceneLoader.d.ts +59 -0
- package/dist/scene/SceneLoader.js +259 -0
- package/dist/scene/spawnEntity.d.ts +30 -0
- package/dist/scene/spawnEntity.js +136 -0
- package/dist/scene/types.d.ts +237 -0
- package/dist/scene/types.js +10 -0
- package/package.json +3 -2
package/SDK-GUIDE.md
CHANGED
|
@@ -544,6 +544,88 @@ Don't blindly apply interpolation to everything. It's wrong for a chess piece an
|
|
|
544
544
|
- Unsubscribe on scene shutdown. `onStateChange`, `on`, `onLeave`, `onError` all return an unsubscribe function — call them from `scene.events.once('shutdown', ...)`.
|
|
545
545
|
- One Colyseus client is kept internally; repeated `joinOrCreate` calls reuse it and mint fresh tokens each connection.
|
|
546
546
|
|
|
547
|
+
## Scene-as-data (visual editor foundation, since 0.2.17)
|
|
548
|
+
|
|
549
|
+
Games that ship with `public/scenes/manifest.json` load layout from JSON instead of hardcoding `this.add.sprite(x, y, ...)` in scene classes. This split is what lets the visual editor edit a game without touching code.
|
|
550
|
+
|
|
551
|
+
**File layout:**
|
|
552
|
+
```
|
|
553
|
+
src/scenes/
|
|
554
|
+
BootScene.ts — preloads manifest, draws loading bar, starts GameScene
|
|
555
|
+
GameScene.ts — generic loader: await loadWorldScene(this, sceneId)
|
|
556
|
+
public/scenes/
|
|
557
|
+
manifest.json — scenes list + asset table + initial scene id
|
|
558
|
+
world/<scene>.json — one world scene per file (entities, camera, world dims)
|
|
559
|
+
```
|
|
560
|
+
|
|
561
|
+
**Manifest shape:**
|
|
562
|
+
```json
|
|
563
|
+
{
|
|
564
|
+
"schemaVersion": 1,
|
|
565
|
+
"id": "my-game", "title": "My Game", "version": "1.0.0",
|
|
566
|
+
"initialScene": "main",
|
|
567
|
+
"scenes": [{ "id": "main", "type": "world", "file": "world/main.json", "hud": null }],
|
|
568
|
+
"huds": [],
|
|
569
|
+
"assets": [
|
|
570
|
+
{ "id": "knight", "textureKey": "knight", "path": "uploaded/knight.png", "kind": "image" },
|
|
571
|
+
{ "id": "tiles", "textureKey": "tiles", "path": "uploaded/tiles.png",
|
|
572
|
+
"kind": "spritesheet",
|
|
573
|
+
"spriteSheetConfig": { "frameWidth": 16, "frameHeight": 16 } }
|
|
574
|
+
]
|
|
575
|
+
}
|
|
576
|
+
```
|
|
577
|
+
|
|
578
|
+
The `assets[]` table is the single source of truth for asset loading in scene-as-data games. Replaces hand-rolled `BootScene.preload()` lines: `loadWorldScene` queues the right `this.load.*` call based on each asset's `kind` and config (lazy — only assets the active scene uses).
|
|
579
|
+
|
|
580
|
+
**Entity shape (world scenes):**
|
|
581
|
+
```json
|
|
582
|
+
{
|
|
583
|
+
"id": "player",
|
|
584
|
+
"kind": "sprite",
|
|
585
|
+
"role": "player",
|
|
586
|
+
"transform": { "x": 200, "y": 400, "rotation": 0, "scaleX": 1, "scaleY": 1 },
|
|
587
|
+
"visual": { "kind": "sprite", "assetId": "knight" },
|
|
588
|
+
"properties": { "maxHp": 3, "speed": 200 }
|
|
589
|
+
}
|
|
590
|
+
```
|
|
591
|
+
|
|
592
|
+
`kind` is the entity-kind discriminator (`sprite` / `primitive` / `group` / `code-rendered` / `tilemap` / `trigger` — last three not in v1). `visual.kind` is the visual variant (`sprite` / `rect` / `circle` / `code-rendered`). They are separate fields with separate enums.
|
|
593
|
+
|
|
594
|
+
**API:**
|
|
595
|
+
```ts
|
|
596
|
+
import { preloadManifest, getManifest, loadWorldScene, getEntityRegistry } from '@unboxy/phaser-sdk';
|
|
597
|
+
|
|
598
|
+
class BootScene extends Phaser.Scene {
|
|
599
|
+
preload() { preloadManifest(this); /* + draw loading bar */ }
|
|
600
|
+
create() {
|
|
601
|
+
const manifest = getManifest(this);
|
|
602
|
+
this.scene.start('GameScene', { sceneId: manifest.initialScene });
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
class GameScene extends Phaser.Scene {
|
|
607
|
+
private sceneId!: string;
|
|
608
|
+
init(data: { sceneId: string }) { this.sceneId = data.sceneId; }
|
|
609
|
+
async create() {
|
|
610
|
+
await loadWorldScene(this, this.sceneId); // spawns entities, applies camera
|
|
611
|
+
const registry = getEntityRegistry(this)!;
|
|
612
|
+
const player = registry.byRole('player')[0] as Phaser.GameObjects.Sprite;
|
|
613
|
+
// ...wire physics + input on `player`...
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
```
|
|
617
|
+
|
|
618
|
+
**Behavior conventions:**
|
|
619
|
+
- Layout (positions, asset references, world dims, camera bounds) lives in scene JSON.
|
|
620
|
+
- Behavior (physics setup, input handlers, update loop, tweens) lives in `GameScene.ts`.
|
|
621
|
+
- Look entities up by `role` (`registry.byRole('player')`) — portable across scenes. By id (`registry.byId('boss-1')`) when you need a specific one.
|
|
622
|
+
- Don't hand-write `this.load.image(...)` lines for game assets — declare them in `manifest.assets[]`.
|
|
623
|
+
- Don't call `this.add.sprite(...)` for game entities in code — declare them in scene JSON.
|
|
624
|
+
|
|
625
|
+
The `scene-data-architecture` agent skill has the full guidance. The `scene-data-migration` skill walks an existing scene-as-code game through the conversion.
|
|
626
|
+
|
|
627
|
+
**Edit mode (host-driven):** when the host (home-ui) sends `{ type: 'unboxy:setEditMode', enabled: true }` via `postMessage`, the SDK pauses every active non-Boot Phaser scene — entities stay rendered but `update`/physics/tweens stop. Sending `enabled: false` resumes. Used by the visual editor's read-only viewer in slice 1.
|
|
628
|
+
|
|
547
629
|
## Anti-patterns (don't do these)
|
|
548
630
|
|
|
549
631
|
- Do **not** call `Unboxy.init()` inside a scene. Initialize at module load in `main.ts` and export the promise.
|
|
@@ -556,6 +638,7 @@ Don't blindly apply interpolation to everything. It's wrong for a chess piece an
|
|
|
556
638
|
|
|
557
639
|
## Changelog
|
|
558
640
|
|
|
641
|
+
- **0.2.17** — added scene-as-data foundation (visual editor slice 1). New exports: `loadWorldScene`, `preloadManifest`, `getManifest`, `preloadSceneAssets`, `spawnEntity`, `EntityRegistry`, `attachEntityRegistry`, `getEntityRegistry`, `setupEditorModeListener`, `isEditMode`, `parseColor`, `SCHEMA_VERSION`, plus all schema types (`Manifest`, `WorldScene`, `WorldEntity`, `Transform`, `AssetRecord`, etc.). New games that ship with `public/scenes/manifest.json` load entities + camera config from JSON; `GameScene.ts` becomes a generic loader that calls `await loadWorldScene(this, sceneId)`. `createUnboxyGame` now also wires `setupEditorModeListener` so the host (home-ui) can pause active scenes via `postMessage({ type: 'unboxy:setEditMode', enabled: boolean })`. Purely additive — existing scene-as-code games are unaffected. Migration to scene-as-data is opt-in via the `scene-data-migration` agent skill.
|
|
559
642
|
- **0.2.16** — added orientation presets. `createUnboxyGame` now accepts an `orientation: 'portrait' | 'landscape'` option as an alternative to explicit `width`/`height` (TS union — pass one or the other). New exports: `Orientation` type and `ORIENTATION_DIMENSIONS` map (`landscape: 1280×720`, `portrait: 720×1280`). Lets games declare orientation once and have a single source of truth for canvas dimensions.
|
|
560
643
|
- **0.2.15** — fix: `ChatFacade.subscribeToPlayers` early-returned when `state.players` was `undefined` at construction time, which it always is on a fresh `joinOrCreate` (Colyseus delivers initial state as a separate message a few ms later). The early return meant `onStateChange` never subscribed and `system.joined` / `system.left` stayed silent in production despite the 0.2.14 callback-API fix. Now: always subscribe; treat the first state change as initial hydration (silent), subsequent changes do real diff. Three regression tests cover the deferred-hydration flow.
|
|
561
644
|
- **0.2.14** — fix: `ChatFacade` `system.joined` / `system.left` events were silently no-op'ing in production. The lifecycle subscription used `state.players.onAdd` / `.onRemove` instance methods that Colyseus 0.16 removed in favour of `getStateCallbacks(room)`; the runtime check `typeof players.onAdd === 'function'` evaluated false, so neither system message fired. Now uses `room.onStateChange` + a known-names diff — robust to future Colyseus API changes. Tests updated to match. Patch-only; no API change for game code.
|
package/dist/core/UnboxyGame.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import Phaser from 'phaser';
|
|
2
2
|
import { setupScreenshotListener } from '../screenshot/ScreenshotManager.js';
|
|
3
3
|
import { setupRecordingListener } from '../recording/RecordingManager.js';
|
|
4
|
+
import { setupEditorModeListener } from '../scene/EditorMode.js';
|
|
4
5
|
import { ORIENTATION_DIMENSIONS } from '../orientation.js';
|
|
5
6
|
/**
|
|
6
7
|
* Create an Unboxy-enhanced Phaser game instance.
|
|
@@ -34,5 +35,6 @@ export function createUnboxyGame(options) {
|
|
|
34
35
|
// Built-in integrations
|
|
35
36
|
setupScreenshotListener(game);
|
|
36
37
|
setupRecordingListener(game);
|
|
38
|
+
setupEditorModeListener(game);
|
|
37
39
|
return game;
|
|
38
40
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -16,4 +16,12 @@ 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 { SCHEMA_VERSION, } from './scene/types.js';
|
|
26
|
+
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
27
|
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,10 @@ 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 { SCHEMA_VERSION, } from './scene/types.js';
|
|
16
22
|
export { PROTOCOL_VERSION, } from './protocol.js';
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Edit mode = pause every active world/HUD scene so the canvas freezes
|
|
3
|
+
* but stays rendered. The visual editor uses this for slice 1's
|
|
4
|
+
* read-only viewer; later slices add overlay/gizmo layers on top.
|
|
5
|
+
*
|
|
6
|
+
* Wire shape (host → SDK only — no reply expected):
|
|
7
|
+
* { type: 'unboxy:setEditMode', enabled: boolean }
|
|
8
|
+
*
|
|
9
|
+
* Boot scenes are skipped — pausing them stalls the loader.
|
|
10
|
+
*/
|
|
11
|
+
const BOOT_SCENE_KEYS = new Set(['BootScene', 'Boot']);
|
|
12
|
+
const EDIT_MODE_FLAG = '__unboxyEditMode';
|
|
13
|
+
export function setupEditorModeListener(game) {
|
|
14
|
+
window.addEventListener('message', (event) => {
|
|
15
|
+
const data = event.data;
|
|
16
|
+
if (!data || typeof data !== 'object')
|
|
17
|
+
return;
|
|
18
|
+
if (data.type !== 'unboxy:setEditMode')
|
|
19
|
+
return;
|
|
20
|
+
const enabled = !!data.enabled;
|
|
21
|
+
if (enabled)
|
|
22
|
+
enterEditMode(game);
|
|
23
|
+
else
|
|
24
|
+
exitEditMode(game);
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
export function isEditMode(game) {
|
|
28
|
+
return !!game[EDIT_MODE_FLAG];
|
|
29
|
+
}
|
|
30
|
+
function enterEditMode(game) {
|
|
31
|
+
const flagged = game;
|
|
32
|
+
if (flagged[EDIT_MODE_FLAG])
|
|
33
|
+
return;
|
|
34
|
+
flagged[EDIT_MODE_FLAG] = true;
|
|
35
|
+
for (const scene of game.scene.getScenes(true)) {
|
|
36
|
+
const key = scene.scene.key;
|
|
37
|
+
if (BOOT_SCENE_KEYS.has(key))
|
|
38
|
+
continue;
|
|
39
|
+
// Phaser's pause() stops update + physics + tweens but leaves the
|
|
40
|
+
// scene rendered — exactly what the read-only viewer wants.
|
|
41
|
+
if (scene.scene.isActive())
|
|
42
|
+
scene.scene.pause();
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function exitEditMode(game) {
|
|
46
|
+
const flagged = game;
|
|
47
|
+
if (!flagged[EDIT_MODE_FLAG])
|
|
48
|
+
return;
|
|
49
|
+
flagged[EDIT_MODE_FLAG] = false;
|
|
50
|
+
for (const scene of game.scene.getScenes(false)) {
|
|
51
|
+
const key = scene.scene.key;
|
|
52
|
+
if (BOOT_SCENE_KEYS.has(key))
|
|
53
|
+
continue;
|
|
54
|
+
if (scene.scene.isPaused())
|
|
55
|
+
scene.scene.resume();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import Phaser from 'phaser';
|
|
2
|
+
import { AssetRecord, WorldEntity } from './types.js';
|
|
3
|
+
import { EntityRegistry } from './EntityRegistry.js';
|
|
4
|
+
/**
|
|
5
|
+
* Resolves an asset id to its AssetRecord, throwing a clear error if the
|
|
6
|
+
* scene file references something the manifest doesn't know about. We
|
|
7
|
+
* surface this as an error (not a silent miss) because a missing asset
|
|
8
|
+
* means either the manifest is stale or the scene file was hand-edited
|
|
9
|
+
* — both worth catching at runtime, not skipping silently.
|
|
10
|
+
*/
|
|
11
|
+
export type AssetResolver = (assetId: string) => AssetRecord;
|
|
12
|
+
/** Optional hook for `code-rendered` visuals. v1 throws if not provided. */
|
|
13
|
+
export type RenderScriptResolver = (scriptPath: string) => ((g: Phaser.GameObjects.Graphics, params: Record<string, unknown>) => void) | undefined;
|
|
14
|
+
export interface SpawnContext {
|
|
15
|
+
scene: Phaser.Scene;
|
|
16
|
+
registry: EntityRegistry;
|
|
17
|
+
resolveAsset: AssetResolver;
|
|
18
|
+
resolveRenderScript?: RenderScriptResolver;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Spawn one entity into the scene and register it. Returns the created
|
|
22
|
+
* GameObject so callers (notably `spawnEntity` itself, recursing on a
|
|
23
|
+
* group's children) can attach it to a parent container.
|
|
24
|
+
*/
|
|
25
|
+
export declare function spawnEntity(ctx: SpawnContext, entity: WorldEntity): Phaser.GameObjects.GameObject;
|
|
26
|
+
/**
|
|
27
|
+
* Accepts `'#rrggbb'`, `'rrggbb'`, or `'0xrrggbb'` and returns a number
|
|
28
|
+
* suitable for Phaser's color APIs.
|
|
29
|
+
*/
|
|
30
|
+
export declare function parseColor(input: string): number;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spawn one entity into the scene and register it. Returns the created
|
|
3
|
+
* GameObject so callers (notably `spawnEntity` itself, recursing on a
|
|
4
|
+
* group's children) can attach it to a parent container.
|
|
5
|
+
*/
|
|
6
|
+
export function spawnEntity(ctx, entity) {
|
|
7
|
+
let go;
|
|
8
|
+
switch (entity.kind) {
|
|
9
|
+
case 'sprite':
|
|
10
|
+
go = createSprite(ctx, entity);
|
|
11
|
+
break;
|
|
12
|
+
case 'primitive':
|
|
13
|
+
go = createPrimitive(ctx, entity);
|
|
14
|
+
break;
|
|
15
|
+
case 'group':
|
|
16
|
+
go = createGroup(ctx, entity);
|
|
17
|
+
break;
|
|
18
|
+
case 'code-rendered':
|
|
19
|
+
go = createCodeRendered(ctx, entity);
|
|
20
|
+
break;
|
|
21
|
+
case 'tilemap':
|
|
22
|
+
throw new Error(`[unboxy/scene] tilemap entity ('${entity.id}') not supported in slice 1 — see 06-tilemap.md`);
|
|
23
|
+
case 'trigger':
|
|
24
|
+
throw new Error(`[unboxy/scene] trigger entity ('${entity.id}') not supported in slice 1 — see 03-world-editor.md`);
|
|
25
|
+
default: {
|
|
26
|
+
const exhaustive = entity;
|
|
27
|
+
throw new Error(`[unboxy/scene] unknown entity kind: ${JSON.stringify(exhaustive)}`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
applyTransform(go, entity.transform);
|
|
31
|
+
tagGameObject(go, entity);
|
|
32
|
+
ctx.registry.register(entity.id, entity.role, go);
|
|
33
|
+
return go;
|
|
34
|
+
}
|
|
35
|
+
function createSprite(ctx, entity) {
|
|
36
|
+
const v = entity.visual;
|
|
37
|
+
const asset = ctx.resolveAsset(v.assetId);
|
|
38
|
+
// Phaser's add.sprite handles both plain images and atlas/spritesheet
|
|
39
|
+
// textures uniformly when given (key, frame). For plain images, frame
|
|
40
|
+
// is undefined and Phaser uses the default frame.
|
|
41
|
+
const sprite = ctx.scene.add.sprite(0, 0, asset.textureKey, v.frame);
|
|
42
|
+
if (v.tint)
|
|
43
|
+
sprite.setTint(parseColor(v.tint));
|
|
44
|
+
if (typeof v.alpha === 'number')
|
|
45
|
+
sprite.setAlpha(v.alpha);
|
|
46
|
+
if (v.flipX)
|
|
47
|
+
sprite.setFlipX(true);
|
|
48
|
+
if (v.flipY)
|
|
49
|
+
sprite.setFlipY(true);
|
|
50
|
+
return sprite;
|
|
51
|
+
}
|
|
52
|
+
function createPrimitive(ctx, entity) {
|
|
53
|
+
const v = entity.visual;
|
|
54
|
+
if (v.kind === 'rect')
|
|
55
|
+
return createRect(ctx, v);
|
|
56
|
+
if (v.kind === 'circle')
|
|
57
|
+
return createCircle(ctx, v);
|
|
58
|
+
const exhaustive = v;
|
|
59
|
+
throw new Error(`[unboxy/scene] unknown primitive kind: ${JSON.stringify(exhaustive)}`);
|
|
60
|
+
}
|
|
61
|
+
function createRect(ctx, v) {
|
|
62
|
+
const fill = v.fillColor ? parseColor(v.fillColor) : 0xffffff;
|
|
63
|
+
const rect = ctx.scene.add.rectangle(0, 0, v.width, v.height, fill);
|
|
64
|
+
if (v.strokeColor && v.strokeWidth) {
|
|
65
|
+
rect.setStrokeStyle(v.strokeWidth, parseColor(v.strokeColor));
|
|
66
|
+
}
|
|
67
|
+
if (typeof v.alpha === 'number')
|
|
68
|
+
rect.setAlpha(v.alpha);
|
|
69
|
+
return rect;
|
|
70
|
+
}
|
|
71
|
+
function createCircle(ctx, v) {
|
|
72
|
+
const fill = v.fillColor ? parseColor(v.fillColor) : 0xffffff;
|
|
73
|
+
const arc = ctx.scene.add.circle(0, 0, v.radius, fill);
|
|
74
|
+
if (v.strokeColor && v.strokeWidth) {
|
|
75
|
+
arc.setStrokeStyle(v.strokeWidth, parseColor(v.strokeColor));
|
|
76
|
+
}
|
|
77
|
+
if (typeof v.alpha === 'number')
|
|
78
|
+
arc.setAlpha(v.alpha);
|
|
79
|
+
return arc;
|
|
80
|
+
}
|
|
81
|
+
function createGroup(ctx, entity) {
|
|
82
|
+
const container = ctx.scene.add.container(0, 0);
|
|
83
|
+
for (const child of entity.children) {
|
|
84
|
+
if (child.kind === 'group') {
|
|
85
|
+
// v1 cap: groups can't nest. Surface a clear error rather than
|
|
86
|
+
// silently producing a half-attached hierarchy.
|
|
87
|
+
throw new Error(`[unboxy/scene] group '${entity.id}' has a nested group child '${child.id}' — v1 supports one level of nesting only`);
|
|
88
|
+
}
|
|
89
|
+
const childGo = spawnEntity(ctx, child);
|
|
90
|
+
container.add(childGo);
|
|
91
|
+
}
|
|
92
|
+
return container;
|
|
93
|
+
}
|
|
94
|
+
function createCodeRendered(ctx, entity) {
|
|
95
|
+
const render = ctx.resolveRenderScript?.(entity.visual.script);
|
|
96
|
+
if (!render) {
|
|
97
|
+
throw new Error(`[unboxy/scene] code-rendered entity '${entity.id}' references '${entity.visual.script}' but no render-script resolver was provided — render scripts land in a later slice (see 02-render-scripts.md)`);
|
|
98
|
+
}
|
|
99
|
+
const g = ctx.scene.add.graphics();
|
|
100
|
+
render(g, entity.visual.params ?? {});
|
|
101
|
+
return g;
|
|
102
|
+
}
|
|
103
|
+
function applyTransform(go, t) {
|
|
104
|
+
// Most game objects implement Transform; Container does too. Cast through
|
|
105
|
+
// a permissive shape so this works for sprite/rect/circle/container alike.
|
|
106
|
+
const target = go;
|
|
107
|
+
target.x = t.x;
|
|
108
|
+
target.y = t.y;
|
|
109
|
+
if (typeof t.rotation === 'number')
|
|
110
|
+
target.rotation = t.rotation;
|
|
111
|
+
if (typeof t.scaleX === 'number')
|
|
112
|
+
target.scaleX = t.scaleX;
|
|
113
|
+
if (typeof t.scaleY === 'number')
|
|
114
|
+
target.scaleY = t.scaleY;
|
|
115
|
+
if (typeof t.depth === 'number' && target.setDepth)
|
|
116
|
+
target.setDepth(t.depth);
|
|
117
|
+
}
|
|
118
|
+
function tagGameObject(go, entity) {
|
|
119
|
+
go.setData('entityId', entity.id);
|
|
120
|
+
if (entity.role)
|
|
121
|
+
go.setData('entityRole', entity.role);
|
|
122
|
+
if (entity.properties)
|
|
123
|
+
go.setData('entityProperties', entity.properties);
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Accepts `'#rrggbb'`, `'rrggbb'`, or `'0xrrggbb'` and returns a number
|
|
127
|
+
* suitable for Phaser's color APIs.
|
|
128
|
+
*/
|
|
129
|
+
export function parseColor(input) {
|
|
130
|
+
const trimmed = input.trim();
|
|
131
|
+
if (trimmed.startsWith('#'))
|
|
132
|
+
return parseInt(trimmed.slice(1), 16);
|
|
133
|
+
if (trimmed.startsWith('0x'))
|
|
134
|
+
return parseInt(trimmed.slice(2), 16);
|
|
135
|
+
return parseInt(trimmed, 16);
|
|
136
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scene-as-data types — visual editor foundation (slice 1).
|
|
3
|
+
*
|
|
4
|
+
* Spec: unboxy-design/features/visual-editor/01-scene-data-foundation.md
|
|
5
|
+
*
|
|
6
|
+
* v1 schema (`schemaVersion: 1`) supports the entity kinds needed for the
|
|
7
|
+
* read-only scene viewer. HUD entity kinds, tilemap, trigger, and
|
|
8
|
+
* code-rendered visuals will land in later slices.
|
|
9
|
+
*/
|
|
10
|
+
export declare const SCHEMA_VERSION = 1;
|
|
11
|
+
export type SceneType = 'world' | 'hud';
|
|
12
|
+
export interface SceneRef {
|
|
13
|
+
id: string;
|
|
14
|
+
type: SceneType;
|
|
15
|
+
/** Path under `public/scenes/`, e.g. `"world/level-1-1.json"`. */
|
|
16
|
+
file: string;
|
|
17
|
+
/** Optional HUD scene id to overlay (world scenes only). Null = no HUD. */
|
|
18
|
+
hud?: string | null;
|
|
19
|
+
}
|
|
20
|
+
export interface HudRef {
|
|
21
|
+
id: string;
|
|
22
|
+
/** Path under `public/scenes/`, e.g. `"hud/game-hud.json"`. */
|
|
23
|
+
file: string;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Per-game asset table. Source of truth for asset id → Phaser textureKey
|
|
27
|
+
* resolution. The editor and runtime both read from this.
|
|
28
|
+
*/
|
|
29
|
+
export type AssetKind = 'image' | 'spritesheet' | 'atlas' | 'audio' | 'json';
|
|
30
|
+
export interface AssetRecord {
|
|
31
|
+
/** Stable asset id used in scene files (`visual.assetId`). */
|
|
32
|
+
id: string;
|
|
33
|
+
/** Phaser texture / cache key. May equal `id` for simple cases. */
|
|
34
|
+
textureKey: string;
|
|
35
|
+
/** Path relative to `public/`. */
|
|
36
|
+
path: string;
|
|
37
|
+
kind: AssetKind;
|
|
38
|
+
/** For `spritesheet`: Phaser SpriteSheetConfig. */
|
|
39
|
+
spriteSheetConfig?: {
|
|
40
|
+
frameWidth: number;
|
|
41
|
+
frameHeight: number;
|
|
42
|
+
margin?: number;
|
|
43
|
+
spacing?: number;
|
|
44
|
+
};
|
|
45
|
+
/** For `atlas`: companion atlas file path (xml or json). */
|
|
46
|
+
atlasPath?: string;
|
|
47
|
+
/** `'xml'` (Starling) or `'json'` (TexturePacker). */
|
|
48
|
+
atlasFormat?: 'xml' | 'json';
|
|
49
|
+
}
|
|
50
|
+
export interface Manifest {
|
|
51
|
+
schemaVersion: number;
|
|
52
|
+
id: string;
|
|
53
|
+
title: string;
|
|
54
|
+
version: string;
|
|
55
|
+
initialScene: string;
|
|
56
|
+
scenes: SceneRef[];
|
|
57
|
+
huds: HudRef[];
|
|
58
|
+
assets: AssetRecord[];
|
|
59
|
+
globals?: {
|
|
60
|
+
physics?: {
|
|
61
|
+
gravity?: {
|
|
62
|
+
x?: number;
|
|
63
|
+
y?: number;
|
|
64
|
+
};
|
|
65
|
+
};
|
|
66
|
+
world?: {
|
|
67
|
+
pixelArt?: boolean;
|
|
68
|
+
};
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
export interface Transform {
|
|
72
|
+
x: number;
|
|
73
|
+
y: number;
|
|
74
|
+
/** Radians. */
|
|
75
|
+
rotation?: number;
|
|
76
|
+
scaleX?: number;
|
|
77
|
+
scaleY?: number;
|
|
78
|
+
depth?: number;
|
|
79
|
+
}
|
|
80
|
+
export type AnchorSide = 'top-left' | 'top' | 'top-right' | 'left' | 'center' | 'right' | 'bottom-left' | 'bottom' | 'bottom-right';
|
|
81
|
+
export interface Anchor {
|
|
82
|
+
side: AnchorSide;
|
|
83
|
+
offsetX?: number;
|
|
84
|
+
offsetY?: number;
|
|
85
|
+
}
|
|
86
|
+
export interface SpriteVisual {
|
|
87
|
+
kind: 'sprite';
|
|
88
|
+
assetId: string;
|
|
89
|
+
/** Atlas frame name OR sprite-sheet frame index. */
|
|
90
|
+
frame?: string | number;
|
|
91
|
+
tint?: string;
|
|
92
|
+
alpha?: number;
|
|
93
|
+
flipX?: boolean;
|
|
94
|
+
flipY?: boolean;
|
|
95
|
+
}
|
|
96
|
+
export interface PrimitiveRectVisual {
|
|
97
|
+
kind: 'rect';
|
|
98
|
+
width: number;
|
|
99
|
+
height: number;
|
|
100
|
+
fillColor?: string;
|
|
101
|
+
strokeColor?: string | null;
|
|
102
|
+
strokeWidth?: number;
|
|
103
|
+
alpha?: number;
|
|
104
|
+
}
|
|
105
|
+
export interface PrimitiveCircleVisual {
|
|
106
|
+
kind: 'circle';
|
|
107
|
+
radius: number;
|
|
108
|
+
fillColor?: string;
|
|
109
|
+
strokeColor?: string | null;
|
|
110
|
+
strokeWidth?: number;
|
|
111
|
+
alpha?: number;
|
|
112
|
+
}
|
|
113
|
+
export type PrimitiveVisual = PrimitiveRectVisual | PrimitiveCircleVisual;
|
|
114
|
+
export interface CodeRenderedVisual {
|
|
115
|
+
kind: 'code-rendered';
|
|
116
|
+
/** Path to render script, e.g. `"src/visuals/boss-renderer.ts"`. */
|
|
117
|
+
script: string;
|
|
118
|
+
params?: Record<string, unknown>;
|
|
119
|
+
}
|
|
120
|
+
export type WorldVisual = SpriteVisual | PrimitiveVisual | CodeRenderedVisual;
|
|
121
|
+
export type WorldEntityKind = 'sprite' | 'primitive' | 'code-rendered' | 'group' | 'tilemap' | 'trigger';
|
|
122
|
+
export interface WorldEntityBase {
|
|
123
|
+
id: string;
|
|
124
|
+
/** Optional semantic tag. Behavior code keys off this. */
|
|
125
|
+
role?: string;
|
|
126
|
+
/** Free-form per-entity data the agent's behavior code reads. */
|
|
127
|
+
properties?: Record<string, unknown>;
|
|
128
|
+
transform: Transform;
|
|
129
|
+
}
|
|
130
|
+
export interface SpriteEntity extends WorldEntityBase {
|
|
131
|
+
kind: 'sprite';
|
|
132
|
+
visual: SpriteVisual;
|
|
133
|
+
}
|
|
134
|
+
export interface PrimitiveEntity extends WorldEntityBase {
|
|
135
|
+
kind: 'primitive';
|
|
136
|
+
visual: PrimitiveVisual;
|
|
137
|
+
}
|
|
138
|
+
export interface CodeRenderedEntity extends WorldEntityBase {
|
|
139
|
+
kind: 'code-rendered';
|
|
140
|
+
visual: CodeRenderedVisual;
|
|
141
|
+
}
|
|
142
|
+
export interface GroupEntity extends WorldEntityBase {
|
|
143
|
+
kind: 'group';
|
|
144
|
+
/**
|
|
145
|
+
* One level of nesting in v1: a child cannot itself be a `group`.
|
|
146
|
+
* If you need deeper hierarchy, the agent should flatten via the
|
|
147
|
+
* existing transform and add a role tag for grouping queries.
|
|
148
|
+
*/
|
|
149
|
+
children: NonGroupWorldEntity[];
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* v1 placeholder — schema slot reserved so future slices can layer in
|
|
153
|
+
* tilemap/trigger without a schemaVersion bump. Loader currently throws
|
|
154
|
+
* on these kinds so an unmigrated game can't silently render nothing.
|
|
155
|
+
*/
|
|
156
|
+
export interface TilemapEntity extends WorldEntityBase {
|
|
157
|
+
kind: 'tilemap';
|
|
158
|
+
/** Opaque tilemap payload — defined in `06-tilemap.md`. */
|
|
159
|
+
data: unknown;
|
|
160
|
+
}
|
|
161
|
+
export interface TriggerEntity extends WorldEntityBase {
|
|
162
|
+
kind: 'trigger';
|
|
163
|
+
/** Opaque trigger payload — defined in `03-world-editor.md`. */
|
|
164
|
+
data: unknown;
|
|
165
|
+
}
|
|
166
|
+
export type NonGroupWorldEntity = SpriteEntity | PrimitiveEntity | CodeRenderedEntity | TilemapEntity | TriggerEntity;
|
|
167
|
+
export type WorldEntity = NonGroupWorldEntity | GroupEntity;
|
|
168
|
+
export interface CameraConfig {
|
|
169
|
+
/** Entity id to follow. Null = static camera. */
|
|
170
|
+
follow?: string | null;
|
|
171
|
+
smoothing?: number;
|
|
172
|
+
deadzone?: {
|
|
173
|
+
x: number;
|
|
174
|
+
y: number;
|
|
175
|
+
};
|
|
176
|
+
lookahead?: number;
|
|
177
|
+
bounds?: {
|
|
178
|
+
x: number;
|
|
179
|
+
y: number;
|
|
180
|
+
width: number;
|
|
181
|
+
height: number;
|
|
182
|
+
};
|
|
183
|
+
zoom?: number;
|
|
184
|
+
pixelPerfect?: boolean;
|
|
185
|
+
shakeDecay?: number;
|
|
186
|
+
}
|
|
187
|
+
export interface WorldSceneConfig {
|
|
188
|
+
width: number;
|
|
189
|
+
height: number;
|
|
190
|
+
background?: {
|
|
191
|
+
color?: string;
|
|
192
|
+
};
|
|
193
|
+
physics?: {
|
|
194
|
+
gravity?: {
|
|
195
|
+
x?: number;
|
|
196
|
+
y?: number;
|
|
197
|
+
};
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
export interface WorldScene {
|
|
201
|
+
schemaVersion: number;
|
|
202
|
+
id: string;
|
|
203
|
+
name: string;
|
|
204
|
+
type: 'world';
|
|
205
|
+
world: WorldSceneConfig;
|
|
206
|
+
camera?: CameraConfig;
|
|
207
|
+
entities: WorldEntity[];
|
|
208
|
+
metadata?: {
|
|
209
|
+
tags?: string[];
|
|
210
|
+
author?: string;
|
|
211
|
+
createdAt?: string;
|
|
212
|
+
updatedAt?: string;
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* HUD scene schema slot — v1 reserves the shape for slice 5. The loader
|
|
217
|
+
* throws if it encounters one in slice 1; the type lives here so the
|
|
218
|
+
* manifest's `huds[]` list is well-typed.
|
|
219
|
+
*/
|
|
220
|
+
export interface HudScene {
|
|
221
|
+
schemaVersion: number;
|
|
222
|
+
id: string;
|
|
223
|
+
name: string;
|
|
224
|
+
type: 'hud';
|
|
225
|
+
design?: {
|
|
226
|
+
designAspectRatio?: string;
|
|
227
|
+
safeArea?: {
|
|
228
|
+
top: number;
|
|
229
|
+
right: number;
|
|
230
|
+
bottom: number;
|
|
231
|
+
left: number;
|
|
232
|
+
};
|
|
233
|
+
};
|
|
234
|
+
entities: unknown[];
|
|
235
|
+
metadata?: WorldScene['metadata'];
|
|
236
|
+
}
|
|
237
|
+
export type SceneFile = WorldScene | HudScene;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scene-as-data types — visual editor foundation (slice 1).
|
|
3
|
+
*
|
|
4
|
+
* Spec: unboxy-design/features/visual-editor/01-scene-data-foundation.md
|
|
5
|
+
*
|
|
6
|
+
* v1 schema (`schemaVersion: 1`) supports the entity kinds needed for the
|
|
7
|
+
* read-only scene viewer. HUD entity kinds, tilemap, trigger, and
|
|
8
|
+
* code-rendered visuals will land in later slices.
|
|
9
|
+
*/
|
|
10
|
+
export const SCHEMA_VERSION = 1;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@unboxy/phaser-sdk",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.17",
|
|
4
4
|
"description": "Unboxy Phaser 3 SDK — game infrastructure for the Unboxy platform",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
"scripts": {
|
|
12
12
|
"build": "tsc",
|
|
13
13
|
"test": "npm run build && node --test scripts/test-chat-facade.mjs",
|
|
14
|
-
"prepublishOnly": "npm run build"
|
|
14
|
+
"prepublishOnly": "npm run build && [ \"$UNBOXY_PUBLISH_VIA_SCRIPT\" = \"1\" ] || (echo '\\nERROR: do not run `npm publish` directly. Use ./publish.sh instead — it gates on the template-sync checklist.\\nSee CLAUDE.md \"Template sync — do not skip\".\\n' >&2 && exit 1)",
|
|
15
|
+
"release": "./publish.sh"
|
|
15
16
|
},
|
|
16
17
|
"dependencies": {
|
|
17
18
|
"colyseus.js": "^0.16.0"
|