@unboxy/phaser-sdk 0.2.19 → 0.2.21

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,8 @@ The `scene-data-architecture` agent skill has the full guidance. The `scene-data
638
638
 
639
639
  ## Changelog
640
640
 
641
+ - **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
+ - **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
643
  - **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
644
  - **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
645
  - **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
+ 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';
3
4
  import { EditorOverlayScene, EDITOR_OVERLAY_KEY } from './EditorOverlayScene.js';
4
5
  import { getEditorState, setEditorActive, setSelection, } from './EditorState.js';
5
6
  /**
@@ -53,6 +54,12 @@ function handleMessage(game, msg) {
53
54
  case 'unboxy:editor:panZoom':
54
55
  applyPanZoom(game, msg);
55
56
  break;
57
+ case 'unboxy:editor:createEntity':
58
+ void createEntity(game, msg.entity, msg.manifestAsset);
59
+ break;
60
+ case 'unboxy:editor:deleteEntity':
61
+ deleteEntity(game, msg.entityId);
62
+ break;
56
63
  }
57
64
  }
58
65
  // --- Enter / exit ---------------------------------------------------------
@@ -138,6 +145,7 @@ function buildOverlayInit(game) {
138
145
  before,
139
146
  after,
140
147
  }),
148
+ postShortcut: (action) => postToHost({ type: 'unboxy:editor:shortcut', action }),
141
149
  };
142
150
  }
143
151
  // --- Snapshot -------------------------------------------------------------
@@ -150,9 +158,19 @@ function postSceneSnapshot(game) {
150
158
  type: 'unboxy:editor:sceneLoaded',
151
159
  sceneId: sceneFile.id,
152
160
  sceneFile,
161
+ manifest: readActiveManifest(game),
153
162
  });
154
163
  game[SNAPSHOT_POSTED_FLAG] = sceneFile.id;
155
164
  }
165
+ function readActiveManifest(game) {
166
+ for (const scene of game.scene.getScenes(false)) {
167
+ const cache = scene.cache.json;
168
+ const m = cache.entries?.entries?.['unboxy:manifest'];
169
+ if (m)
170
+ return m;
171
+ }
172
+ return undefined;
173
+ }
156
174
  function hasPostedSnapshot(game) {
157
175
  return !!game[SNAPSHOT_POSTED_FLAG];
158
176
  }
@@ -291,6 +309,150 @@ function applyVisualPatch(go, v) {
291
309
  }
292
310
  }
293
311
  }
312
+ // --- Create / delete ------------------------------------------------------
313
+ /**
314
+ * Spawn a new entity into the active world scene. Slice 3.
315
+ *
316
+ * If the entity references an asset whose texture isn't yet in the Phaser
317
+ * cache, we lazy-load it before spawn — same pattern as SceneLoader's
318
+ * preloadSceneAssets, but on a single asset and at edit time. Without this
319
+ * the host would have to coordinate a build before showing a dropped
320
+ * sprite, which defeats the point of drag-to-place.
321
+ */
322
+ async function createEntity(game, entity, manifestAsset) {
323
+ const scene = findWorldScene(game);
324
+ if (!scene) {
325
+ console.warn('[unboxy/editor] createEntity: no world scene to spawn into');
326
+ return;
327
+ }
328
+ const registry = getEntityRegistry(scene);
329
+ if (!registry) {
330
+ console.warn('[unboxy/editor] createEntity: world scene has no entity registry');
331
+ return;
332
+ }
333
+ // Lazy-load texture if needed (sprite + asset not yet in cache).
334
+ if (manifestAsset &&
335
+ entity.kind === 'sprite' &&
336
+ !scene.textures.exists(manifestAsset.textureKey)) {
337
+ await loadAssetIntoScene(scene, manifestAsset);
338
+ }
339
+ const ctx = {
340
+ scene,
341
+ registry,
342
+ resolveAsset: (id) => {
343
+ // For ad-hoc creation we may not have full manifest access. Rely on
344
+ // the host to have stamped the right textureKey/path into the asset
345
+ // we received via manifestAsset; fallback to a synthetic record so
346
+ // spawnEntity can still find a textureKey.
347
+ if (manifestAsset && manifestAsset.id === id)
348
+ return manifestAsset;
349
+ throw new Error(`[unboxy/editor] createEntity: asset '${id}' not in manifest payload — host must include manifestAsset`);
350
+ },
351
+ resolveRenderScript: undefined,
352
+ };
353
+ try {
354
+ spawnEntity(ctx, entity);
355
+ }
356
+ catch (e) {
357
+ console.warn('[unboxy/editor] spawnEntity failed:', e);
358
+ }
359
+ }
360
+ function loadAssetIntoScene(scene, asset) {
361
+ return new Promise((resolve, reject) => {
362
+ if (scene.textures.exists(asset.textureKey)) {
363
+ resolve();
364
+ return;
365
+ }
366
+ const onComplete = () => {
367
+ cleanup();
368
+ resolve();
369
+ };
370
+ const onError = (file) => {
371
+ if (file.key !== asset.textureKey)
372
+ return;
373
+ cleanup();
374
+ reject(new Error(`failed to load asset ${asset.id}: ${file.url}`));
375
+ };
376
+ const cleanup = () => {
377
+ scene.load.off(Phaser.Loader.Events.FILE_COMPLETE, perFileComplete);
378
+ scene.load.off(Phaser.Loader.Events.FILE_LOAD_ERROR, onError);
379
+ scene.load.off(Phaser.Loader.Events.COMPLETE, onComplete);
380
+ };
381
+ const perFileComplete = (key) => {
382
+ if (key === asset.textureKey) {
383
+ cleanup();
384
+ resolve();
385
+ }
386
+ };
387
+ scene.load.on(Phaser.Loader.Events.FILE_COMPLETE, perFileComplete);
388
+ scene.load.on(Phaser.Loader.Events.FILE_LOAD_ERROR, onError);
389
+ scene.load.on(Phaser.Loader.Events.COMPLETE, onComplete);
390
+ switch (asset.kind) {
391
+ case 'image':
392
+ scene.load.image(asset.textureKey, asset.path);
393
+ break;
394
+ case 'spritesheet':
395
+ if (asset.spriteSheetConfig) {
396
+ scene.load.spritesheet(asset.textureKey, asset.path, asset.spriteSheetConfig);
397
+ }
398
+ else {
399
+ scene.load.image(asset.textureKey, asset.path);
400
+ }
401
+ break;
402
+ case 'atlas':
403
+ if (asset.atlasPath && asset.atlasFormat === 'xml') {
404
+ scene.load.atlasXML(asset.textureKey, asset.path, asset.atlasPath);
405
+ }
406
+ else if (asset.atlasPath) {
407
+ scene.load.atlas(asset.textureKey, asset.path, asset.atlasPath);
408
+ }
409
+ break;
410
+ case 'audio':
411
+ scene.load.audio(asset.textureKey, asset.path);
412
+ break;
413
+ default:
414
+ cleanup();
415
+ reject(new Error(`unsupported asset kind for editor lazy-load: ${asset.kind}`));
416
+ return;
417
+ }
418
+ if (!scene.load.isLoading())
419
+ scene.load.start();
420
+ });
421
+ }
422
+ function deleteEntity(game, entityId) {
423
+ const scene = findWorldScene(game);
424
+ if (!scene)
425
+ return;
426
+ const registry = getEntityRegistry(scene);
427
+ if (!registry)
428
+ return;
429
+ const go = registry.byId(entityId);
430
+ if (!go)
431
+ return;
432
+ // Remove from registry by clearing + rebuilding the entries we care about.
433
+ // The slice-1 EntityRegistry doesn't expose a direct removeById; the
434
+ // simplest thing is to destroy the GameObject and let stale registry
435
+ // entries dangle until the next scene reload (post-flush). Slice 3.5
436
+ // can add an explicit `registry.unregister` if it becomes a problem.
437
+ go.destroy();
438
+ // Drop selection if it pointed at this entity.
439
+ const sel = game;
440
+ const state = sel['__unboxyEditorState'];
441
+ if (state && state.selectedId === entityId)
442
+ state.selectedId = null;
443
+ }
444
+ function findWorldScene(game) {
445
+ for (const scene of game.scene.getScenes(false)) {
446
+ const key = scene.scene.key;
447
+ if (BOOT_SCENE_KEYS.has(key))
448
+ continue;
449
+ if (key === EDITOR_OVERLAY_KEY)
450
+ continue;
451
+ if (getEntityRegistry(scene))
452
+ return scene;
453
+ }
454
+ return undefined;
455
+ }
294
456
  // --- Pan / zoom -----------------------------------------------------------
295
457
  function applyPanZoom(game, msg) {
296
458
  // Apply to BOTH the world scene's camera (so the entity rendering moves)
@@ -43,6 +43,12 @@ interface EditorOverlayInitData {
43
43
  x: number;
44
44
  y: number;
45
45
  }) => void;
46
+ /**
47
+ * Forward an editor keyboard shortcut to the host. Set when the iframe has
48
+ * focus (which is always after the user clicks the canvas to select);
49
+ * native Cmd+Z otherwise can't reach the host.
50
+ */
51
+ postShortcut: (action: 'undo' | 'redo' | 'save') => void;
46
52
  }
47
53
  export declare class EditorOverlayScene extends Phaser.Scene {
48
54
  private graphics;
@@ -50,9 +56,11 @@ export declare class EditorOverlayScene extends Phaser.Scene {
50
56
  private hitTest;
51
57
  private postPick;
52
58
  private postDragEnd;
59
+ private postShortcut;
53
60
  constructor();
54
61
  init(data: EditorOverlayInitData): void;
55
62
  create(): void;
63
+ private handleShortcut;
56
64
  private handlePointerDown;
57
65
  private handlePointerMove;
58
66
  private handlePointerUp;
@@ -26,12 +26,40 @@ const WORLD_BOUNDS_ALPHA = 0.5;
26
26
  export class EditorOverlayScene extends Phaser.Scene {
27
27
  constructor() {
28
28
  super({ key: EDITOR_OVERLAY_KEY });
29
+ this.handleShortcut = (e) => {
30
+ if (!(e.metaKey || e.ctrlKey))
31
+ return;
32
+ // Don't swallow keys when the user is typing in a real form control —
33
+ // shouldn't happen inside the iframe, but be defensive.
34
+ const target = e.target;
35
+ if (target) {
36
+ const tag = target.tagName;
37
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT')
38
+ return;
39
+ if (target.isContentEditable)
40
+ return;
41
+ }
42
+ const k = e.key.toLowerCase();
43
+ if (k === 'z') {
44
+ e.preventDefault();
45
+ this.postShortcut(e.shiftKey ? 'redo' : 'undo');
46
+ }
47
+ else if (k === 'y') {
48
+ e.preventDefault();
49
+ this.postShortcut('redo');
50
+ }
51
+ else if (k === 's') {
52
+ e.preventDefault();
53
+ this.postShortcut('save');
54
+ }
55
+ };
29
56
  }
30
57
  init(data) {
31
58
  this.worldBounds = data.worldBounds;
32
59
  this.hitTest = data.hitTest;
33
60
  this.postPick = data.postPick;
34
61
  this.postDragEnd = data.postDragEnd;
62
+ this.postShortcut = data.postShortcut;
35
63
  }
36
64
  create() {
37
65
  this.graphics = this.add.graphics();
@@ -45,6 +73,19 @@ export class EditorOverlayScene extends Phaser.Scene {
45
73
  this.input.on('pointerdown', this.handlePointerDown, this);
46
74
  this.input.on('pointermove', this.handlePointerMove, this);
47
75
  this.input.on('pointerup', this.handlePointerUp, this);
76
+ // Forward editor shortcuts back to the host. The user clicks the canvas
77
+ // to select an entity, which moves focus into the iframe — after that,
78
+ // native Cmd+Z lands here, not on the host's window. We catch it before
79
+ // Phaser's keyboard plugin gets it (DOM phase is fine because Phaser
80
+ // listens via its own KeyboardManager), preventDefault'ing browser
81
+ // back-nav-on-Cmd+Left etc. is unnecessary; we only intercept the few
82
+ // editor keys we care about.
83
+ if (typeof document !== 'undefined') {
84
+ document.addEventListener('keydown', this.handleShortcut, true);
85
+ this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => {
86
+ document.removeEventListener('keydown', this.handleShortcut, true);
87
+ });
88
+ }
48
89
  }
49
90
  handlePointerDown(pointer) {
50
91
  const state = getEditorState(this.game);
package/dist/index.d.ts CHANGED
@@ -24,7 +24,7 @@ export { EntityRegistry, attachEntityRegistry, getEntityRegistry, } from './scen
24
24
  export { setupEditorModeListener, isEditMode } from './scene/EditorMode.js';
25
25
  export { setupEditorBridge } from './editor/EditorBridge.js';
26
26
  export { EditorOverlayScene, EDITOR_OVERLAY_KEY } from './editor/EditorOverlayScene.js';
27
- export type { EditorEntityPatch, EditorEnterMessage, EditorExitMessage, EditorGetSceneMessage, EditorApplyEditMessage, EditorSetSelectionMessage, EditorPanZoomMessage, EditorHostToSdkMessage, EditorSceneLoadedMessage, EditorSelectionPickedMessage, EditorDragEndMessage, EditorSdkToHostMessage, } from './protocol.js';
27
+ export type { EditorEntityPatch, EditorEnterMessage, EditorExitMessage, EditorGetSceneMessage, EditorApplyEditMessage, EditorSetSelectionMessage, EditorPanZoomMessage, EditorHostToSdkMessage, EditorSceneLoadedMessage, EditorSelectionPickedMessage, EditorDragEndMessage, EditorShortcutMessage, EditorCreateEntityMessage, EditorDeleteEntityMessage, EditorSdkToHostMessage, } from './protocol.js';
28
28
  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';
29
+ 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
30
  export { PROTOCOL_VERSION, type HelloMessage, type InitMessage, type RpcRequestMessage, type RpcResultOk, type RpcResultError, type HostToSdkMessage, type SdkToHostMessage, type RpcErrorPayload, type RpcMethod, type SavesGetParams, type SavesGetResult, type SavesSetParams, type SavesSetResult, type SavesDeleteParams, type SavesDeleteResult, type SavesListResult, type GameDataGetParams, type GameDataGetResult, type GameDataSetParams, type GameDataSetResult, type GameDataDeleteParams, type GameDataDeleteResult, type GameDataListResult, type RealtimeGetTokenParams, type RealtimeGetTokenResult, } from './protocol.js';
@@ -108,12 +108,35 @@ export interface EditorPanZoomMessage {
108
108
  /** If true, deltas are added to current scroll. */
109
109
  relative?: boolean;
110
110
  }
111
- export type EditorHostToSdkMessage = EditorEnterMessage | EditorExitMessage | EditorGetSceneMessage | EditorApplyEditMessage | EditorSetSelectionMessage | EditorPanZoomMessage;
111
+ /**
112
+ * Create a new entity in the active scene (slice 3 — drag-to-place).
113
+ * The SDK picks an asset record from `manifestAsset` (if present) so the
114
+ * texture can be lazy-loaded before spawn. Position can be supplied as
115
+ * world coords (preferred — host computed via 1:1 mapping in slice 3) or
116
+ * skipped if the entity already carries a transform.
117
+ */
118
+ export interface EditorCreateEntityMessage {
119
+ type: 'unboxy:editor:createEntity';
120
+ /** Full entity record. transform.x/y is authoritative. */
121
+ entity: unknown;
122
+ /** Asset record to add to runtime cache before spawn. Omit if assetId is already loaded. */
123
+ manifestAsset?: unknown;
124
+ }
125
+ export interface EditorDeleteEntityMessage {
126
+ type: 'unboxy:editor:deleteEntity';
127
+ entityId: string;
128
+ }
129
+ export type EditorHostToSdkMessage = EditorEnterMessage | EditorExitMessage | EditorGetSceneMessage | EditorApplyEditMessage | EditorSetSelectionMessage | EditorPanZoomMessage | EditorCreateEntityMessage | EditorDeleteEntityMessage;
112
130
  export interface EditorSceneLoadedMessage {
113
131
  type: 'unboxy:editor:sceneLoaded';
114
132
  sceneId: string;
115
133
  /** Snapshot of the world scene file's current entities + camera. */
116
134
  sceneFile: unknown;
135
+ /**
136
+ * Snapshot of the manifest (asset table + scene list). Slice 3+ — home-ui
137
+ * needs this to mutate the manifest when drag-to-place adds a new asset.
138
+ */
139
+ manifest?: unknown;
117
140
  }
118
141
  export interface EditorSelectionPickedMessage {
119
142
  /** Pointer-down on an entity in the canvas — host should update selection. */
@@ -141,7 +164,16 @@ export interface EditorDragEndMessage {
141
164
  y: number;
142
165
  };
143
166
  }
144
- export type EditorSdkToHostMessage = EditorSceneLoadedMessage | EditorSelectionPickedMessage | EditorDragEndMessage;
167
+ /**
168
+ * Editor keyboard shortcut intercepted inside the iframe (user clicked the
169
+ * canvas, so focus is on the iframe and the host window doesn't see the
170
+ * native keydown). The SDK forwards a small allowlist back to the host.
171
+ */
172
+ export interface EditorShortcutMessage {
173
+ type: 'unboxy:editor:shortcut';
174
+ action: 'undo' | 'redo' | 'save';
175
+ }
176
+ export type EditorSdkToHostMessage = EditorSceneLoadedMessage | EditorSelectionPickedMessage | EditorDragEndMessage | EditorShortcutMessage;
145
177
  export type RpcMethod = 'saves.get' | 'saves.set' | 'saves.delete' | 'saves.list' | 'gameData.get' | 'gameData.set' | 'gameData.delete' | 'gameData.list' | 'realtime.getToken';
146
178
  export interface SavesGetParams {
147
179
  key: string;
@@ -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)}`);
@@ -122,6 +124,61 @@ function tagGameObject(go, entity) {
122
124
  if (entity.properties)
123
125
  go.setData('entityProperties', entity.properties);
124
126
  }
127
+ /**
128
+ * Trigger stub renderer — slice 3. Renders the trigger zone as a
129
+ * semi-transparent fill with cyan tint per design 03 §8.2 (in edit mode it
130
+ * is always visible; at play time the SDK will hide it once slice 5 wires
131
+ * the behavior layer).
132
+ */
133
+ function createTriggerStub(ctx, entity) {
134
+ const TINT = 0x00bfff; // cyan-ish "neutral trigger" per design
135
+ const FILL_ALPHA = 0.25;
136
+ const STROKE_ALPHA = 0.7;
137
+ if (entity.shape.kind === 'rect') {
138
+ const r = ctx.scene.add.rectangle(0, 0, entity.shape.width, entity.shape.height, TINT, FILL_ALPHA);
139
+ r.setStrokeStyle(1.5, TINT, STROKE_ALPHA);
140
+ return r;
141
+ }
142
+ // circle
143
+ const c = ctx.scene.add.circle(0, 0, entity.shape.radius, TINT, FILL_ALPHA);
144
+ c.setStrokeStyle(1.5, TINT, STROKE_ALPHA);
145
+ return c;
146
+ }
147
+ /**
148
+ * Tilemap stub renderer — slice 3. Draws a translucent grid sketch of the
149
+ * tilemap's bounds + cell lines so the user can see where the tilemap is
150
+ * and how big it is. Real tilemap rendering (with tilesets, autotile,
151
+ * Y-sort, etc.) lands in slice 6.
152
+ */
153
+ function createTilemapStub(ctx, entity) {
154
+ const g = ctx.scene.add.graphics();
155
+ const w = entity.size.width * entity.tileSize.width;
156
+ const h = entity.size.height * entity.tileSize.height;
157
+ g.fillStyle(0xffffff, 0.04);
158
+ g.fillRect(0, 0, w, h);
159
+ // Outer border
160
+ g.lineStyle(2, 0xaaaaaa, 0.6);
161
+ g.strokeRect(0, 0, w, h);
162
+ // Cell grid — only draw if cells aren't too tiny (perf cap).
163
+ if (entity.tileSize.width >= 8 && entity.size.width * entity.size.height < 4000) {
164
+ g.lineStyle(1, 0xaaaaaa, 0.15);
165
+ for (let i = 1; i < entity.size.width; i++) {
166
+ const x = i * entity.tileSize.width;
167
+ g.beginPath();
168
+ g.moveTo(x, 0);
169
+ g.lineTo(x, h);
170
+ g.strokePath();
171
+ }
172
+ for (let i = 1; i < entity.size.height; i++) {
173
+ const y = i * entity.tileSize.height;
174
+ g.beginPath();
175
+ g.moveTo(0, y);
176
+ g.lineTo(w, y);
177
+ g.strokePath();
178
+ }
179
+ }
180
+ return g;
181
+ }
125
182
  /**
126
183
  * Accepts `'#rrggbb'`, `'rrggbb'`, or `'0xrrggbb'` and returns a number
127
184
  * 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.19",
3
+ "version": "0.2.21",
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",