@unboxy/phaser-sdk 0.2.21 → 0.2.23

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.22** — render-script registry wired (visual editor slice 3.5). `createUnboxyGame` accepts a new `renderScripts: Record<path, RenderScriptModule>` option; templates build it via `import.meta.glob('./visuals/*.ts', { eager: true })`. `loadWorldScene` falls back to that registry when no explicit `resolveRenderScript` is passed, so games don't have to plumb it through every scene call. Missing scripts no longer crash the scene boot — `spawnEntity` renders a clear orange-bordered "?" placeholder instead and tags the GameObject with `renderScriptMissing` for debug. `applyEdit` now handles `visual.params` patches on `code-rendered` entities by re-calling render with merged params (live editor preview as you tune in the Inspector). New exports: `RenderScriptModule`, `setRenderScriptRegistry`, `getRenderScriptRegistry`, `resolveRenderScript`. Pairs with the `phaser-render-script` agent skill which teaches the pure-function contract.
641
642
  - **0.2.21** — visual editor slice 3 (drag-to-place). Formalized trigger + tilemap entity data shapes (TriggerEntity now has `shape: rect|circle`, `description`, `targets[]`; TilemapEntity has `tileSize`, `size`, `layers[]`). spawnEntity renders both as placeholders (trigger = semi-transparent cyan rect/circle, tilemap = translucent grid sketch — full features ship in slices 5+6). New protocol: `unboxy:editor:createEntity { entity, manifestAsset? }` (host-side drag-to-place spawns entity at world coord; lazy-loads asset texture if needed) + `unboxy:editor:deleteEntity { entityId }` (Backspace + undo of create). `sceneLoaded` now also carries the manifest snapshot so home-ui can mutate the asset table when a new asset gets dropped on the canvas.
642
643
  - **0.2.20** — added editor shortcut forwarding (Cmd+Z/Y/S land in iframe when canvas focused; SDK posts back to host via `unboxy:editor:shortcut`). Without this, native shortcuts didn't reach the host's keydown listener after the user clicked the canvas.
643
644
  - **0.2.19** — fix: editor `enter` arriving while BootScene is still preloading (the post-flush iframe-rebuild path) had nothing to pause, so the world scene resumed running freely once it started up. Now the bridge re-attempts the pause + scene snapshot for ~3 seconds after enter, catching the scene as it transitions from BootScene → GameScene. Snapshot is also re-posted once a scene file lands in cache. Surfaced when slice-2's auto-flush rebuilt the iframe and the user found the sprite started moving again post-save (2026-05-07).
@@ -1,5 +1,6 @@
1
1
  import Phaser from 'phaser';
2
2
  import { type Orientation } from '../orientation.js';
3
+ import { RenderScriptModule } from '../scene/renderScripts.js';
3
4
  interface UnboxyGameBaseOptions {
4
5
  /** Phaser scene classes to register */
5
6
  scenes: (new (...args: any[]) => Phaser.Scene)[];
@@ -11,6 +12,13 @@ interface UnboxyGameBaseOptions {
11
12
  physics?: Phaser.Types.Core.PhysicsConfig;
12
13
  /** Phaser plugin registrations (e.g. `phaser3-rex-plugins` virtual joystick) */
13
14
  plugins?: Phaser.Types.Core.PluginObject;
15
+ /**
16
+ * Map of render-script paths → modules that export `render(g, params)`.
17
+ * Templates build this via `import.meta.glob('./visuals/*.ts', { eager: true })`.
18
+ * Used by `code-rendered` entities and consumed inside `loadWorldScene`.
19
+ * Optional — games without code-rendered visuals can omit it.
20
+ */
21
+ renderScripts?: Record<string, RenderScriptModule>;
14
22
  }
15
23
  /**
16
24
  * Either pass `orientation` (preset dims from ORIENTATION_DIMENSIONS) OR
@@ -3,6 +3,7 @@ import { setupScreenshotListener } from '../screenshot/ScreenshotManager.js';
3
3
  import { setupRecordingListener } from '../recording/RecordingManager.js';
4
4
  import { setupEditorModeListener } from '../scene/EditorMode.js';
5
5
  import { ORIENTATION_DIMENSIONS } from '../orientation.js';
6
+ import { setRenderScriptRegistry, } from '../scene/renderScripts.js';
6
7
  /**
7
8
  * Create an Unboxy-enhanced Phaser game instance.
8
9
  * Includes built-in integrations: screenshot capture, preserveDrawingBuffer, etc.
@@ -36,5 +37,8 @@ export function createUnboxyGame(options) {
36
37
  setupScreenshotListener(game);
37
38
  setupRecordingListener(game);
38
39
  setupEditorModeListener(game);
40
+ if (options.renderScripts) {
41
+ setRenderScriptRegistry(game, options.renderScripts);
42
+ }
39
43
  return game;
40
44
  }
@@ -1,6 +1,7 @@
1
1
  import Phaser from 'phaser';
2
2
  import { getEntityRegistry } from '../scene/EntityRegistry.js';
3
3
  import { parseColor, spawnEntity } from '../scene/spawnEntity.js';
4
+ import { resolveRenderScript } from '../scene/renderScripts.js';
4
5
  import { EditorOverlayScene, EDITOR_OVERLAY_KEY } from './EditorOverlayScene.js';
5
6
  import { getEditorState, setEditorActive, setSelection, } from './EditorState.js';
6
7
  /**
@@ -208,15 +209,15 @@ function hitTest(game, worldX, worldY) {
208
209
  let topmost = null;
209
210
  let topmostDepth = -Infinity;
210
211
  for (const go of registry.all()) {
211
- const withBounds = go;
212
- if (typeof withBounds.getBounds !== 'function')
212
+ const r = entityHitRect(go);
213
+ if (!r)
213
214
  continue;
214
- const r = withBounds.getBounds();
215
215
  if (worldX < r.x || worldX > r.x + r.width)
216
216
  continue;
217
217
  if (worldY < r.y || worldY > r.y + r.height)
218
218
  continue;
219
- const depth = typeof withBounds.depth === 'number' ? withBounds.depth : 0;
219
+ const withDepth = go;
220
+ const depth = typeof withDepth.depth === 'number' ? withDepth.depth : 0;
220
221
  if (depth >= topmostDepth) {
221
222
  topmostDepth = depth;
222
223
  topmost = go;
@@ -224,6 +225,37 @@ function hitTest(game, worldX, worldY) {
224
225
  }
225
226
  return topmost;
226
227
  }
228
+ /**
229
+ * Compute a hit-test rectangle for a spawned entity in world coords.
230
+ *
231
+ * 1. If the entity has `editorHitWidth` / `editorHitHeight` set in data
232
+ * (code-rendered entities — Graphics has no intrinsic bounds), use
233
+ * those centered on the entity's x/y.
234
+ * 2. Otherwise fall back to Phaser's `getBounds()` (works for Sprite,
235
+ * Rectangle, Arc, Container — anything with a Size component).
236
+ *
237
+ * Returns null if neither path yields a usable rect.
238
+ */
239
+ function entityHitRect(go) {
240
+ const hitW = go.getData('editorHitWidth');
241
+ const hitH = go.getData('editorHitHeight');
242
+ if (typeof hitW === 'number' && typeof hitH === 'number') {
243
+ const positioned = go;
244
+ return {
245
+ x: positioned.x - hitW / 2,
246
+ y: positioned.y - hitH / 2,
247
+ width: hitW,
248
+ height: hitH,
249
+ };
250
+ }
251
+ const withBounds = go;
252
+ if (typeof withBounds.getBounds !== 'function')
253
+ return null;
254
+ const r = withBounds.getBounds();
255
+ if (r.width === 0 && r.height === 0)
256
+ return null;
257
+ return { x: r.x, y: r.y, width: r.width, height: r.height };
258
+ }
227
259
  function findRegistry(game) {
228
260
  for (const scene of game.scene.getScenes(false)) {
229
261
  const reg = getEntityRegistry(scene);
@@ -242,6 +274,7 @@ function applyEdit(game, entityId, patch) {
242
274
  return;
243
275
  applyTransformPatch(go, patch.transform);
244
276
  applyVisualPatch(go, patch.visual);
277
+ applyCodeRenderedParamsPatch(game, go, patch.visual?.params);
245
278
  if (patch.role !== undefined) {
246
279
  if (patch.role === null)
247
280
  go.setData('entityRole', undefined);
@@ -252,6 +285,27 @@ function applyEdit(game, entityId, patch) {
252
285
  go.setData('entityProperties', patch.properties);
253
286
  }
254
287
  }
288
+ /**
289
+ * Re-render a code-rendered entity's visual when its params change.
290
+ * Slice 3.5 — Inspector params editor produces these patches.
291
+ *
292
+ * No-op for non-code-rendered entities (no renderScriptPath in data) so
293
+ * sprite/primitive patches that incidentally include `visual.params` (the
294
+ * type allows it) flow through harmlessly.
295
+ */
296
+ function applyCodeRenderedParamsPatch(game, go, newParams) {
297
+ if (newParams === undefined)
298
+ return;
299
+ const scriptPath = go.getData('renderScriptPath');
300
+ if (!scriptPath)
301
+ return; // not a code-rendered entity
302
+ const render = resolveRenderScript(game, scriptPath);
303
+ if (!render)
304
+ return;
305
+ const g = go;
306
+ render(g, newParams);
307
+ go.setData('renderScriptParams', newParams);
308
+ }
255
309
  function applyTransformPatch(go, t) {
256
310
  if (!t)
257
311
  return;
@@ -190,10 +190,24 @@ export class EditorOverlayScene extends Phaser.Scene {
190
190
  }
191
191
  }
192
192
  function computeBounds(go) {
193
- // Most game objects implement getBounds; Container does too.
193
+ // Code-rendered entities stash hit dimensions on data because Phaser
194
+ // Graphics has no intrinsic bounds. Use those when present.
195
+ const hitW = go.getData('editorHitWidth');
196
+ const hitH = go.getData('editorHitHeight');
197
+ if (typeof hitW === 'number' && typeof hitH === 'number') {
198
+ const positioned = go;
199
+ return {
200
+ x: positioned.x - hitW / 2,
201
+ y: positioned.y - hitH / 2,
202
+ width: hitW,
203
+ height: hitH,
204
+ };
205
+ }
194
206
  const withBounds = go;
195
207
  if (typeof withBounds.getBounds !== 'function')
196
208
  return null;
197
209
  const r = withBounds.getBounds();
210
+ if (r.width === 0 && r.height === 0)
211
+ return null;
198
212
  return { x: r.x, y: r.y, width: r.width, height: r.height };
199
213
  }
package/dist/index.d.ts CHANGED
@@ -22,6 +22,8 @@ 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 { setRenderScriptRegistry, getRenderScriptRegistry, resolveRenderScript, } from './scene/renderScripts.js';
26
+ export type { RenderScriptModule } from './scene/renderScripts.js';
25
27
  export { setupEditorBridge } from './editor/EditorBridge.js';
26
28
  export { EditorOverlayScene, EDITOR_OVERLAY_KEY } from './editor/EditorOverlayScene.js';
27
29
  export type { EditorEntityPatch, EditorEnterMessage, EditorExitMessage, EditorGetSceneMessage, EditorApplyEditMessage, EditorSetSelectionMessage, EditorPanZoomMessage, EditorHostToSdkMessage, EditorSceneLoadedMessage, EditorSelectionPickedMessage, EditorDragEndMessage, EditorShortcutMessage, EditorCreateEntityMessage, EditorDeleteEntityMessage, EditorSdkToHostMessage, } from './protocol.js';
package/dist/index.js CHANGED
@@ -18,6 +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 { setRenderScriptRegistry, getRenderScriptRegistry, resolveRenderScript, } from './scene/renderScripts.js';
21
22
  export { setupEditorBridge } from './editor/EditorBridge.js';
22
23
  export { EditorOverlayScene, EDITOR_OVERLAY_KEY } from './editor/EditorOverlayScene.js';
23
24
  export { SCHEMA_VERSION, } from './scene/types.js';
@@ -74,6 +74,12 @@ export interface EditorEntityPatch {
74
74
  fillColor?: string;
75
75
  strokeColor?: string | null;
76
76
  strokeWidth?: number;
77
+ /**
78
+ * For `code-rendered` entities — the entire params object replaces
79
+ * `visual.params`. SDK re-calls `render(g, params)` on receipt.
80
+ * Slice 3.5.
81
+ */
82
+ params?: Record<string, unknown>;
77
83
  };
78
84
  role?: string | null;
79
85
  properties?: Record<string, unknown>;
@@ -2,6 +2,7 @@ import Phaser from 'phaser';
2
2
  import { SCHEMA_VERSION, } from './types.js';
3
3
  import { spawnEntity } from './spawnEntity.js';
4
4
  import { attachEntityRegistry } from './EntityRegistry.js';
5
+ import { resolveRenderScript } from './renderScripts.js';
5
6
  /**
6
7
  * Where scene files live in the workspace and at runtime. The Vite build
7
8
  * copies `public/` to the served root, so paths are origin-relative
@@ -179,7 +180,8 @@ export async function loadWorldScene(scene, sceneId, options = {}) {
179
180
  scene,
180
181
  registry,
181
182
  resolveAsset: (id) => resolveAsset(manifest, id),
182
- resolveRenderScript: options.resolveRenderScript,
183
+ resolveRenderScript: options.resolveRenderScript ??
184
+ ((path) => resolveRenderScript(scene.game, path)),
183
185
  };
184
186
  for (const entity of sceneFile.entities)
185
187
  spawnEntity(ctx, entity);
@@ -0,0 +1,53 @@
1
+ import Phaser from 'phaser';
2
+ /**
3
+ * Render-script module shape — slice 3.5.
4
+ *
5
+ * Spec: `unboxy-design/features/visual-editor/02-render-scripts.md`.
6
+ *
7
+ * A `code-rendered` entity references a render script by path. The script's
8
+ * `render` is a **pure function** that draws into a Phaser Graphics object
9
+ * given a parameters object. The function:
10
+ *
11
+ * - Calls `g.clear()` first (idempotent re-renders).
12
+ * - Has no time/random dependency (motion is in tween system, not here).
13
+ * - Holds no module state.
14
+ *
15
+ * Optional exports the editor / SDK uses if present:
16
+ *
17
+ * - `defaultParams` — used as a starting point for new entities.
18
+ * - `paramSchema` — drives the Inspector's auto-generated UI (slice 4+).
19
+ *
20
+ * The script registry is built by the template (`import.meta.glob`), passed
21
+ * to `createUnboxyGame({ renderScripts })`, and resolved by path string at
22
+ * spawn time. Path strings in scene files match the registry keys exactly:
23
+ * usually `src/visuals/<name>.ts`.
24
+ */
25
+ export interface RenderScriptModule<P extends Record<string, unknown> = Record<string, unknown>> {
26
+ render: (g: Phaser.GameObjects.Graphics, params: P) => void;
27
+ defaultParams?: P;
28
+ paramSchema?: unknown;
29
+ }
30
+ /**
31
+ * Stash a render-script registry on the Phaser game so `loadWorldScene` /
32
+ * `EditorBridge.applyEdit` can find it without explicit plumbing.
33
+ *
34
+ * The map's keys are absolute paths matching what scene files reference
35
+ * (e.g. `src/visuals/coin.ts`). Templates that build via `import.meta.glob`
36
+ * commonly produce relative keys like `./visuals/coin.ts`; the resolver
37
+ * normalises trailing slashes / leading `./` so both shapes work.
38
+ */
39
+ export declare function setRenderScriptRegistry(game: Phaser.Game, scripts: Record<string, RenderScriptModule>): void;
40
+ export declare function getRenderScriptRegistry(game: Phaser.Game): Record<string, RenderScriptModule> | undefined;
41
+ /**
42
+ * Returns the `render` function for a script path, or undefined if no
43
+ * matching module is registered. The matcher is forgiving:
44
+ *
45
+ * - exact match (`src/visuals/coin.ts`)
46
+ * - leading `./` stripped (`./visuals/coin.ts` matches `src/visuals/coin.ts`)
47
+ * - filename-only fallback so renames in the registry that drop the
48
+ * `src/` prefix still resolve.
49
+ *
50
+ * The forgiveness lets templates use whatever import.meta.glob shape they
51
+ * find natural without forcing a one-true-key convention.
52
+ */
53
+ export declare function resolveRenderScript(game: Phaser.Game, scriptPath: string): ((g: Phaser.GameObjects.Graphics, params: Record<string, unknown>) => void) | undefined;
@@ -0,0 +1,67 @@
1
+ const REGISTRY_KEY = '__unboxyRenderScriptRegistry';
2
+ /**
3
+ * Stash a render-script registry on the Phaser game so `loadWorldScene` /
4
+ * `EditorBridge.applyEdit` can find it without explicit plumbing.
5
+ *
6
+ * The map's keys are absolute paths matching what scene files reference
7
+ * (e.g. `src/visuals/coin.ts`). Templates that build via `import.meta.glob`
8
+ * commonly produce relative keys like `./visuals/coin.ts`; the resolver
9
+ * normalises trailing slashes / leading `./` so both shapes work.
10
+ */
11
+ export function setRenderScriptRegistry(game, scripts) {
12
+ const bag = game;
13
+ bag[REGISTRY_KEY] = scripts;
14
+ }
15
+ export function getRenderScriptRegistry(game) {
16
+ const bag = game;
17
+ return bag[REGISTRY_KEY];
18
+ }
19
+ /**
20
+ * Returns the `render` function for a script path, or undefined if no
21
+ * matching module is registered. The matcher is forgiving:
22
+ *
23
+ * - exact match (`src/visuals/coin.ts`)
24
+ * - leading `./` stripped (`./visuals/coin.ts` matches `src/visuals/coin.ts`)
25
+ * - filename-only fallback so renames in the registry that drop the
26
+ * `src/` prefix still resolve.
27
+ *
28
+ * The forgiveness lets templates use whatever import.meta.glob shape they
29
+ * find natural without forcing a one-true-key convention.
30
+ */
31
+ export function resolveRenderScript(game, scriptPath) {
32
+ const registry = getRenderScriptRegistry(game);
33
+ if (!registry)
34
+ return undefined;
35
+ // Direct hit.
36
+ if (registry[scriptPath])
37
+ return registry[scriptPath].render;
38
+ // Try common normalisations.
39
+ const candidates = candidateKeys(scriptPath);
40
+ for (const c of candidates) {
41
+ if (registry[c])
42
+ return registry[c].render;
43
+ }
44
+ // Filename-only fallback.
45
+ const filename = scriptPath.split('/').pop();
46
+ if (filename) {
47
+ for (const key of Object.keys(registry)) {
48
+ if (key.endsWith('/' + filename) || key === filename) {
49
+ return registry[key].render;
50
+ }
51
+ }
52
+ }
53
+ return undefined;
54
+ }
55
+ function candidateKeys(scriptPath) {
56
+ const out = [];
57
+ const trimmed = scriptPath.replace(/^\.\//, '');
58
+ out.push(trimmed);
59
+ // src/visuals/coin.ts → ./visuals/coin.ts
60
+ if (trimmed.startsWith('src/'))
61
+ out.push('./' + trimmed.slice(4));
62
+ // visuals/coin.ts → src/visuals/coin.ts
63
+ if (!trimmed.startsWith('src/') && !trimmed.startsWith('./')) {
64
+ out.push('src/' + trimmed);
65
+ }
66
+ return out;
67
+ }
@@ -94,14 +94,45 @@ function createGroup(ctx, entity) {
94
94
  return container;
95
95
  }
96
96
  function createCodeRendered(ctx, entity) {
97
+ const w = entity.visual.width ?? 64;
98
+ const h = entity.visual.height ?? 64;
97
99
  const render = ctx.resolveRenderScript?.(entity.visual.script);
98
100
  if (!render) {
99
- 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)`);
101
+ // Slice 3.5: render scripts have a registry now, but a missing entry
102
+ // shouldn't crash the whole scene boot — the agent might be writing
103
+ // the script in a follow-up turn. Render a clear "?" placeholder so
104
+ // the user sees the entity exists but the visual is unresolved.
105
+ const ph = ctx.scene.add.graphics();
106
+ ph.fillStyle(0x666666, 0.4);
107
+ ph.fillRect(-w / 2, -h / 2, w, h);
108
+ ph.lineStyle(2, 0xff8800, 0.9);
109
+ ph.strokeRect(-w / 2, -h / 2, w, h);
110
+ sizeForHitTest(ph, w, h);
111
+ ph.setData('renderScriptMissing', entity.visual.script);
112
+ return ph;
100
113
  }
101
114
  const g = ctx.scene.add.graphics();
102
- render(g, entity.visual.params ?? {});
115
+ const params = entity.visual.params ?? {};
116
+ render(g, params);
117
+ sizeForHitTest(g, w, h);
118
+ // Stash data needed for live re-render from EditorBridge.applyEdit.
119
+ g.setData('renderScriptPath', entity.visual.script);
120
+ g.setData('renderScriptParams', params);
103
121
  return g;
104
122
  }
123
+ /**
124
+ * Phaser Graphics doesn't track its drawn area — `getBounds()` returns
125
+ * a 0×0 rect because the Graphics class doesn't include the Size/Origin
126
+ * mixins. Rather than fight Phaser's class hierarchy with method casts,
127
+ * stash the editor hit area on the GameObject's data manager. The editor
128
+ * overlay reads these before falling back to `getBounds()`. The implicit
129
+ * convention: hit area is centered on the GameObject's x/y, matching how
130
+ * render scripts conventionally draw around (0, 0).
131
+ */
132
+ function sizeForHitTest(g, width, height) {
133
+ g.setData('editorHitWidth', width);
134
+ g.setData('editorHitHeight', height);
135
+ }
105
136
  function applyTransform(go, t) {
106
137
  // Most game objects implement Transform; Container does too. Cast through
107
138
  // a permissive shape so this works for sprite/rect/circle/container alike.
@@ -116,6 +116,14 @@ export interface CodeRenderedVisual {
116
116
  /** Path to render script, e.g. `"src/visuals/boss-renderer.ts"`. */
117
117
  script: string;
118
118
  params?: Record<string, unknown>;
119
+ /**
120
+ * Optional bounds used for editor hit-testing (click + drag) and the
121
+ * selection rectangle. Phaser Graphics has no intrinsic size, so we set
122
+ * it explicitly. Defaults to 64×64 centered on the entity. The agent can
123
+ * override per-entity when the script draws something larger or smaller.
124
+ */
125
+ width?: number;
126
+ height?: number;
119
127
  }
120
128
  export type WorldVisual = SpriteVisual | PrimitiveVisual | CodeRenderedVisual;
121
129
  export type WorldEntityKind = 'sprite' | 'primitive' | 'code-rendered' | 'group' | 'tilemap' | 'trigger';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unboxy/phaser-sdk",
3
- "version": "0.2.21",
3
+ "version": "0.2.23",
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",