@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 +3 -0
- package/dist/core/UnboxyGame.d.ts +8 -0
- package/dist/core/UnboxyGame.js +4 -0
- package/dist/editor/EditorBridge.js +185 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.js +1 -0
- package/dist/protocol.d.ts +30 -1
- package/dist/scene/SceneLoader.js +3 -1
- package/dist/scene/renderScripts.d.ts +53 -0
- package/dist/scene/renderScripts.js +67 -0
- package/dist/scene/spawnEntity.js +76 -4
- package/dist/scene/types.d.ts +47 -7
- package/package.json +1 -1
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
|
package/dist/core/UnboxyGame.js
CHANGED
|
@@ -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';
|
package/dist/protocol.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
22
|
+
go = createTilemapStub(ctx, entity);
|
|
23
|
+
break;
|
|
23
24
|
case 'trigger':
|
|
24
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
package/dist/scene/types.d.ts
CHANGED
|
@@ -149,19 +149,59 @@ export interface GroupEntity extends WorldEntityBase {
|
|
|
149
149
|
children: NonGroupWorldEntity[];
|
|
150
150
|
}
|
|
151
151
|
/**
|
|
152
|
-
*
|
|
153
|
-
* tilemap
|
|
154
|
-
* on these kinds so an unmigrated game can't silently render nothing.
|
|
152
|
+
* Tilemap entity — slice 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
|
-
|
|
159
|
-
|
|
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
|
-
|
|
164
|
-
|
|
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;
|