@unboxy/phaser-sdk 0.2.20 → 0.2.22

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,9 @@ 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.
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.
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.
641
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).
642
645
  - **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.
643
646
  - **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.
@@ -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,5 +1,7 @@
1
+ import Phaser from 'phaser';
1
2
  import { getEntityRegistry } from '../scene/EntityRegistry.js';
2
- import { parseColor } from '../scene/spawnEntity.js';
3
+ import { parseColor, spawnEntity } from '../scene/spawnEntity.js';
4
+ import { resolveRenderScript } from '../scene/renderScripts.js';
3
5
  import { EditorOverlayScene, EDITOR_OVERLAY_KEY } from './EditorOverlayScene.js';
4
6
  import { getEditorState, setEditorActive, setSelection, } from './EditorState.js';
5
7
  /**
@@ -53,6 +55,12 @@ function handleMessage(game, msg) {
53
55
  case 'unboxy:editor:panZoom':
54
56
  applyPanZoom(game, msg);
55
57
  break;
58
+ case 'unboxy:editor:createEntity':
59
+ void createEntity(game, msg.entity, msg.manifestAsset);
60
+ break;
61
+ case 'unboxy:editor:deleteEntity':
62
+ deleteEntity(game, msg.entityId);
63
+ break;
56
64
  }
57
65
  }
58
66
  // --- Enter / exit ---------------------------------------------------------
@@ -151,9 +159,19 @@ function postSceneSnapshot(game) {
151
159
  type: 'unboxy:editor:sceneLoaded',
152
160
  sceneId: sceneFile.id,
153
161
  sceneFile,
162
+ manifest: readActiveManifest(game),
154
163
  });
155
164
  game[SNAPSHOT_POSTED_FLAG] = sceneFile.id;
156
165
  }
166
+ function readActiveManifest(game) {
167
+ for (const scene of game.scene.getScenes(false)) {
168
+ const cache = scene.cache.json;
169
+ const m = cache.entries?.entries?.['unboxy:manifest'];
170
+ if (m)
171
+ return m;
172
+ }
173
+ return undefined;
174
+ }
157
175
  function hasPostedSnapshot(game) {
158
176
  return !!game[SNAPSHOT_POSTED_FLAG];
159
177
  }
@@ -225,6 +243,7 @@ function applyEdit(game, entityId, patch) {
225
243
  return;
226
244
  applyTransformPatch(go, patch.transform);
227
245
  applyVisualPatch(go, patch.visual);
246
+ applyCodeRenderedParamsPatch(game, go, patch.visual?.params);
228
247
  if (patch.role !== undefined) {
229
248
  if (patch.role === null)
230
249
  go.setData('entityRole', undefined);
@@ -235,6 +254,27 @@ function applyEdit(game, entityId, patch) {
235
254
  go.setData('entityProperties', patch.properties);
236
255
  }
237
256
  }
257
+ /**
258
+ * Re-render a code-rendered entity's visual when its params change.
259
+ * Slice 3.5 — Inspector params editor produces these patches.
260
+ *
261
+ * No-op for non-code-rendered entities (no renderScriptPath in data) so
262
+ * sprite/primitive patches that incidentally include `visual.params` (the
263
+ * type allows it) flow through harmlessly.
264
+ */
265
+ function applyCodeRenderedParamsPatch(game, go, newParams) {
266
+ if (newParams === undefined)
267
+ return;
268
+ const scriptPath = go.getData('renderScriptPath');
269
+ if (!scriptPath)
270
+ return; // not a code-rendered entity
271
+ const render = resolveRenderScript(game, scriptPath);
272
+ if (!render)
273
+ return;
274
+ const g = go;
275
+ render(g, newParams);
276
+ go.setData('renderScriptParams', newParams);
277
+ }
238
278
  function applyTransformPatch(go, t) {
239
279
  if (!t)
240
280
  return;
@@ -292,6 +332,150 @@ function applyVisualPatch(go, v) {
292
332
  }
293
333
  }
294
334
  }
335
+ // --- Create / delete ------------------------------------------------------
336
+ /**
337
+ * Spawn a new entity into the active world scene. Slice 3.
338
+ *
339
+ * If the entity references an asset whose texture isn't yet in the Phaser
340
+ * cache, we lazy-load it before spawn — same pattern as SceneLoader's
341
+ * preloadSceneAssets, but on a single asset and at edit time. Without this
342
+ * the host would have to coordinate a build before showing a dropped
343
+ * sprite, which defeats the point of drag-to-place.
344
+ */
345
+ async function createEntity(game, entity, manifestAsset) {
346
+ const scene = findWorldScene(game);
347
+ if (!scene) {
348
+ console.warn('[unboxy/editor] createEntity: no world scene to spawn into');
349
+ return;
350
+ }
351
+ const registry = getEntityRegistry(scene);
352
+ if (!registry) {
353
+ console.warn('[unboxy/editor] createEntity: world scene has no entity registry');
354
+ return;
355
+ }
356
+ // Lazy-load texture if needed (sprite + asset not yet in cache).
357
+ if (manifestAsset &&
358
+ entity.kind === 'sprite' &&
359
+ !scene.textures.exists(manifestAsset.textureKey)) {
360
+ await loadAssetIntoScene(scene, manifestAsset);
361
+ }
362
+ const ctx = {
363
+ scene,
364
+ registry,
365
+ resolveAsset: (id) => {
366
+ // For ad-hoc creation we may not have full manifest access. Rely on
367
+ // the host to have stamped the right textureKey/path into the asset
368
+ // we received via manifestAsset; fallback to a synthetic record so
369
+ // spawnEntity can still find a textureKey.
370
+ if (manifestAsset && manifestAsset.id === id)
371
+ return manifestAsset;
372
+ throw new Error(`[unboxy/editor] createEntity: asset '${id}' not in manifest payload — host must include manifestAsset`);
373
+ },
374
+ resolveRenderScript: undefined,
375
+ };
376
+ try {
377
+ spawnEntity(ctx, entity);
378
+ }
379
+ catch (e) {
380
+ console.warn('[unboxy/editor] spawnEntity failed:', e);
381
+ }
382
+ }
383
+ function loadAssetIntoScene(scene, asset) {
384
+ return new Promise((resolve, reject) => {
385
+ if (scene.textures.exists(asset.textureKey)) {
386
+ resolve();
387
+ return;
388
+ }
389
+ const onComplete = () => {
390
+ cleanup();
391
+ resolve();
392
+ };
393
+ const onError = (file) => {
394
+ if (file.key !== asset.textureKey)
395
+ return;
396
+ cleanup();
397
+ reject(new Error(`failed to load asset ${asset.id}: ${file.url}`));
398
+ };
399
+ const cleanup = () => {
400
+ scene.load.off(Phaser.Loader.Events.FILE_COMPLETE, perFileComplete);
401
+ scene.load.off(Phaser.Loader.Events.FILE_LOAD_ERROR, onError);
402
+ scene.load.off(Phaser.Loader.Events.COMPLETE, onComplete);
403
+ };
404
+ const perFileComplete = (key) => {
405
+ if (key === asset.textureKey) {
406
+ cleanup();
407
+ resolve();
408
+ }
409
+ };
410
+ scene.load.on(Phaser.Loader.Events.FILE_COMPLETE, perFileComplete);
411
+ scene.load.on(Phaser.Loader.Events.FILE_LOAD_ERROR, onError);
412
+ scene.load.on(Phaser.Loader.Events.COMPLETE, onComplete);
413
+ switch (asset.kind) {
414
+ case 'image':
415
+ scene.load.image(asset.textureKey, asset.path);
416
+ break;
417
+ case 'spritesheet':
418
+ if (asset.spriteSheetConfig) {
419
+ scene.load.spritesheet(asset.textureKey, asset.path, asset.spriteSheetConfig);
420
+ }
421
+ else {
422
+ scene.load.image(asset.textureKey, asset.path);
423
+ }
424
+ break;
425
+ case 'atlas':
426
+ if (asset.atlasPath && asset.atlasFormat === 'xml') {
427
+ scene.load.atlasXML(asset.textureKey, asset.path, asset.atlasPath);
428
+ }
429
+ else if (asset.atlasPath) {
430
+ scene.load.atlas(asset.textureKey, asset.path, asset.atlasPath);
431
+ }
432
+ break;
433
+ case 'audio':
434
+ scene.load.audio(asset.textureKey, asset.path);
435
+ break;
436
+ default:
437
+ cleanup();
438
+ reject(new Error(`unsupported asset kind for editor lazy-load: ${asset.kind}`));
439
+ return;
440
+ }
441
+ if (!scene.load.isLoading())
442
+ scene.load.start();
443
+ });
444
+ }
445
+ function deleteEntity(game, entityId) {
446
+ const scene = findWorldScene(game);
447
+ if (!scene)
448
+ return;
449
+ const registry = getEntityRegistry(scene);
450
+ if (!registry)
451
+ return;
452
+ const go = registry.byId(entityId);
453
+ if (!go)
454
+ return;
455
+ // Remove from registry by clearing + rebuilding the entries we care about.
456
+ // The slice-1 EntityRegistry doesn't expose a direct removeById; the
457
+ // simplest thing is to destroy the GameObject and let stale registry
458
+ // entries dangle until the next scene reload (post-flush). Slice 3.5
459
+ // can add an explicit `registry.unregister` if it becomes a problem.
460
+ go.destroy();
461
+ // Drop selection if it pointed at this entity.
462
+ const sel = game;
463
+ const state = sel['__unboxyEditorState'];
464
+ if (state && state.selectedId === entityId)
465
+ state.selectedId = null;
466
+ }
467
+ function findWorldScene(game) {
468
+ for (const scene of game.scene.getScenes(false)) {
469
+ const key = scene.scene.key;
470
+ if (BOOT_SCENE_KEYS.has(key))
471
+ continue;
472
+ if (key === EDITOR_OVERLAY_KEY)
473
+ continue;
474
+ if (getEntityRegistry(scene))
475
+ return scene;
476
+ }
477
+ return undefined;
478
+ }
295
479
  // --- Pan / zoom -----------------------------------------------------------
296
480
  function applyPanZoom(game, msg) {
297
481
  // Apply to BOTH the world scene's camera (so the entity rendering moves)
package/dist/index.d.ts CHANGED
@@ -22,9 +22,11 @@ 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
- export type { EditorEntityPatch, EditorEnterMessage, EditorExitMessage, EditorGetSceneMessage, EditorApplyEditMessage, EditorSetSelectionMessage, EditorPanZoomMessage, EditorHostToSdkMessage, EditorSceneLoadedMessage, EditorSelectionPickedMessage, EditorDragEndMessage, EditorShortcutMessage, EditorSdkToHostMessage, } from './protocol.js';
29
+ export type { EditorEntityPatch, EditorEnterMessage, EditorExitMessage, EditorGetSceneMessage, EditorApplyEditMessage, EditorSetSelectionMessage, EditorPanZoomMessage, EditorHostToSdkMessage, EditorSceneLoadedMessage, EditorSelectionPickedMessage, EditorDragEndMessage, EditorShortcutMessage, EditorCreateEntityMessage, EditorDeleteEntityMessage, EditorSdkToHostMessage, } from './protocol.js';
28
30
  export { SCHEMA_VERSION, } from './scene/types.js';
29
- export type { Manifest, SceneRef, HudRef, AssetRecord, AssetKind, SceneType, SceneFile, WorldScene, HudScene, WorldSceneConfig, CameraConfig, WorldEntity, WorldEntityKind, NonGroupWorldEntity, SpriteEntity, PrimitiveEntity, CodeRenderedEntity, GroupEntity, TilemapEntity, TriggerEntity, WorldVisual, SpriteVisual, PrimitiveVisual, PrimitiveRectVisual, PrimitiveCircleVisual, CodeRenderedVisual, Transform, Anchor, AnchorSide, } from './scene/types.js';
31
+ export type { Manifest, SceneRef, HudRef, AssetRecord, AssetKind, SceneType, SceneFile, WorldScene, HudScene, WorldSceneConfig, CameraConfig, WorldEntity, WorldEntityKind, NonGroupWorldEntity, SpriteEntity, PrimitiveEntity, CodeRenderedEntity, GroupEntity, TilemapEntity, TriggerEntity, TriggerShape, WorldVisual, SpriteVisual, PrimitiveVisual, PrimitiveRectVisual, PrimitiveCircleVisual, CodeRenderedVisual, Transform, Anchor, AnchorSide, } from './scene/types.js';
30
32
  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,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>;
@@ -108,12 +114,35 @@ export interface EditorPanZoomMessage {
108
114
  /** If true, deltas are added to current scroll. */
109
115
  relative?: boolean;
110
116
  }
111
- export type EditorHostToSdkMessage = EditorEnterMessage | EditorExitMessage | EditorGetSceneMessage | EditorApplyEditMessage | EditorSetSelectionMessage | EditorPanZoomMessage;
117
+ /**
118
+ * Create a new entity in the active scene (slice 3 — drag-to-place).
119
+ * The SDK picks an asset record from `manifestAsset` (if present) so the
120
+ * texture can be lazy-loaded before spawn. Position can be supplied as
121
+ * world coords (preferred — host computed via 1:1 mapping in slice 3) or
122
+ * skipped if the entity already carries a transform.
123
+ */
124
+ export interface EditorCreateEntityMessage {
125
+ type: 'unboxy:editor:createEntity';
126
+ /** Full entity record. transform.x/y is authoritative. */
127
+ entity: unknown;
128
+ /** Asset record to add to runtime cache before spawn. Omit if assetId is already loaded. */
129
+ manifestAsset?: unknown;
130
+ }
131
+ export interface EditorDeleteEntityMessage {
132
+ type: 'unboxy:editor:deleteEntity';
133
+ entityId: string;
134
+ }
135
+ export type EditorHostToSdkMessage = EditorEnterMessage | EditorExitMessage | EditorGetSceneMessage | EditorApplyEditMessage | EditorSetSelectionMessage | EditorPanZoomMessage | EditorCreateEntityMessage | EditorDeleteEntityMessage;
112
136
  export interface EditorSceneLoadedMessage {
113
137
  type: 'unboxy:editor:sceneLoaded';
114
138
  sceneId: string;
115
139
  /** Snapshot of the world scene file's current entities + camera. */
116
140
  sceneFile: unknown;
141
+ /**
142
+ * Snapshot of the manifest (asset table + scene list). Slice 3+ — home-ui
143
+ * needs this to mutate the manifest when drag-to-place adds a new asset.
144
+ */
145
+ manifest?: unknown;
117
146
  }
118
147
  export interface EditorSelectionPickedMessage {
119
148
  /** Pointer-down on an entity in the canvas — host should update selection. */
@@ -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
+ }
@@ -19,9 +19,11 @@ export function spawnEntity(ctx, entity) {
19
19
  go = createCodeRendered(ctx, entity);
20
20
  break;
21
21
  case 'tilemap':
22
- throw new Error(`[unboxy/scene] tilemap entity ('${entity.id}') not supported in slice 1 — see 06-tilemap.md`);
22
+ go = createTilemapStub(ctx, entity);
23
+ break;
23
24
  case 'trigger':
24
- throw new Error(`[unboxy/scene] trigger entity ('${entity.id}') not supported in slice 1 — see 03-world-editor.md`);
25
+ go = createTriggerStub(ctx, entity);
26
+ break;
25
27
  default: {
26
28
  const exhaustive = entity;
27
29
  throw new Error(`[unboxy/scene] unknown entity kind: ${JSON.stringify(exhaustive)}`);
@@ -94,10 +96,25 @@ function createGroup(ctx, entity) {
94
96
  function createCodeRendered(ctx, entity) {
95
97
  const render = ctx.resolveRenderScript?.(entity.visual.script);
96
98
  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)`);
99
+ // Slice 3.5: render scripts have a registry now, but a missing entry
100
+ // shouldn't crash the whole scene boot — the agent might be writing
101
+ // the script in a follow-up turn. Render a clear "?" placeholder so
102
+ // the user sees the entity exists but the visual is unresolved.
103
+ const ph = ctx.scene.add.graphics();
104
+ ph.fillStyle(0x666666, 0.4);
105
+ ph.fillRect(-16, -16, 32, 32);
106
+ ph.lineStyle(2, 0xff8800, 0.9);
107
+ ph.strokeRect(-16, -16, 32, 32);
108
+ // Annotate so editor / debug overlays can identify.
109
+ ph.setData('renderScriptMissing', entity.visual.script);
110
+ return ph;
98
111
  }
99
112
  const g = ctx.scene.add.graphics();
100
- render(g, entity.visual.params ?? {});
113
+ const params = entity.visual.params ?? {};
114
+ render(g, params);
115
+ // Stash data needed for live re-render from EditorBridge.applyEdit.
116
+ g.setData('renderScriptPath', entity.visual.script);
117
+ g.setData('renderScriptParams', params);
101
118
  return g;
102
119
  }
103
120
  function applyTransform(go, t) {
@@ -122,6 +139,61 @@ function tagGameObject(go, entity) {
122
139
  if (entity.properties)
123
140
  go.setData('entityProperties', entity.properties);
124
141
  }
142
+ /**
143
+ * Trigger stub renderer — slice 3. Renders the trigger zone as a
144
+ * semi-transparent fill with cyan tint per design 03 §8.2 (in edit mode it
145
+ * is always visible; at play time the SDK will hide it once slice 5 wires
146
+ * the behavior layer).
147
+ */
148
+ function createTriggerStub(ctx, entity) {
149
+ const TINT = 0x00bfff; // cyan-ish "neutral trigger" per design
150
+ const FILL_ALPHA = 0.25;
151
+ const STROKE_ALPHA = 0.7;
152
+ if (entity.shape.kind === 'rect') {
153
+ const r = ctx.scene.add.rectangle(0, 0, entity.shape.width, entity.shape.height, TINT, FILL_ALPHA);
154
+ r.setStrokeStyle(1.5, TINT, STROKE_ALPHA);
155
+ return r;
156
+ }
157
+ // circle
158
+ const c = ctx.scene.add.circle(0, 0, entity.shape.radius, TINT, FILL_ALPHA);
159
+ c.setStrokeStyle(1.5, TINT, STROKE_ALPHA);
160
+ return c;
161
+ }
162
+ /**
163
+ * Tilemap stub renderer — slice 3. Draws a translucent grid sketch of the
164
+ * tilemap's bounds + cell lines so the user can see where the tilemap is
165
+ * and how big it is. Real tilemap rendering (with tilesets, autotile,
166
+ * Y-sort, etc.) lands in slice 6.
167
+ */
168
+ function createTilemapStub(ctx, entity) {
169
+ const g = ctx.scene.add.graphics();
170
+ const w = entity.size.width * entity.tileSize.width;
171
+ const h = entity.size.height * entity.tileSize.height;
172
+ g.fillStyle(0xffffff, 0.04);
173
+ g.fillRect(0, 0, w, h);
174
+ // Outer border
175
+ g.lineStyle(2, 0xaaaaaa, 0.6);
176
+ g.strokeRect(0, 0, w, h);
177
+ // Cell grid — only draw if cells aren't too tiny (perf cap).
178
+ if (entity.tileSize.width >= 8 && entity.size.width * entity.size.height < 4000) {
179
+ g.lineStyle(1, 0xaaaaaa, 0.15);
180
+ for (let i = 1; i < entity.size.width; i++) {
181
+ const x = i * entity.tileSize.width;
182
+ g.beginPath();
183
+ g.moveTo(x, 0);
184
+ g.lineTo(x, h);
185
+ g.strokePath();
186
+ }
187
+ for (let i = 1; i < entity.size.height; i++) {
188
+ const y = i * entity.tileSize.height;
189
+ g.beginPath();
190
+ g.moveTo(0, y);
191
+ g.lineTo(w, y);
192
+ g.strokePath();
193
+ }
194
+ }
195
+ return g;
196
+ }
125
197
  /**
126
198
  * Accepts `'#rrggbb'`, `'rrggbb'`, or `'0xrrggbb'` and returns a number
127
199
  * suitable for Phaser's color APIs.
@@ -149,19 +149,59 @@ export interface GroupEntity extends WorldEntityBase {
149
149
  children: NonGroupWorldEntity[];
150
150
  }
151
151
  /**
152
- * v1 placeholderschema 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.
152
+ * Tilemap entityslice 3 ships a stub renderer (placeholder rectangle).
153
+ * Full tilemap painting + autotile lands in slice 6 (06-tilemap.md).
155
154
  */
156
155
  export interface TilemapEntity extends WorldEntityBase {
157
156
  kind: 'tilemap';
158
- /** Opaque tilemap payload — defined in `06-tilemap.md`. */
159
- data: unknown;
157
+ tileSize: {
158
+ width: number;
159
+ height: number;
160
+ };
161
+ size: {
162
+ width: number;
163
+ height: number;
164
+ };
165
+ layers: Array<{
166
+ id: string;
167
+ name?: string;
168
+ tilesetIds?: string[];
169
+ z?: number;
170
+ /** 2D array of tile ids; null cells == empty. */
171
+ data?: Array<Array<string | null>>;
172
+ visible?: boolean;
173
+ locked?: boolean;
174
+ autotile?: {
175
+ kind: 'wang-4bit';
176
+ rules?: 'auto-detected' | unknown;
177
+ };
178
+ ySort?: boolean;
179
+ }>;
160
180
  }
181
+ export type TriggerShape = {
182
+ kind: 'rect';
183
+ width: number;
184
+ height: number;
185
+ } | {
186
+ kind: 'circle';
187
+ radius: number;
188
+ };
189
+ /**
190
+ * Trigger entity — slice 3 ships a stub renderer (semi-transparent rect/circle
191
+ * with the design's cyan tint). Full behavior wiring (description / targets /
192
+ * behavior script) lands in slice 5 (03-world-editor.md §8).
193
+ */
161
194
  export interface TriggerEntity extends WorldEntityBase {
162
195
  kind: 'trigger';
163
- /** Opaque trigger payload — defined in `03-world-editor.md`. */
164
- data: unknown;
196
+ shape: TriggerShape;
197
+ /** User-facing label shown in Hierarchy + on canvas. */
198
+ description?: string;
199
+ /** Entity ids this trigger affects. Slice 5 wires the relationship UI. */
200
+ targets?: string[];
201
+ /** Optional path to behavior script. Slice 5 fills this in. */
202
+ behaviorScript?: string;
203
+ /** Optional preset name (open-door / damage-zone / scene-transition / spawner / heal). */
204
+ preset?: string;
165
205
  }
166
206
  export type NonGroupWorldEntity = SpriteEntity | PrimitiveEntity | CodeRenderedEntity | TilemapEntity | TriggerEntity;
167
207
  export type WorldEntity = NonGroupWorldEntity | GroupEntity;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unboxy/phaser-sdk",
3
- "version": "0.2.20",
3
+ "version": "0.2.22",
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",