@unboxy/phaser-sdk 0.2.17 → 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
@@ -638,6 +638,7 @@ The `scene-data-architecture` agent skill has the full guidance. The `scene-data
638
638
 
639
639
  ## Changelog
640
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.
641
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.
642
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.
643
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.
@@ -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
+ }
@@ -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
@@ -22,6 +22,9 @@ export { spawnEntity, parseColor } from './scene/spawnEntity.js';
22
22
  export type { SpawnContext, AssetResolver, RenderScriptResolver, } from './scene/spawnEntity.js';
23
23
  export { EntityRegistry, attachEntityRegistry, getEntityRegistry, } from './scene/EntityRegistry.js';
24
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';
25
28
  export { SCHEMA_VERSION, } from './scene/types.js';
26
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';
27
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
@@ -18,5 +18,7 @@ export { loadWorldScene, preloadManifest, preloadSceneAssets, getManifest, SCENE
18
18
  export { spawnEntity, parseColor } from './scene/spawnEntity.js';
19
19
  export { EntityRegistry, attachEntityRegistry, getEntityRegistry, } from './scene/EntityRegistry.js';
20
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';
21
23
  export { SCHEMA_VERSION, } from './scene/types.js';
22
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;
@@ -1,3 +1,17 @@
1
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
+ */
2
16
  export declare function setupEditorModeListener(game: Phaser.Game): void;
3
17
  export declare function isEditMode(game: Phaser.Game): boolean;
@@ -1,57 +1,22 @@
1
+ import { setupEditorBridge } from '../editor/EditorBridge.js';
2
+ import { getEditorState } from '../editor/EditorState.js';
1
3
  /**
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.
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).
5
7
  *
6
- * Wire shape (host → SDK only — no reply expected):
7
- * { type: 'unboxy:setEditMode', enabled: boolean }
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
8
12
  *
9
- * Boot scenes are skipped pausing them stalls the loader.
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.
10
16
  */
11
- const BOOT_SCENE_KEYS = new Set(['BootScene', 'Boot']);
12
- const EDIT_MODE_FLAG = '__unboxyEditMode';
13
17
  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
- });
18
+ setupEditorBridge(game);
26
19
  }
27
20
  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
- }
21
+ return getEditorState(game).active;
57
22
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unboxy/phaser-sdk",
3
- "version": "0.2.17",
3
+ "version": "0.2.18",
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",