@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.
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,8 @@ 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.18** — added editor bridge (visual editor slice 2). Replaces the slice-1 `unboxy:setEditMode` message with a full editor protocol: `unboxy:editor:enter`/`:exit` (toggles edit mode + launches the overlay scene), `:applyEdit` (mutates a live entity from the host), `:setSelection` (host pushes selection), `:panZoom` (editor camera control), plus SDK→host `:sceneLoaded` (initial snapshot), `:pickEntity` (pointer-down selection), `:dragEnd` (entity dragged to new position). New module `src/editor/` with `EditorOverlayScene` (high-depth Phaser.Scene drawing selection rect + world bounds, capturing pointer events) and `EditorBridge` (postMessage handler + applyEdit logic). New exports: `setupEditorBridge`, `EditorOverlayScene`, `EDITOR_OVERLAY_KEY`, plus all editor protocol types. `setupEditorModeListener` is preserved as a thin wrapper that delegates to the bridge so existing `createUnboxyGame` wiring continues to work. Pairs with home-ui's new Hierarchy + Inspector panels and `useEditorDraft` hook.
642
+ - **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
643
  - **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
644
  - **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
645
  - **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.
@@ -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
  }
@@ -0,0 +1,2 @@
1
+ import Phaser from 'phaser';
2
+ export declare function setupEditorBridge(game: Phaser.Game): void;
@@ -0,0 +1,295 @@
1
+ import { getEntityRegistry } from '../scene/EntityRegistry.js';
2
+ import { parseColor } from '../scene/spawnEntity.js';
3
+ import { EditorOverlayScene, EDITOR_OVERLAY_KEY } from './EditorOverlayScene.js';
4
+ import { getEditorState, setEditorActive, setSelection, } from './EditorState.js';
5
+ /**
6
+ * EditorBridge wires the host (home-ui) to the iframe's Phaser game during
7
+ * Edit mode. Manages:
8
+ *
9
+ * - Entering / exiting edit mode (pauses non-Boot scenes; launches the overlay)
10
+ * - Applying entity patches from the host (Inspector / Undo edits)
11
+ * - Tracking selection (host pushes; overlay reads)
12
+ * - Pan/zoom of the editor camera
13
+ * - Posting pickEntity + dragEnd back to the host
14
+ *
15
+ * Wired by `setupEditorModeListener` in EditorMode.ts.
16
+ */
17
+ const BOOT_SCENE_KEYS = new Set(['BootScene', 'Boot']);
18
+ const EDITOR_LISTENER_FLAG = '__unboxyEditorBridgeListener';
19
+ export function setupEditorBridge(game) {
20
+ const flagged = game;
21
+ if (flagged[EDITOR_LISTENER_FLAG])
22
+ return;
23
+ flagged[EDITOR_LISTENER_FLAG] = true;
24
+ // Register the overlay scene class so it can be launched when needed.
25
+ // (Adding a class to game.scene.add() registers it without starting it.)
26
+ game.scene.add(EDITOR_OVERLAY_KEY, EditorOverlayScene, false);
27
+ window.addEventListener('message', (event) => {
28
+ const data = event.data;
29
+ if (!data || typeof data !== 'object' || typeof data.type !== 'string')
30
+ return;
31
+ if (!data.type.startsWith('unboxy:editor:'))
32
+ return;
33
+ handleMessage(game, data);
34
+ });
35
+ }
36
+ function handleMessage(game, msg) {
37
+ switch (msg.type) {
38
+ case 'unboxy:editor:enter':
39
+ enterEdit(game);
40
+ break;
41
+ case 'unboxy:editor:exit':
42
+ exitEdit(game);
43
+ break;
44
+ case 'unboxy:editor:getScene':
45
+ postSceneSnapshot(game);
46
+ break;
47
+ case 'unboxy:editor:applyEdit':
48
+ applyEdit(game, msg.entityId, msg.patch);
49
+ break;
50
+ case 'unboxy:editor:setSelection':
51
+ setSelection(game, msg.entityIds[0] ?? null);
52
+ break;
53
+ case 'unboxy:editor:panZoom':
54
+ applyPanZoom(game, msg);
55
+ break;
56
+ }
57
+ }
58
+ // --- Enter / exit ---------------------------------------------------------
59
+ function enterEdit(game) {
60
+ if (getEditorState(game).active)
61
+ return;
62
+ setEditorActive(game, true);
63
+ for (const scene of game.scene.getScenes(true)) {
64
+ const key = scene.scene.key;
65
+ if (BOOT_SCENE_KEYS.has(key))
66
+ continue;
67
+ if (key === EDITOR_OVERLAY_KEY)
68
+ continue;
69
+ if (scene.scene.isActive())
70
+ scene.scene.pause();
71
+ }
72
+ // Launch the overlay AFTER pausing world scenes so it sits on top in render
73
+ // order (Phaser renders scenes in the order they were started).
74
+ const overlayInit = buildOverlayInit(game);
75
+ game.scene.run(EDITOR_OVERLAY_KEY, overlayInit);
76
+ // Send the initial scene snapshot so home-ui can populate Hierarchy /
77
+ // Inspector without a separate getScene round-trip.
78
+ postSceneSnapshot(game);
79
+ }
80
+ function exitEdit(game) {
81
+ if (!getEditorState(game).active)
82
+ return;
83
+ setEditorActive(game, false);
84
+ setSelection(game, null);
85
+ if (game.scene.isActive(EDITOR_OVERLAY_KEY)) {
86
+ game.scene.stop(EDITOR_OVERLAY_KEY);
87
+ }
88
+ for (const scene of game.scene.getScenes(false)) {
89
+ const key = scene.scene.key;
90
+ if (BOOT_SCENE_KEYS.has(key))
91
+ continue;
92
+ if (key === EDITOR_OVERLAY_KEY)
93
+ continue;
94
+ if (scene.scene.isPaused())
95
+ scene.scene.resume();
96
+ }
97
+ }
98
+ function buildOverlayInit(game) {
99
+ const sceneFile = readActiveSceneFile(game);
100
+ return {
101
+ worldBounds: sceneFile
102
+ ? { x: 0, y: 0, width: sceneFile.world.width, height: sceneFile.world.height }
103
+ : undefined,
104
+ hitTest: (worldX, worldY) => hitTest(game, worldX, worldY),
105
+ postPick: (entityId, modifiers) => postToHost({
106
+ type: 'unboxy:editor:pickEntity',
107
+ entityId,
108
+ modifiers,
109
+ }),
110
+ postDragEnd: (entityId, before, after) => postToHost({
111
+ type: 'unboxy:editor:dragEnd',
112
+ entityId,
113
+ before,
114
+ after,
115
+ }),
116
+ };
117
+ }
118
+ // --- Snapshot -------------------------------------------------------------
119
+ function postSceneSnapshot(game) {
120
+ const sceneFile = readActiveSceneFile(game);
121
+ if (!sceneFile)
122
+ return;
123
+ postToHost({
124
+ type: 'unboxy:editor:sceneLoaded',
125
+ sceneId: sceneFile.id,
126
+ sceneFile,
127
+ });
128
+ }
129
+ function readActiveSceneFile(game) {
130
+ // The active world scene cached its scene file in Phaser's JSON cache via
131
+ // SceneLoader. Pull it from there. We pick the first non-Boot scene we
132
+ // find with a JSON entry matching `unboxy:scene:<id>`.
133
+ for (const scene of game.scene.getScenes(false)) {
134
+ const key = scene.scene.key;
135
+ if (BOOT_SCENE_KEYS.has(key))
136
+ continue;
137
+ if (key === EDITOR_OVERLAY_KEY)
138
+ continue;
139
+ // Scan cache for any 'unboxy:scene:*' entry. There's typically one.
140
+ const cache = scene.cache.json;
141
+ const entries = cache.entries?.entries ?? {};
142
+ for (const cacheKey of Object.keys(entries)) {
143
+ if (cacheKey.startsWith('unboxy:scene:')) {
144
+ const candidate = entries[cacheKey];
145
+ if (isWorldScene(candidate))
146
+ return candidate;
147
+ }
148
+ }
149
+ }
150
+ return null;
151
+ }
152
+ function isWorldScene(v) {
153
+ return !!v && typeof v === 'object' && v.type === 'world';
154
+ }
155
+ // --- Hit test -------------------------------------------------------------
156
+ function hitTest(game, worldX, worldY) {
157
+ const registry = findRegistry(game);
158
+ if (!registry)
159
+ return null;
160
+ let topmost = null;
161
+ let topmostDepth = -Infinity;
162
+ for (const go of registry.all()) {
163
+ const withBounds = go;
164
+ if (typeof withBounds.getBounds !== 'function')
165
+ continue;
166
+ const r = withBounds.getBounds();
167
+ if (worldX < r.x || worldX > r.x + r.width)
168
+ continue;
169
+ if (worldY < r.y || worldY > r.y + r.height)
170
+ continue;
171
+ const depth = typeof withBounds.depth === 'number' ? withBounds.depth : 0;
172
+ if (depth >= topmostDepth) {
173
+ topmostDepth = depth;
174
+ topmost = go;
175
+ }
176
+ }
177
+ return topmost;
178
+ }
179
+ function findRegistry(game) {
180
+ for (const scene of game.scene.getScenes(false)) {
181
+ const reg = getEntityRegistry(scene);
182
+ if (reg)
183
+ return reg;
184
+ }
185
+ return undefined;
186
+ }
187
+ // --- applyEdit ------------------------------------------------------------
188
+ function applyEdit(game, entityId, patch) {
189
+ const registry = findRegistry(game);
190
+ if (!registry)
191
+ return;
192
+ const go = registry.byId(entityId);
193
+ if (!go)
194
+ return;
195
+ applyTransformPatch(go, patch.transform);
196
+ applyVisualPatch(go, patch.visual);
197
+ if (patch.role !== undefined) {
198
+ if (patch.role === null)
199
+ go.setData('entityRole', undefined);
200
+ else
201
+ go.setData('entityRole', patch.role);
202
+ }
203
+ if (patch.properties !== undefined) {
204
+ go.setData('entityProperties', patch.properties);
205
+ }
206
+ }
207
+ function applyTransformPatch(go, t) {
208
+ if (!t)
209
+ return;
210
+ const target = go;
211
+ if (typeof t.x === 'number')
212
+ target.x = t.x;
213
+ if (typeof t.y === 'number')
214
+ target.y = t.y;
215
+ if (typeof t.rotation === 'number')
216
+ target.rotation = t.rotation;
217
+ if (typeof t.scaleX === 'number')
218
+ target.scaleX = t.scaleX;
219
+ if (typeof t.scaleY === 'number')
220
+ target.scaleY = t.scaleY;
221
+ if (typeof t.depth === 'number' && target.setDepth)
222
+ target.setDepth(t.depth);
223
+ }
224
+ function applyVisualPatch(go, v) {
225
+ if (!v)
226
+ return;
227
+ const target = go;
228
+ if (v.tint !== undefined) {
229
+ if (v.tint === null)
230
+ target.clearTint?.();
231
+ else
232
+ target.setTint?.(parseColor(v.tint));
233
+ }
234
+ if (typeof v.alpha === 'number')
235
+ target.setAlpha?.(v.alpha);
236
+ if (typeof v.flipX === 'boolean')
237
+ target.setFlipX?.(v.flipX);
238
+ if (typeof v.flipY === 'boolean')
239
+ target.setFlipY?.(v.flipY);
240
+ if (v.frame !== undefined)
241
+ target.setFrame?.(v.frame);
242
+ if (typeof v.width === 'number' && typeof v.height === 'number') {
243
+ target.setSize?.(v.width, v.height);
244
+ }
245
+ if (typeof v.radius === 'number') {
246
+ // Phaser's Arc doesn't expose setRadius reliably; mutate + redraw.
247
+ if ('radius' in target)
248
+ target.radius = v.radius;
249
+ }
250
+ if (v.fillColor !== undefined) {
251
+ target.setFillStyle?.(parseColor(v.fillColor));
252
+ }
253
+ if (v.strokeColor !== undefined && typeof v.strokeWidth === 'number') {
254
+ if (v.strokeColor === null) {
255
+ // Phaser doesn't expose a clearStroke; setting width=0 + black is the
256
+ // closest approximation.
257
+ target.setStrokeStyle?.(0, 0);
258
+ }
259
+ else {
260
+ target.setStrokeStyle?.(v.strokeWidth, parseColor(v.strokeColor));
261
+ }
262
+ }
263
+ }
264
+ // --- Pan / zoom -----------------------------------------------------------
265
+ function applyPanZoom(game, msg) {
266
+ // Apply to BOTH the world scene's camera (so the entity rendering moves)
267
+ // and the overlay scene's camera (so handles draw at the right place).
268
+ for (const scene of game.scene.getScenes(false)) {
269
+ if (BOOT_SCENE_KEYS.has(scene.scene.key))
270
+ continue;
271
+ const cam = scene.cameras.main;
272
+ if (msg.relative) {
273
+ if (typeof msg.scrollX === 'number')
274
+ cam.scrollX += msg.scrollX;
275
+ if (typeof msg.scrollY === 'number')
276
+ cam.scrollY += msg.scrollY;
277
+ }
278
+ else {
279
+ if (typeof msg.scrollX === 'number')
280
+ cam.scrollX = msg.scrollX;
281
+ if (typeof msg.scrollY === 'number')
282
+ cam.scrollY = msg.scrollY;
283
+ }
284
+ if (typeof msg.zoom === 'number') {
285
+ const z = Math.max(0.25, Math.min(2, msg.zoom));
286
+ cam.setZoom(z);
287
+ }
288
+ }
289
+ }
290
+ // --- postMessage helper ---------------------------------------------------
291
+ function postToHost(msg) {
292
+ if (typeof window === 'undefined' || !window.parent)
293
+ return;
294
+ window.parent.postMessage(msg, '*');
295
+ }
@@ -0,0 +1,72 @@
1
+ import Phaser from 'phaser';
2
+ /**
3
+ * Editor overlay — slice 2.
4
+ *
5
+ * Runs ABOVE the world/HUD scenes. Its job:
6
+ *
7
+ * 1. Render selection rectangle around the currently selected entity
8
+ * 2. Render world bounds rectangle (visual cue for "edge of the game world")
9
+ * 3. Capture pointer events:
10
+ * - pointerdown on an entity → posts pickEntity to host
11
+ * - drag → mutates the entity's x/y in real time (visual only)
12
+ * - pointerup → posts dragEnd with before/after to host
13
+ *
14
+ * The overlay scene is launched by EditorBridge when entering Edit mode,
15
+ * stopped on exit. It holds no persistent state of its own — selection +
16
+ * drag info live in the shared EditorState attached to the Phaser game.
17
+ */
18
+ export declare const EDITOR_OVERLAY_KEY = "__UnboxyEditorOverlay";
19
+ interface EditorOverlayInitData {
20
+ /** World bounds rect to draw (slice 2 draws but doesn't allow editing). */
21
+ worldBounds?: {
22
+ x: number;
23
+ y: number;
24
+ width: number;
25
+ height: number;
26
+ };
27
+ /**
28
+ * Callback to resolve the entity at a given world point. Provided by the
29
+ * EditorBridge so the overlay doesn't have to know about the EntityRegistry.
30
+ * Returns the topmost (highest depth) entity at that point or null.
31
+ */
32
+ hitTest: (worldX: number, worldY: number) => Phaser.GameObjects.GameObject | null;
33
+ /** postMessage helpers (so the scene can send pickEntity / dragEnd). */
34
+ postPick: (entityId: string | null, modifiers: {
35
+ shift: boolean;
36
+ cmdOrCtrl: boolean;
37
+ alt: boolean;
38
+ }) => void;
39
+ postDragEnd: (entityId: string, before: {
40
+ x: number;
41
+ y: number;
42
+ }, after: {
43
+ x: number;
44
+ y: number;
45
+ }) => void;
46
+ }
47
+ export declare class EditorOverlayScene extends Phaser.Scene {
48
+ private graphics;
49
+ private worldBounds?;
50
+ private hitTest;
51
+ private postPick;
52
+ private postDragEnd;
53
+ constructor();
54
+ init(data: EditorOverlayInitData): void;
55
+ create(): void;
56
+ private handlePointerDown;
57
+ private handlePointerMove;
58
+ private handlePointerUp;
59
+ update(): void;
60
+ /**
61
+ * Find the world scene's main camera so the overlay can mirror it.
62
+ * In edit mode, the world scene is paused but its camera state is what
63
+ * the overlay's pointer math should use.
64
+ */
65
+ private findWorldSceneCamera;
66
+ /**
67
+ * The EntityRegistry lives on the world scene. Pull it from there so we
68
+ * don't have to plumb registry references through the bridge.
69
+ */
70
+ private findEntityRegistry;
71
+ }
72
+ export {};
@@ -0,0 +1,158 @@
1
+ import Phaser from 'phaser';
2
+ import { getEntityRegistry } from '../scene/EntityRegistry.js';
3
+ import { getEditorState, startDrag, clearDrag, getDrag, getSelection, } from './EditorState.js';
4
+ /**
5
+ * Editor overlay — slice 2.
6
+ *
7
+ * Runs ABOVE the world/HUD scenes. Its job:
8
+ *
9
+ * 1. Render selection rectangle around the currently selected entity
10
+ * 2. Render world bounds rectangle (visual cue for "edge of the game world")
11
+ * 3. Capture pointer events:
12
+ * - pointerdown on an entity → posts pickEntity to host
13
+ * - drag → mutates the entity's x/y in real time (visual only)
14
+ * - pointerup → posts dragEnd with before/after to host
15
+ *
16
+ * The overlay scene is launched by EditorBridge when entering Edit mode,
17
+ * stopped on exit. It holds no persistent state of its own — selection +
18
+ * drag info live in the shared EditorState attached to the Phaser game.
19
+ */
20
+ export const EDITOR_OVERLAY_KEY = '__UnboxyEditorOverlay';
21
+ const SELECTION_COLOR = 0x4662d8;
22
+ const SELECTION_ALPHA = 1;
23
+ const SELECTION_LINE_WIDTH = 2;
24
+ const WORLD_BOUNDS_COLOR = 0x888888;
25
+ const WORLD_BOUNDS_ALPHA = 0.5;
26
+ export class EditorOverlayScene extends Phaser.Scene {
27
+ constructor() {
28
+ super({ key: EDITOR_OVERLAY_KEY });
29
+ }
30
+ init(data) {
31
+ this.worldBounds = data.worldBounds;
32
+ this.hitTest = data.hitTest;
33
+ this.postPick = data.postPick;
34
+ this.postDragEnd = data.postDragEnd;
35
+ }
36
+ create() {
37
+ this.graphics = this.add.graphics();
38
+ // Mirror the world scene's camera so editor overlay draws in world coords.
39
+ // The host (home-ui) will pan/zoom this camera via panZoom postMessages.
40
+ const worldCam = this.findWorldSceneCamera();
41
+ if (worldCam) {
42
+ this.cameras.main.setScroll(worldCam.scrollX, worldCam.scrollY);
43
+ this.cameras.main.setZoom(worldCam.zoom);
44
+ }
45
+ this.input.on('pointerdown', this.handlePointerDown, this);
46
+ this.input.on('pointermove', this.handlePointerMove, this);
47
+ this.input.on('pointerup', this.handlePointerUp, this);
48
+ }
49
+ handlePointerDown(pointer) {
50
+ const state = getEditorState(this.game);
51
+ if (!state.active)
52
+ return;
53
+ const wx = pointer.worldX;
54
+ const wy = pointer.worldY;
55
+ const hit = this.hitTest(wx, wy);
56
+ const event = pointer.event;
57
+ const modifiers = {
58
+ shift: !!event?.shiftKey,
59
+ cmdOrCtrl: !!(event?.metaKey || event?.ctrlKey),
60
+ alt: !!event?.altKey,
61
+ };
62
+ if (!hit) {
63
+ this.postPick(null, modifiers);
64
+ return;
65
+ }
66
+ const entityId = hit.getData('entityId');
67
+ if (!entityId) {
68
+ this.postPick(null, modifiers);
69
+ return;
70
+ }
71
+ this.postPick(entityId, modifiers);
72
+ // Begin drag immediately on pointerdown (drag threshold can be added later).
73
+ const target = hit;
74
+ startDrag(this.game, entityId, { x: wx, y: wy }, { x: target.x, y: target.y });
75
+ }
76
+ handlePointerMove(pointer) {
77
+ const drag = getDrag(this.game);
78
+ if (!drag)
79
+ return;
80
+ const registry = this.findEntityRegistry();
81
+ if (!registry)
82
+ return;
83
+ const go = registry.byId(drag.entityId);
84
+ if (!go)
85
+ return;
86
+ const dx = pointer.worldX - drag.startWorld.x;
87
+ const dy = pointer.worldY - drag.startWorld.y;
88
+ go.x = drag.startEntity.x + dx;
89
+ go.y = drag.startEntity.y + dy;
90
+ }
91
+ handlePointerUp(pointer) {
92
+ const drag = getDrag(this.game);
93
+ if (!drag)
94
+ return;
95
+ const dx = pointer.worldX - drag.startWorld.x;
96
+ const dy = pointer.worldY - drag.startWorld.y;
97
+ const before = drag.startEntity;
98
+ const after = { x: drag.startEntity.x + dx, y: drag.startEntity.y + dy };
99
+ clearDrag(this.game);
100
+ // Suppress dragEnd for trivial "click without movement" — the host already
101
+ // got pickEntity for those.
102
+ if (Math.abs(dx) < 0.5 && Math.abs(dy) < 0.5)
103
+ return;
104
+ this.postDragEnd(drag.entityId, before, after);
105
+ }
106
+ update() {
107
+ this.graphics.clear();
108
+ if (this.worldBounds) {
109
+ this.graphics.lineStyle(2, WORLD_BOUNDS_COLOR, WORLD_BOUNDS_ALPHA);
110
+ this.graphics.strokeRect(this.worldBounds.x, this.worldBounds.y, this.worldBounds.width, this.worldBounds.height);
111
+ }
112
+ const selectedId = getSelection(this.game);
113
+ if (selectedId) {
114
+ const registry = this.findEntityRegistry();
115
+ const go = registry?.byId(selectedId);
116
+ if (go) {
117
+ const bounds = computeBounds(go);
118
+ if (bounds) {
119
+ this.graphics.lineStyle(SELECTION_LINE_WIDTH, SELECTION_COLOR, SELECTION_ALPHA);
120
+ this.graphics.strokeRect(bounds.x, bounds.y, bounds.width, bounds.height);
121
+ }
122
+ }
123
+ }
124
+ }
125
+ /**
126
+ * Find the world scene's main camera so the overlay can mirror it.
127
+ * In edit mode, the world scene is paused but its camera state is what
128
+ * the overlay's pointer math should use.
129
+ */
130
+ findWorldSceneCamera() {
131
+ for (const scene of this.game.scene.getScenes(false)) {
132
+ if (scene.scene.key === 'GameScene') {
133
+ return scene.cameras.main;
134
+ }
135
+ }
136
+ return null;
137
+ }
138
+ /**
139
+ * The EntityRegistry lives on the world scene. Pull it from there so we
140
+ * don't have to plumb registry references through the bridge.
141
+ */
142
+ findEntityRegistry() {
143
+ for (const scene of this.game.scene.getScenes(false)) {
144
+ const reg = getEntityRegistry(scene);
145
+ if (reg)
146
+ return reg;
147
+ }
148
+ return undefined;
149
+ }
150
+ }
151
+ function computeBounds(go) {
152
+ // Most game objects implement getBounds; Container does too.
153
+ const withBounds = go;
154
+ if (typeof withBounds.getBounds !== 'function')
155
+ return null;
156
+ const r = withBounds.getBounds();
157
+ return { x: r.x, y: r.y, width: r.width, height: r.height };
158
+ }