@unboxy/phaser-sdk 0.2.43 → 0.2.44

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
@@ -775,6 +775,69 @@ For now there's no built-in action binding (no `behavior: { type: 'pause-game' }
775
775
 
776
776
  A colored rectangle with optional border + rounded corners. Use as a visual frame behind groups of HUD widgets (no children layout in v1 — child widgets are sibling entities; you align them with anchor + offset).
777
777
 
778
+ ### Textured backgrounds for `icon-button` and `panel` (since 0.2.37)
779
+
780
+ Both `icon-button` and `panel` accept an asset image as the background instead of the default colored fill. Three flavors, each stretchable with crisp corners via the SDK's built-in 9-slice renderer:
781
+
782
+ **1. Single-image 9-slice** (since 0.2.37) — manifest asset is `kind: 'image'` with `ninePatch: { leftWidth, rightWidth, topHeight, bottomHeight }`. The whole texture slices into 9 regions; corners stay native, edges + middle stretch.
783
+
784
+ ```json
785
+ {
786
+ "kind": "icon-button",
787
+ "visual": {
788
+ "kind": "icon-button",
789
+ "label": "Start",
790
+ "width": 240, "height": 64,
791
+ "backgroundAssetId": "button-panel"
792
+ }
793
+ }
794
+ ```
795
+
796
+ **2. Per-frame on a uniform grid** (since 0.2.38) — manifest asset is `kind: 'spritesheet'` with `ninePatch: { perFrame: {...} }`. Used when every cell of a button-pack sheet is the same size and shares the same corner radius (typical itch.io packs). Pair with `backgroundFrame: <cell index>` to pick which cell.
797
+
798
+ ```json
799
+ {
800
+ "visual": {
801
+ "kind": "icon-button",
802
+ "backgroundAssetId": "button-pack",
803
+ "backgroundFrame": 3
804
+ }
805
+ }
806
+ ```
807
+
808
+ **3. Region atlas** (since 0.2.42) — manifest asset is `kind: 'atlas'` + `atlasFormat: 'json'` + `atlasPath: '...'`. The atlas JSON file carries named frame rects, each optionally with its own inline `ninePatch`. Pair with `backgroundRegion: '<frame name>'` to pick the region. Mutually exclusive with `backgroundFrame` — when both are set, `backgroundRegion` wins.
809
+
810
+ ```json
811
+ {
812
+ "visual": {
813
+ "kind": "icon-button",
814
+ "backgroundAssetId": "ui-buttons",
815
+ "backgroundRegion": "btn-primary-idle"
816
+ }
817
+ }
818
+ ```
819
+
820
+ Atlas JSON shape — Phaser-native JSON-Hash with an optional per-frame `ninePatch` field that Phaser's parser ignores but the SDK reads via a side-cache:
821
+
822
+ ```json
823
+ {
824
+ "frames": {
825
+ "btn-primary-idle": {
826
+ "frame": { "x": 7, "y": 9, "w": 18, "h": 30 },
827
+ "sourceSize": { "w": 18, "h": 30 },
828
+ "ninePatch": { "leftWidth": 4, "rightWidth": 4, "topHeight": 4, "bottomHeight": 4 }
829
+ },
830
+ "btn-primary-pressed": {
831
+ "frame": { "x": 39, "y": 9, "w": 18, "h": 30 },
832
+ "sourceSize": { "w": 18, "h": 30 }
833
+ }
834
+ },
835
+ "meta": { "image": "ui-buttons.png", "size": { "w": 128, "h": 96 }, "scale": "1" }
836
+ }
837
+ ```
838
+
839
+ The Region Editor in the home-ui (visual editor slice 10) writes this shape for you — drag-tag named regions on a single PNG, optionally toggle 9-slice per region, save. Regions without `ninePatch` render as plain stretched images of that region; regions with `ninePatch` render via the 9-slice path.
840
+
778
841
  ### Dynamic bindings — driving the HUD from gameplay
779
842
 
780
843
  For values that change during play (score, HP, timer, lives), use **Phaser's game registry** as the bridge. The HUD widget references a `binding` key; gameplay code calls `this.registry.set(key, value)` and the HUD auto-refreshes.
@@ -801,9 +864,145 @@ The visual editor's `World/HUD` toggle pauses the active world scene and lets th
801
864
 
802
865
  For game code, the editor is invisible — you write HUD scene JSON the same way regardless of whether the user uses the editor.
803
866
 
804
- ## Anti-patterns (don't do these)
867
+ ## Collision + Y-sort (visual editor slice 8, since 0.2.44)
868
+
869
+ Two new fields on `AssetRecord` give per-asset collision shapes and footprint
870
+ anchors for top-down draw-order sorting. The SDK consumes them automatically
871
+ at sprite spawn time. `applyAssetHitbox` is also exported for behavior code
872
+ that attaches a physics body after spawn.
873
+
874
+ ### `Asset.hitbox` — collision body shape
875
+
876
+ Per-asset rect (in v1) in source-pixel coordinates relative to the
877
+ asset's frame top-left. Two shapes:
878
+
879
+ ```ts
880
+ // Single — one body across all frames (trees, walls, idle characters)
881
+ hitbox: { kind: 'rect', x: 8, y: 24, w: 16, h: 8 }
882
+
883
+ // Per-frame — combat sheets where attack frames need a wider hitbox
884
+ hitbox: {
885
+ default: { kind: 'rect', x: 8, y: 24, w: 16, h: 8 },
886
+ frames: {
887
+ 8: { kind: 'rect', x: 0, y: 16, w: 32, h: 16 },
888
+ 9: { kind: 'rect', x: 0, y: 16, w: 32, h: 16 },
889
+ 10: { kind: 'rect', x: 0, y: 16, w: 32, h: 16 },
890
+ 11: { kind: 'rect', x: 0, y: 16, w: 32, h: 16 }
891
+ }
892
+ }
893
+ ```
894
+
895
+ `kind: 'rect'` is the only valid value in v1; `'circle'` is reserved for v2
896
+ and the union widens as a pure addition. The `isPerFrameHitbox(hitbox)` type
897
+ guard narrows the union.
898
+
899
+ ### `Asset.depthAnchor` — Y-sort footprint pixel
900
+
901
+ ```ts
902
+ depthAnchor: { x: 16, y: 28 } // pixel within frame 0 the SDK aligns sprite.y with
903
+ ```
904
+
905
+ The SDK applies `sprite.setOrigin(x / frameW, y / frameH)` at spawn so the
906
+ sprite's world `y` equals the world y of this pixel. Typical: feet for
907
+ characters, trunk base for trees, foundation midpoint for buildings.
908
+
909
+ ### `applyAssetHitbox(sprite, asset)` — apply at runtime
910
+
911
+ Called automatically by `createSprite` at scene boot. **Also callable** by
912
+ behavior code after attaching a body later:
913
+
914
+ ```ts
915
+ import { applyAssetHitbox } from '@unboxy/phaser-sdk';
916
+
917
+ const player = registry.getById('player');
918
+ const asset = manifest.assets.find(a => a.id === player.getData('assetId'));
919
+
920
+ scene.physics.add.existing(player);
921
+ applyAssetHitbox(player, asset); // reads asset.hitbox, applies to body
922
+ ```
923
+
924
+ Semantics:
925
+
926
+ - **No-op (silent)** when `asset.hitbox` is unset. Safe to call defensively.
927
+ - **Dev-warn (NOT throw)** when called on a sprite without a physics body.
928
+ Most likely cause: call order — `scene.physics.add.existing(sprite)` must
929
+ come first.
930
+ - **For per-frame hitboxes**: installs an `ANIMATION_UPDATE` listener that
931
+ swaps `body.setSize`/`setOffset` on every frame change during animation
932
+ playback. Idempotent — re-applying tears down the old listener first.
933
+
934
+ **v1 caveat**: manual `sprite.setFrame(idx)` calls outside of animation
935
+ playback do NOT swap the body. Combat sheets are animation-driven, so this
936
+ rarely bites in practice.
937
+
938
+ ### Scene-level `ySort: true` — per-frame depth sort
939
+
940
+ ```json
941
+ // public/scenes/world/main.json
942
+ {
943
+ "schemaVersion": 1,
944
+ "id": "main",
945
+ "type": "world",
946
+ "ySort": true,
947
+ "entities": [ ... ]
948
+ }
949
+ ```
950
+
951
+ When set, `loadWorldScene` installs a per-frame update hook that walks the
952
+ entity registry and assigns `sprite.depth = sprite.y` to every entity.
953
+ **Per-game cost**: O(n) per frame; 200 entities at 60 FPS = 12,000
954
+ `setDepth` calls/sec — well within Phaser's render budget.
955
+
956
+ Two opt-out mechanisms (orthogonal, cover different intents):
957
+
958
+ ```ts
959
+ // explicit constant depth → ySort skips the entity entirely
960
+ { "transform": { "x": 100, "y": 100, "depth": 1000 } } // cloud always on top
961
+
962
+ // behavior-managed depth → ySort doesn't touch the entity
963
+ { "transform": { "x": 100, "y": 100 }, "properties": { "skipYSort": true } }
964
+ ```
965
+
966
+ ### The conditional rule (when to call `applyAssetHitbox`)
967
+
968
+ ```ts
969
+ const asset = manifest.assets.find(a => a.id === sprite.getData('assetId'));
970
+ scene.physics.add.existing(sprite);
971
+
972
+ if (asset?.hitbox) {
973
+ applyAssetHitbox(sprite, asset); // metadata is source of truth
974
+ } else {
975
+ sprite.body.setSize(30, 20); // hardcode from gameplay intent
976
+ sprite.body.setOffset(5, 5);
977
+ }
978
+ ```
979
+
980
+ Both paths are correct depending on context:
981
+
982
+ - **`applyAssetHitbox` path** — sprite uses an Asset that has `hitbox`
983
+ metadata. Pack import sets it via vision; users refine via the Hitbox
984
+ Editor. Hardcoding `setSize` afterward would override the user's
985
+ authored values.
986
+ - **Hardcode path** — primitive sprites (`scene.add.rectangle`), AI-gen
987
+ images with no vision pass, chat-only games with no scene-as-data
988
+ manifest. There's nothing to apply; hardcoding is the right answer.
989
+
990
+ The `asset-hitbox-physics` agent skill (in
991
+ `plugins/unboxy-phaser/skills/asset-hitbox-physics/SKILL.md`) walks through
992
+ this decision tree with worked examples.
993
+
994
+ ### Authoring metadata (the editor surface)
995
+
996
+ Users author `hitbox` + `depthAnchor` in the home-ui's Hitbox Editor modal —
997
+ launched from the Assets panel's per-card hover button (green hitbox icon).
998
+ Supports both Same-for-all-frames and Per-frame overrides modes, with a
999
+ frame strip + override-dot indicators for the per-frame case.
1000
+
1001
+ Vision pre-fills the single-shape form at pack-import time for character /
1002
+ tree / building / rock / decoration subjects (narrow trunk for trees, foot
1003
+ footprint for characters, foundation for buildings).
1004
+
805
1005
 
806
- - Do **not** call `Unboxy.init()` inside a scene. Initialize at module load in `main.ts` and export the promise.
807
1006
  - Do **not** block scene creation on `unboxyReady`. Games must cold-start immediately; hydrate when the promise resolves.
808
1007
  - Do **not** save on every frame / every score tick. Debounce or only save on meaningful events (game over, level clear).
809
1008
  - Do **not** store large binary blobs (images, audio) as save values — use a dedicated blob API when it ships.
@@ -813,6 +1012,7 @@ For game code, the editor is invisible — you write HUD scene JSON the same way
813
1012
 
814
1013
  ## Changelog
815
1014
 
1015
+ - **0.2.44** — visual editor slice 8: depth + asymmetric collision. New fields `AssetRecord.hitbox` (single rect or per-frame overrides) + `AssetRecord.depthAnchor` (footprint pixel for Y-sort). New scene flag `WorldScene.ySort: boolean` (per-frame `setDepth(y)` hook). New SDK export `applyAssetHitbox(sprite, asset)` — silent no-op without metadata, dev-warn on missing body, idempotent `ANIMATION_UPDATE` listener install for per-frame variants. New type guard `isPerFrameHitbox`. Two new editor protocol messages — `unboxy:editor:assetUpdate { asset }` (broadcast hitbox/anchor edits to running iframe; re-applies to every spawned instance, no scene reload) + `unboxy:editor:setDebugOverlay { showHitboxes }` (toggle the EditorOverlayScene's per-frame hitbox + anchor draw). See "Collision + Y-sort" chapter. Pairs with: backend `PATCH /games/{id}/assets/{aid}/hitbox`, vision detection in pack import (character/tree/building/rock/decoration subjects), Hitbox Editor modal in home-ui, `asset-hitbox-physics` agent skill teaching the conditional rule.
816
1016
  - **0.2.33** — docs: HUD scenes chapter added to this guide (covers slice-5 widget kinds, anchor model, dynamic bindings via `scene.registry`, `hud:press` event). Existing workspaces pick this up via `npm update @unboxy/phaser-sdk`.
817
1017
  - **0.2.32** — fix: 9-grid anchor side change in the visual editor moved container-based widgets (icon-button, progress-bar, panel) far from the new corner. `applyHudPatch` now re-applies the origin shift that compensates for containers having no `setOrigin`.
818
1018
  - **0.2.31** — fix: progress-bar and panel widgets drew their Graphics rect from `(0, 0)` instead of centered, so the visual was offset by `(w/2, h/2)` from the editor's selection rect. Both now draw centered.
@@ -1,10 +1,10 @@
1
1
  import Phaser from 'phaser';
2
2
  import { getEntityRegistry } from '../scene/EntityRegistry.js';
3
- import { parseColor, spawnEntity } from '../scene/spawnEntity.js';
3
+ import { applyAssetHitbox, parseColor, spawnEntity } from '../scene/spawnEntity.js';
4
4
  import { resolveRenderScript } from '../scene/renderScripts.js';
5
5
  import { getManifest } from '../scene/SceneLoader.js';
6
6
  import { EditorOverlayScene, EDITOR_OVERLAY_KEY } from './EditorOverlayScene.js';
7
- import { getEditorState, setEditorActive, setSelection, getSelection, getEditorMode, setEditorMode, } from './EditorState.js';
7
+ import { getEditorState, setEditorActive, setSelection, getSelection, getEditorMode, setEditorMode, setDebugOverlayState, } from './EditorState.js';
8
8
  import { applyHudPatch, createHudEntityInScene, deleteHudEntityFromScene, findHudRegistry, findHudSceneFile, UNBOXY_HUD_SCENE_KEY, } from '../scene/HudRuntime.js';
9
9
  /**
10
10
  * EditorBridge wires the host (home-ui) to the iframe's Phaser game during
@@ -76,6 +76,14 @@ function handleMessage(game, msg) {
76
76
  setSelection(game, null);
77
77
  postSelectionRect(game);
78
78
  break;
79
+ case 'unboxy:editor:assetUpdate':
80
+ // Slice 8 — Asset-level live update for hitbox / depthAnchor edits.
81
+ void handleAssetUpdate(game, msg.asset);
82
+ break;
83
+ case 'unboxy:editor:setDebugOverlay':
84
+ // Slice 8 — "Show hitboxes" debug overlay toggle.
85
+ setDebugOverlay(game, { showHitboxes: msg.showHitboxes });
86
+ break;
79
87
  }
80
88
  }
81
89
  // --- Enter / exit ---------------------------------------------------------
@@ -485,6 +493,72 @@ async function applyEdit(game, entityId, patch, manifestAsset) {
485
493
  if (getSelection(game) === entityId)
486
494
  postSelectionRect(game);
487
495
  }
496
+ // --- Slice 8: asset-level live update ------------------------------------
497
+ /**
498
+ * Handle an `unboxy:editor:assetUpdate` postMessage — used by the Hitbox
499
+ * Editor to push freshly-saved hitbox / depthAnchor metadata into the
500
+ * running iframe without a scene reload.
501
+ *
502
+ * Flow:
503
+ * 1. Upsert the asset into the iframe's cached manifest (so future spawns
504
+ * see the new metadata).
505
+ * 2. Lazy-load texture if it isn't cached yet (defensive — Hitbox Editor
506
+ * is typically opened on already-loaded assets, but the same code path
507
+ * is reused if a user edits hitbox on an asset that was never in this
508
+ * scene's manifest).
509
+ * 3. Walk the entity registry, find every sprite with `entityAssetId`
510
+ * matching the updated asset's id, and re-apply `setOrigin` (from new
511
+ * depthAnchor) + `applyAssetHitbox` (which is idempotent and re-installs
512
+ * the per-frame listener cleanly).
513
+ *
514
+ * HUD-mode is a no-op — HUD widgets don't carry world hitboxes / depthAnchors.
515
+ * The Hitbox Editor is launched only on world-scene assets.
516
+ */
517
+ async function handleAssetUpdate(game, asset) {
518
+ if (getEditorMode(game) === 'hud')
519
+ return;
520
+ const scene = findWorldScene(game);
521
+ if (!scene)
522
+ return;
523
+ try {
524
+ upsertCachedManifestAsset(scene, asset);
525
+ await ensureAssetLoaded(scene, asset);
526
+ }
527
+ catch (e) {
528
+ console.warn('[unboxy/editor] assetUpdate upsert/load failed:', e);
529
+ return;
530
+ }
531
+ const registry = findRegistry(game);
532
+ if (!registry)
533
+ return;
534
+ for (const go of registry.all()) {
535
+ if (go.getData('entityAssetId') !== asset.id)
536
+ continue;
537
+ const sprite = go;
538
+ // Re-apply origin from new depthAnchor (no-op if anchor unset; spawn-time
539
+ // default origin of 0.5/0.5 stays). Cleared anchor reverts to center.
540
+ if (asset.depthAnchor) {
541
+ const frameW = sprite.width || 1;
542
+ const frameH = sprite.height || 1;
543
+ sprite.setOrigin?.(asset.depthAnchor.x / frameW, asset.depthAnchor.y / frameH);
544
+ }
545
+ else if (typeof sprite.setOrigin === 'function') {
546
+ sprite.setOrigin(0.5, 0.5);
547
+ }
548
+ // Re-apply hitbox. Idempotent — tears down the prior per-frame listener
549
+ // before installing the new one. Silent no-op when sprite has no body.
550
+ applyAssetHitbox(sprite, asset);
551
+ }
552
+ }
553
+ // --- Slice 8: debug overlay toggle ---------------------------------------
554
+ /**
555
+ * Forward the "Show hitboxes" toggle into the editor overlay scene. The
556
+ * overlay reads this flag each frame from the editor state attached to the
557
+ * Phaser game (see EditorState.setDebugOverlay).
558
+ */
559
+ function setDebugOverlay(game, state) {
560
+ setDebugOverlayState(game, state);
561
+ }
488
562
  /**
489
563
  * Re-render a code-rendered entity's visual when its params change.
490
564
  * Slice 3.5 — Inspector params editor produces these patches.
@@ -70,6 +70,23 @@ export declare class EditorOverlayScene extends Phaser.Scene {
70
70
  private handlePointerMove;
71
71
  private handlePointerUp;
72
72
  update(): void;
73
+ /**
74
+ * Walks the world entity registry and draws each sprite's `hitbox` rect +
75
+ * `depthAnchor` crosshair (when present on the entity's Asset). Sprites
76
+ * without metadata get nothing drawn — chat-only Workflow A respect.
77
+ *
78
+ * Per-frame mode: reads the sprite's CURRENT frame index and looks up the
79
+ * override (falls back to default) so the rendered rect tracks what the
80
+ * SDK is actually applying to the body at runtime.
81
+ */
82
+ private drawHitboxDebug;
83
+ /**
84
+ * For per-frame hitboxes, pick the shape the SDK would currently be
85
+ * applying — the override for the playing frame, falling back to default.
86
+ * For single-shape hitboxes, just return the rect.
87
+ */
88
+ private resolveCurrentHitboxShape;
89
+ private findWorldSceneInstance;
73
90
  /**
74
91
  * Find the world scene's main camera so the overlay can mirror it.
75
92
  * In edit mode, the world scene is paused but its camera state is what
@@ -1,7 +1,9 @@
1
1
  import Phaser from 'phaser';
2
2
  import { getEntityRegistry } from '../scene/EntityRegistry.js';
3
3
  import { findHudEntity, findHudRegistry, UNBOXY_HUD_SCENE_KEY, } from '../scene/HudRuntime.js';
4
- import { getEditorState, startDrag, clearDrag, getDrag, getSelection, getEditorMode, } from './EditorState.js';
4
+ import { isPerFrameHitbox } from '../scene/types.js';
5
+ import { getManifest } from '../scene/SceneLoader.js';
6
+ import { getEditorState, startDrag, clearDrag, getDrag, getSelection, getEditorMode, getDebugOverlayState, } from './EditorState.js';
5
7
  /**
6
8
  * Editor overlay — slice 2.
7
9
  *
@@ -24,6 +26,16 @@ const SELECTION_ALPHA = 1;
24
26
  const SELECTION_LINE_WIDTH = 2;
25
27
  const WORLD_BOUNDS_COLOR = 0x888888;
26
28
  const WORLD_BOUNDS_ALPHA = 0.5;
29
+ // Slice 8: "Show hitboxes" debug overlay colors. Green for hitboxes (matches
30
+ // Hitbox Editor canvas), cyan for depth-anchor crosshairs (distinct + visible
31
+ // on most sprite backgrounds).
32
+ const HITBOX_FILL_COLOR = 0x33dd55;
33
+ const HITBOX_FILL_ALPHA = 0.25;
34
+ const HITBOX_STROKE_COLOR = 0x22aa44;
35
+ const HITBOX_STROKE_ALPHA = 0.9;
36
+ const ANCHOR_COLOR = 0x00d0ff;
37
+ const ANCHOR_ALPHA = 1;
38
+ const ANCHOR_CROSS_LEN = 6;
27
39
  export class EditorOverlayScene extends Phaser.Scene {
28
40
  constructor() {
29
41
  super({ key: EDITOR_OVERLAY_KEY });
@@ -215,6 +227,100 @@ export class EditorOverlayScene extends Phaser.Scene {
215
227
  }
216
228
  }
217
229
  }
230
+ // Slice 8: "Show hitboxes" debug overlay. Only renders in world mode;
231
+ // HUD widgets don't carry world hitboxes.
232
+ if (getEditorMode(this.game) !== 'hud' && getDebugOverlayState(this.game).showHitboxes) {
233
+ this.drawHitboxDebug();
234
+ }
235
+ }
236
+ /**
237
+ * Walks the world entity registry and draws each sprite's `hitbox` rect +
238
+ * `depthAnchor` crosshair (when present on the entity's Asset). Sprites
239
+ * without metadata get nothing drawn — chat-only Workflow A respect.
240
+ *
241
+ * Per-frame mode: reads the sprite's CURRENT frame index and looks up the
242
+ * override (falls back to default) so the rendered rect tracks what the
243
+ * SDK is actually applying to the body at runtime.
244
+ */
245
+ drawHitboxDebug() {
246
+ const registry = this.findEntityRegistry();
247
+ if (!registry)
248
+ return;
249
+ const worldScene = this.findWorldSceneInstance();
250
+ const manifest = worldScene ? getManifest(worldScene) : undefined;
251
+ if (!manifest)
252
+ return;
253
+ const assetsById = new Map();
254
+ for (const asset of manifest.assets ?? []) {
255
+ assetsById.set(asset.id, asset);
256
+ }
257
+ for (const go of registry.all()) {
258
+ const assetId = go.getData('entityAssetId');
259
+ if (!assetId)
260
+ continue; // primitives / code-rendered / group — skip
261
+ const asset = assetsById.get(assetId);
262
+ if (!asset)
263
+ continue;
264
+ const sprite = go;
265
+ const frameW = sprite.frame?.width ?? sprite.width ?? 0;
266
+ const frameH = sprite.frame?.height ?? sprite.height ?? 0;
267
+ if (frameW === 0 || frameH === 0)
268
+ continue;
269
+ // Top-left corner of the frame in world coords (compensate for origin
270
+ // shift so the source-pixel coordinate space lands on the sprite's
271
+ // actual rendered position).
272
+ const originX = sprite.originX ?? 0.5;
273
+ const originY = sprite.originY ?? 0.5;
274
+ const frameLeft = sprite.x - frameW * originX;
275
+ const frameTop = sprite.y - frameH * originY;
276
+ if (asset.hitbox) {
277
+ const shape = this.resolveCurrentHitboxShape(asset, sprite);
278
+ if (shape) {
279
+ this.graphics.fillStyle(HITBOX_FILL_COLOR, HITBOX_FILL_ALPHA);
280
+ this.graphics.fillRect(frameLeft + shape.x, frameTop + shape.y, shape.w, shape.h);
281
+ this.graphics.lineStyle(1, HITBOX_STROKE_COLOR, HITBOX_STROKE_ALPHA);
282
+ this.graphics.strokeRect(frameLeft + shape.x, frameTop + shape.y, shape.w, shape.h);
283
+ }
284
+ }
285
+ if (asset.depthAnchor) {
286
+ const ax = frameLeft + asset.depthAnchor.x;
287
+ const ay = frameTop + asset.depthAnchor.y;
288
+ this.graphics.lineStyle(1, ANCHOR_COLOR, ANCHOR_ALPHA);
289
+ this.graphics.lineBetween(ax - ANCHOR_CROSS_LEN, ay, ax + ANCHOR_CROSS_LEN, ay);
290
+ this.graphics.lineBetween(ax, ay - ANCHOR_CROSS_LEN, ax, ay + ANCHOR_CROSS_LEN);
291
+ }
292
+ }
293
+ }
294
+ /**
295
+ * For per-frame hitboxes, pick the shape the SDK would currently be
296
+ * applying — the override for the playing frame, falling back to default.
297
+ * For single-shape hitboxes, just return the rect.
298
+ */
299
+ resolveCurrentHitboxShape(asset, sprite) {
300
+ const h = asset.hitbox;
301
+ if (!h)
302
+ return undefined;
303
+ if (isPerFrameHitbox(h)) {
304
+ const idx = sprite.anims?.currentFrame?.index;
305
+ if (typeof idx === 'number' && h.frames[idx])
306
+ return h.frames[idx];
307
+ return h.default;
308
+ }
309
+ return h;
310
+ }
311
+ findWorldSceneInstance() {
312
+ for (const scene of this.game.scene.getScenes(false)) {
313
+ const key = scene.scene.key;
314
+ if (key === EDITOR_OVERLAY_KEY)
315
+ continue;
316
+ if (key === UNBOXY_HUD_SCENE_KEY)
317
+ continue;
318
+ if (key === 'BootScene' || key === 'Boot')
319
+ continue;
320
+ // First non-overlay non-HUD non-Boot scene is the world scene.
321
+ return scene;
322
+ }
323
+ return undefined;
218
324
  }
219
325
  /**
220
326
  * Find the world scene's main camera so the overlay can mirror it.
@@ -12,6 +12,14 @@
12
12
  * Toggled by host via `unboxy:editor:setEditMode`.
13
13
  */
14
14
  export type EditorMode = 'world' | 'hud';
15
+ /**
16
+ * Editor debug-overlay flags (slice 8). Toggled by the host via
17
+ * `unboxy:editor:setDebugOverlay`; read each frame by EditorOverlayScene.
18
+ */
19
+ export interface EditorDebugOverlayState {
20
+ /** Draw hitbox rects + depth-anchor crosshairs on sprites with metadata. */
21
+ showHitboxes: boolean;
22
+ }
15
23
  interface EditorStateShape {
16
24
  active: boolean;
17
25
  selectedId: string | null;
@@ -34,6 +42,7 @@ interface EditorStateShape {
34
42
  y: number;
35
43
  };
36
44
  } | null;
45
+ debugOverlay: EditorDebugOverlayState;
37
46
  }
38
47
  /**
39
48
  * Accept any object — we only use a single internal symbol-style key, so
@@ -55,4 +64,6 @@ export declare function startDrag(game: AnyObject, entityId: string, startWorld:
55
64
  }): void;
56
65
  export declare function clearDrag(game: AnyObject): void;
57
66
  export declare function getDrag(game: AnyObject): EditorStateShape['drag'];
67
+ export declare function getDebugOverlayState(game: AnyObject): EditorDebugOverlayState;
68
+ export declare function setDebugOverlayState(game: AnyObject, patch: Partial<EditorDebugOverlayState>): void;
58
69
  export {};
@@ -15,7 +15,13 @@ export function getEditorState(game) {
15
15
  const existing = b[KEY];
16
16
  if (existing)
17
17
  return existing;
18
- const fresh = { active: false, selectedId: null, mode: 'world', drag: null };
18
+ const fresh = {
19
+ active: false,
20
+ selectedId: null,
21
+ mode: 'world',
22
+ drag: null,
23
+ debugOverlay: { showHitboxes: false },
24
+ };
19
25
  b[KEY] = fresh;
20
26
  return fresh;
21
27
  }
@@ -43,3 +49,10 @@ export function clearDrag(game) {
43
49
  export function getDrag(game) {
44
50
  return getEditorState(game).drag;
45
51
  }
52
+ export function getDebugOverlayState(game) {
53
+ return getEditorState(game).debugOverlay;
54
+ }
55
+ export function setDebugOverlayState(game, patch) {
56
+ const state = getEditorState(game).debugOverlay;
57
+ Object.assign(state, patch);
58
+ }
package/dist/index.d.ts CHANGED
@@ -18,7 +18,7 @@ export type { Transport, TransportKind } from './core/Transport.js';
18
18
  export type { UnboxyUser } from './protocol.js';
19
19
  export { loadWorldScene, preloadManifest, preloadSceneAssets, applyPixelArtFilters, getManifest, SCENES_BASE, MANIFEST_PATH, } from './scene/SceneLoader.js';
20
20
  export type { LoadWorldSceneOptions } from './scene/SceneLoader.js';
21
- export { spawnEntity, parseColor } from './scene/spawnEntity.js';
21
+ export { spawnEntity, parseColor, applyAssetHitbox } 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';
@@ -27,6 +27,6 @@ export type { RenderScriptModule } from './scene/renderScripts.js';
27
27
  export { setupEditorBridge } from './editor/EditorBridge.js';
28
28
  export { EditorOverlayScene, EDITOR_OVERLAY_KEY } from './editor/EditorOverlayScene.js';
29
29
  export type { EditorEntityPatch, EditorEnterMessage, EditorExitMessage, EditorGetSceneMessage, EditorApplyEditMessage, EditorSetSelectionMessage, EditorPanZoomMessage, EditorHostToSdkMessage, EditorSceneLoadedMessage, EditorSelectionPickedMessage, EditorDragEndMessage, EditorShortcutMessage, EditorCreateEntityMessage, EditorDeleteEntityMessage, EditorSetEditModeMessage, EditorSelectionRectMessage, EditorSdkToHostMessage, } from './protocol.js';
30
- export { SCHEMA_VERSION, isPerFrameNinePatch, } 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, HudEntity, HudEntityKind, HudEntityBase, HudTextEntity, HudImageEntity, HudIconButtonEntity, HudProgressBarEntity, HudPanelEntity, HudVisual, HudTextVisual, HudImageVisual, HudIconButtonVisual, HudProgressBarVisual, HudPanelVisual, HudTextSource, HudNumberSource, HudLayer, Transform, Anchor, AnchorSide, NinePatchConfig, NinePatchPerFrame, } from './scene/types.js';
30
+ export { SCHEMA_VERSION, isPerFrameNinePatch, isPerFrameHitbox, } 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, HudEntity, HudEntityKind, HudEntityBase, HudTextEntity, HudImageEntity, HudIconButtonEntity, HudProgressBarEntity, HudPanelEntity, HudVisual, HudTextVisual, HudImageVisual, HudIconButtonVisual, HudProgressBarVisual, HudPanelVisual, HudTextSource, HudNumberSource, HudLayer, Transform, Anchor, AnchorSide, NinePatchConfig, NinePatchPerFrame, HitboxRect, HitboxPerFrame, DepthAnchor, } from './scene/types.js';
32
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
@@ -15,11 +15,11 @@ export { UnboxyRoom, PlayerDataFacade, RoomDataFacade, ChatFacade, MAX_CHAT_TEXT
15
15
  export { RpcError } from './core/Transport.js';
16
16
  // Scene-as-data (visual editor foundation, slice 1)
17
17
  export { loadWorldScene, preloadManifest, preloadSceneAssets, applyPixelArtFilters, getManifest, SCENES_BASE, MANIFEST_PATH, } from './scene/SceneLoader.js';
18
- export { spawnEntity, parseColor } from './scene/spawnEntity.js';
18
+ export { spawnEntity, parseColor, applyAssetHitbox } from './scene/spawnEntity.js';
19
19
  export { EntityRegistry, attachEntityRegistry, getEntityRegistry, } from './scene/EntityRegistry.js';
20
20
  export { setupEditorModeListener, isEditMode } from './scene/EditorMode.js';
21
21
  export { setRenderScriptRegistry, getRenderScriptRegistry, resolveRenderScript, } from './scene/renderScripts.js';
22
22
  export { setupEditorBridge } from './editor/EditorBridge.js';
23
23
  export { EditorOverlayScene, EDITOR_OVERLAY_KEY } from './editor/EditorOverlayScene.js';
24
- export { SCHEMA_VERSION, isPerFrameNinePatch, } from './scene/types.js';
24
+ export { SCHEMA_VERSION, isPerFrameNinePatch, isPerFrameHitbox, } from './scene/types.js';
25
25
  export { PROTOCOL_VERSION, } from './protocol.js';
@@ -191,7 +191,42 @@ export interface EditorSetEditModeMessage {
191
191
  type: 'unboxy:editor:setEditMode';
192
192
  mode: 'world' | 'hud';
193
193
  }
194
- export type EditorHostToSdkMessage = EditorEnterMessage | EditorExitMessage | EditorGetSceneMessage | EditorApplyEditMessage | EditorSetSelectionMessage | EditorPanZoomMessage | EditorCreateEntityMessage | EditorDeleteEntityMessage | EditorSetEditModeMessage;
194
+ /**
195
+ * Asset-level live update (slice 8 — hitbox / depthAnchor editing).
196
+ *
197
+ * When the user edits an Asset's hitbox or depthAnchor in the Hitbox Editor
198
+ * modal, the change is at the asset level — not tied to a specific entity
199
+ * patch. The host sends this message; the bridge:
200
+ * 1. Upserts the asset into the iframe's cached manifest
201
+ * 2. Walks the entity registry, finds every sprite entity using this asset
202
+ * (matched via `entityAssetId` data-manager stash), and re-applies
203
+ * `setOrigin(depthAnchor)` + `applyAssetHitbox` to each
204
+ *
205
+ * Unlike `applyEdit`, this is NOT scoped to a single entity — one asset edit
206
+ * can affect dozens of in-scene sprites simultaneously.
207
+ */
208
+ export interface EditorAssetUpdateMessage {
209
+ type: 'unboxy:editor:assetUpdate';
210
+ /** Full AssetRecord — replaces the existing manifest entry. */
211
+ asset: unknown;
212
+ }
213
+ /**
214
+ * Debug overlay toggle (slice 8 — "Show hitboxes" diagnostic).
215
+ *
216
+ * When the user toggles the scene-panel checkbox, the host sends this
217
+ * message. The overlay scene draws each sprite's hitbox rect (semi-
218
+ * transparent green) + depth-anchor crosshair (cyan ✚) for entities whose
219
+ * asset has those metadata fields set. Sprites without metadata get
220
+ * nothing drawn — chat-only Workflow A respect.
221
+ *
222
+ * Edit-mode only — complements Phaser's runtime `physics.world.createDebugGraphic`
223
+ * (which is the Play-mode tool), doesn't replace it.
224
+ */
225
+ export interface EditorSetDebugOverlayMessage {
226
+ type: 'unboxy:editor:setDebugOverlay';
227
+ showHitboxes: boolean;
228
+ }
229
+ export type EditorHostToSdkMessage = EditorEnterMessage | EditorExitMessage | EditorGetSceneMessage | EditorApplyEditMessage | EditorSetSelectionMessage | EditorPanZoomMessage | EditorCreateEntityMessage | EditorDeleteEntityMessage | EditorSetEditModeMessage | EditorAssetUpdateMessage | EditorSetDebugOverlayMessage;
195
230
  export interface EditorSceneLoadedMessage {
196
231
  type: 'unboxy:editor:sceneLoaded';
197
232
  sceneId: string;
@@ -350,6 +350,14 @@ export async function loadWorldScene(scene, sceneId, options = {}) {
350
350
  spawnEntity(ctx, entity);
351
351
  // Configure camera.
352
352
  applyCamera(scene, sceneFile, registry);
353
+ // Slice 8: install per-frame Y-sort hook when the scene opts in. Walks
354
+ // the entity registry every frame and sets `sprite.depth = sprite.y` so
355
+ // closer-to-camera sprites overlap farther ones. Two opt-out signals
356
+ // (explicit transform.depth or properties.skipYSort) keep specific
357
+ // entities on their constant layer.
358
+ if (sceneFile.ySort) {
359
+ installYSortHook(scene, registry);
360
+ }
353
361
  // Auto-launch the HUD scene attached to this world (slice 5). Imported
354
362
  // lazily to avoid pulling HudRuntime into worlds that don't need it.
355
363
  if (ref.hud) {
@@ -399,6 +407,43 @@ function resolveAsset(manifest, assetId) {
399
407
  }
400
408
  return asset;
401
409
  }
410
+ /**
411
+ * Slice 8: per-frame Y-sort. Walks the entity registry and assigns
412
+ * `setDepth(y)` to every sprite/primitive/code-rendered entity that hasn't
413
+ * opted out via explicit `transform.depth` or `properties.skipYSort`.
414
+ *
415
+ * Iteration scope is the registry, not `scene.children.list` — registry
416
+ * holds slice-1 entities only (no editor overlay graphics, no transient
417
+ * Graphics drawn by behavior code). Cost is O(n) per frame where n =
418
+ * entity count; well within budget for a 200-entity top-down scene at
419
+ * 60 FPS.
420
+ *
421
+ * Opt-out priority:
422
+ * 1. `entity.transform.depth` is a number → that constant wins permanently;
423
+ * setDepth never overwrites. For "cloud always on top," "tilemap at
424
+ * -1000," etc.
425
+ * 2. `entity.properties.skipYSort: true` → behavior code manages depth;
426
+ * ySort doesn't touch the entity.
427
+ *
428
+ * Both checks read from the data manager (stashed by `tagGameObject` in
429
+ * spawnEntity.ts) to avoid round-tripping to the scene file every frame.
430
+ */
431
+ function installYSortHook(scene, registry) {
432
+ scene.events.on(Phaser.Scenes.Events.UPDATE, () => {
433
+ for (const go of registry.all()) {
434
+ const transform = go.getData('entityTransform');
435
+ if (typeof transform?.depth === 'number')
436
+ continue;
437
+ const props = go.getData('entityProperties');
438
+ if (props?.skipYSort)
439
+ continue;
440
+ const target = go;
441
+ if (typeof target.y === 'number' && target.setDepth) {
442
+ target.setDepth(target.y);
443
+ }
444
+ }
445
+ });
446
+ }
402
447
  function applyCamera(scene, sceneFile, registry) {
403
448
  const cam = scene.cameras.main;
404
449
  const cfg = sceneFile.camera ?? {};
@@ -28,3 +28,30 @@ export declare function spawnEntity(ctx: SpawnContext, entity: WorldEntity): Pha
28
28
  * suitable for Phaser's color APIs.
29
29
  */
30
30
  export declare function parseColor(input: string): number;
31
+ /**
32
+ * Apply the asset's `hitbox` metadata to a sprite's physics body.
33
+ *
34
+ * Called automatically at spawn from `createSprite`; ALSO callable by
35
+ * behavior code that attaches a body later (e.g. the agent's physics skill
36
+ * does `scene.physics.add.existing(sprite); applyAssetHitbox(sprite, asset);`).
37
+ *
38
+ * Semantics:
39
+ * - **No-op (silent)** when `asset.hitbox` is unset — that's the chat-only
40
+ * path (primitives, AI-gen images without vision metadata). Workflow A
41
+ * in design doc 09 §4.4 is unaffected.
42
+ * - **Dev-warn (NOT throw)** when called on a sprite without a physics body.
43
+ * The most likely agent mistake is calling this BEFORE
44
+ * `scene.physics.add.existing(sprite)`; the warning surfaces in the
45
+ * iframe console for the agent's post-turn diagnostic loop.
46
+ * - **Per-frame variant** installs an `ANIMATION_UPDATE` listener that
47
+ * swaps `body.setSize/setOffset` on every frame change during anim
48
+ * playback. Listener is idempotent — calling `applyAssetHitbox` twice
49
+ * on the same sprite tears down the prior listener before installing a
50
+ * new one (handles asset hot-reload + Inspector live-edits).
51
+ *
52
+ * Caveat: per-frame swap fires on `Phaser.Animations.Events.ANIMATION_UPDATE`
53
+ * only, which means manual `sprite.setFrame(idx)` calls outside of animation
54
+ * playback do NOT swap the body. v1 accepts this — combat sheets are
55
+ * animation-driven. A `setFrame` wrapper is a v2 candidate.
56
+ */
57
+ export declare function applyAssetHitbox(sprite: Phaser.GameObjects.Sprite, asset: AssetRecord): void;
@@ -1,3 +1,5 @@
1
+ import Phaser from 'phaser';
2
+ import { isPerFrameHitbox, } from './types.js';
1
3
  /**
2
4
  * Spawn one entity into the scene and register it. Returns the created
3
5
  * GameObject so callers (notably `spawnEntity` itself, recursing on a
@@ -60,6 +62,21 @@ function createSprite(ctx, entity) {
60
62
  sprite.setFlipX(true);
61
63
  if (v.flipY)
62
64
  sprite.setFlipY(true);
65
+ // Slice 8: depth anchor → setOrigin so sprite.y aligns with the asset's
66
+ // footprint pixel (feet, trunk base, etc.). Without this, ySort compares
67
+ // geometric centers — characters draw behind walls they're standing in
68
+ // front of. Phaser's sprite.width / .height reflect frame dims (not the
69
+ // whole spritesheet texture).
70
+ if (asset.depthAnchor) {
71
+ const frameW = sprite.width || 1;
72
+ const frameH = sprite.height || 1;
73
+ sprite.setOrigin(asset.depthAnchor.x / frameW, asset.depthAnchor.y / frameH);
74
+ }
75
+ // Slice 8: apply asset.hitbox to the physics body IF a body exists. We do
76
+ // NOT auto-add a body — the agent's physics skill / behavior code decides
77
+ // when to wire collision. This call is a no-op when asset.hitbox is unset
78
+ // (chat-only Workflow A path stays untouched).
79
+ applyAssetHitbox(sprite, asset);
63
80
  return sprite;
64
81
  }
65
82
  /**
@@ -188,6 +205,17 @@ function tagGameObject(go, entity) {
188
205
  go.setData('entityRole', entity.role);
189
206
  if (entity.properties)
190
207
  go.setData('entityProperties', entity.properties);
208
+ // Slice 8: ySort loop reads transform.depth to skip explicit-depth entities.
209
+ // Stash the transform on the GO's data manager to avoid a round-trip back
210
+ // to the scene file every frame.
211
+ go.setData('entityTransform', entity.transform);
212
+ // Slice 8: assetUpdate handler walks the registry to find all instances of
213
+ // an asset whose hitbox/anchor changed. Stash the assetId for sprite
214
+ // entities so the lookup is O(n) on the registry rather than a per-entity
215
+ // re-parse of the scene file.
216
+ if (entity.kind === 'sprite') {
217
+ go.setData('entityAssetId', entity.visual.assetId);
218
+ }
191
219
  }
192
220
  /**
193
221
  * Trigger stub renderer — slice 3. Renders the trigger zone as a
@@ -263,3 +291,76 @@ export function parseColor(input) {
263
291
  return parseInt(trimmed.slice(2), 16);
264
292
  return parseInt(trimmed, 16);
265
293
  }
294
+ // --- Slice 8: hitbox application -----------------------------------------
295
+ const HITBOX_LISTENER_KEY = 'unboxyHitboxListener';
296
+ /**
297
+ * Apply the asset's `hitbox` metadata to a sprite's physics body.
298
+ *
299
+ * Called automatically at spawn from `createSprite`; ALSO callable by
300
+ * behavior code that attaches a body later (e.g. the agent's physics skill
301
+ * does `scene.physics.add.existing(sprite); applyAssetHitbox(sprite, asset);`).
302
+ *
303
+ * Semantics:
304
+ * - **No-op (silent)** when `asset.hitbox` is unset — that's the chat-only
305
+ * path (primitives, AI-gen images without vision metadata). Workflow A
306
+ * in design doc 09 §4.4 is unaffected.
307
+ * - **Dev-warn (NOT throw)** when called on a sprite without a physics body.
308
+ * The most likely agent mistake is calling this BEFORE
309
+ * `scene.physics.add.existing(sprite)`; the warning surfaces in the
310
+ * iframe console for the agent's post-turn diagnostic loop.
311
+ * - **Per-frame variant** installs an `ANIMATION_UPDATE` listener that
312
+ * swaps `body.setSize/setOffset` on every frame change during anim
313
+ * playback. Listener is idempotent — calling `applyAssetHitbox` twice
314
+ * on the same sprite tears down the prior listener before installing a
315
+ * new one (handles asset hot-reload + Inspector live-edits).
316
+ *
317
+ * Caveat: per-frame swap fires on `Phaser.Animations.Events.ANIMATION_UPDATE`
318
+ * only, which means manual `sprite.setFrame(idx)` calls outside of animation
319
+ * playback do NOT swap the body. v1 accepts this — combat sheets are
320
+ * animation-driven. A `setFrame` wrapper is a v2 candidate.
321
+ */
322
+ export function applyAssetHitbox(sprite, asset) {
323
+ if (!asset.hitbox)
324
+ return;
325
+ const body = sprite.body;
326
+ if (!body) {
327
+ // eslint-disable-next-line no-console
328
+ console.warn(`[unboxy/scene] applyAssetHitbox called on a sprite with no physics ` +
329
+ `body (asset=${asset.id}). Call scene.physics.add.existing(sprite) ` +
330
+ `first, or this is a no-op.`);
331
+ return;
332
+ }
333
+ // Tear down any previously installed per-frame listener so re-applying is
334
+ // idempotent. The listener reference is stashed on the sprite's data
335
+ // manager because Phaser's emitter needs the exact function reference to
336
+ // remove it.
337
+ const prior = sprite.getData(HITBOX_LISTENER_KEY);
338
+ if (prior) {
339
+ sprite.off(Phaser.Animations.Events.ANIMATION_UPDATE, prior);
340
+ sprite.setData(HITBOX_LISTENER_KEY, undefined);
341
+ }
342
+ if (isPerFrameHitbox(asset.hitbox)) {
343
+ // Apply the default shape immediately — covers the current frame plus
344
+ // every frame not present in the overrides map.
345
+ applyShape(body, asset.hitbox.default);
346
+ const overrides = asset.hitbox.frames;
347
+ const defaultShape = asset.hitbox.default;
348
+ const listener = (_anim, frame) => {
349
+ applyShape(body, overrides[frame.index] ?? defaultShape);
350
+ };
351
+ sprite.setData(HITBOX_LISTENER_KEY, listener);
352
+ sprite.on(Phaser.Animations.Events.ANIMATION_UPDATE, listener);
353
+ }
354
+ else {
355
+ applyShape(body, asset.hitbox);
356
+ }
357
+ }
358
+ /**
359
+ * Internal helper that maps a HitboxRect to Phaser's body API. v1 handles
360
+ * `kind: 'rect'` only; v2 will widen the union to `HitboxRect | HitboxCircle`
361
+ * and branch on `shape.kind` — see design doc 09 §9.2.1.
362
+ */
363
+ function applyShape(body, shape) {
364
+ body.setSize(shape.w, shape.h);
365
+ body.setOffset(shape.x, shape.y);
366
+ }
@@ -97,6 +97,41 @@ export interface AssetRecord {
97
97
  * backfill close their own gaps separately (see design doc 07 §8.4).
98
98
  */
99
99
  pixelArt?: boolean;
100
+ /**
101
+ * Per-asset collision body metadata (slice 8 — see design doc 09).
102
+ *
103
+ * Two shapes:
104
+ * - `HitboxRect` — single rect shared across every frame (top-down
105
+ * characters with constant foot footprint, trees, buildings, walls).
106
+ * - `HitboxPerFrame` — `default` rect plus per-frame overrides indexed by
107
+ * frame number. Used for melee combat where an attack-swing frame's
108
+ * hitbox extends to where the tool reaches (Sprout Lands chop-the-tree
109
+ * case). The SDK installs an `ANIMATION_UPDATE` listener that swaps
110
+ * `body.setSize/setOffset` per frame during animation playback.
111
+ *
112
+ * v1 ships `kind: 'rect'` only. v2 adds `'circle'` as a pure forward-compat
113
+ * extension — see design doc 09 §9.2.1.
114
+ *
115
+ * Consumed by `applyAssetHitbox(sprite, asset)` at spawn time AND callable
116
+ * by behavior code after attaching a body. No-op if the sprite has no body.
117
+ */
118
+ hitbox?: HitboxRect | HitboxPerFrame;
119
+ /**
120
+ * Asset-wide footprint anchor (slice 8 — see design doc 09 §2).
121
+ *
122
+ * Pixel within the asset's frame (top-left = 0,0) that defines where the
123
+ * sprite "touches the ground." SDK applies as
124
+ * `sprite.setOrigin(x / frameW, y / frameH)` at spawn, so `sprite.y` in
125
+ * world space equals the world y of the anchor pixel — perfect for ySort
126
+ * (`sprite.depth = sprite.y`).
127
+ *
128
+ * Typical values: character feet (e.g. `(16, 28)` on a 32×32 cell), tree
129
+ * trunk base, building foundation midpoint. Decorations / centered objects
130
+ * leave unset → Phaser default origin (0.5, 0.5) → ySort by geometric
131
+ * center (acceptable fallback; the scene panel surfaces a warning when
132
+ * ySort is on and some sprite assets lack anchor).
133
+ */
134
+ depthAnchor?: DepthAnchor;
100
135
  }
101
136
  /** Single-image 9-slice config. Pixels from each edge to the slice line. */
102
137
  export interface NinePatchConfig {
@@ -119,6 +154,74 @@ export interface NinePatchPerFrame {
119
154
  * sites that only need to handle one shape stay narrow.
120
155
  */
121
156
  export declare function isPerFrameNinePatch(np: NinePatchConfig | NinePatchPerFrame | undefined): np is NinePatchPerFrame;
157
+ /**
158
+ * Rectangular hitbox in source-pixel coordinates (top-left = 0,0 within the
159
+ * asset's frame). Consumed by the SDK at sprite spawn time as
160
+ * `body.setSize(w, h)` + `body.setOffset(x, y)` when a physics body exists.
161
+ *
162
+ * v1 is the only supported `kind`. v2 adds `'circle'` (see design doc 09
163
+ * §9.2.1 for the full forward-compat migration story); polygon is v3+.
164
+ */
165
+ export interface HitboxRect {
166
+ kind: 'rect';
167
+ /** Top-left x of the hitbox in source pixels. */
168
+ x: number;
169
+ /** Top-left y of the hitbox in source pixels. */
170
+ y: number;
171
+ w: number;
172
+ h: number;
173
+ }
174
+ /**
175
+ * v2 — placeholder doc only. NOT in v1 schema. When v2 adds circle, this
176
+ * interface lands and the `AssetRecord.hitbox` + `HitboxPerFrame.default` /
177
+ * `.frames` unions widen to include it. v1 backend validation whitelists
178
+ * `kind in ['rect']`; v2 widens the whitelist. All v1 data stays valid.
179
+ *
180
+ * interface HitboxCircle {
181
+ * kind: 'circle';
182
+ * x: number; // center x in source-pixel coords of frame 0
183
+ * y: number; // center y in source-pixel coords of frame 0
184
+ * radius: number;
185
+ * }
186
+ */
187
+ /**
188
+ * Per-frame hitbox configuration. The `default` shape applies to every frame
189
+ * not present in `frames`; entries in `frames` override per frame index.
190
+ *
191
+ * This default+overrides model matches how combat sheets decompose — most
192
+ * frames share one hitbox (idle / walk / hurt at the feet), only attack-swing
193
+ * frames need a wider shape that reaches to where the tool / weapon visually
194
+ * extends. The motivating Sprout Lands case: 16-frame character sheet where
195
+ * frames 8–11 are an axe swing reaching ~16 pixels sideways past the foot.
196
+ *
197
+ * Discriminator: `'default' in hitbox` (HitboxRect has `kind` instead).
198
+ */
199
+ export interface HitboxPerFrame {
200
+ default: HitboxRect;
201
+ /** Frame index → override shape. Frames absent fall back to `default`. */
202
+ frames: Record<number, HitboxRect>;
203
+ }
204
+ /**
205
+ * Type guard — true when `hitbox` is the per-frame variant. Used by SDK
206
+ * runtime to decide whether to install an `ANIMATION_UPDATE` listener that
207
+ * swaps body shape per frame.
208
+ */
209
+ export declare function isPerFrameHitbox(h: HitboxRect | HitboxPerFrame | undefined): h is HitboxPerFrame;
210
+ /**
211
+ * Depth anchor — pixel within the asset's frame (top-left = 0,0) that defines
212
+ * the sprite's footprint position. The SDK applies it at spawn as
213
+ * `sprite.setOrigin(x / frameW, y / frameH)` so the sprite's world `y` aligns
214
+ * with the world position of this pixel. ySort then compares foot positions
215
+ * directly via `sprite.depth = sprite.y` without per-asset offset math.
216
+ *
217
+ * Asset-wide — shared across all frames of a spritesheet. Per-frame variation
218
+ * is not in scope (footprint position doesn't change across animations even
219
+ * when hitbox shape does).
220
+ */
221
+ export interface DepthAnchor {
222
+ x: number;
223
+ y: number;
224
+ }
122
225
  export interface SheetAnimation {
123
226
  /** Phaser animation key — used as `sprite.play('<name>')`. */
124
227
  name: string;
@@ -337,6 +440,25 @@ export interface WorldScene {
337
440
  world: WorldSceneConfig;
338
441
  camera?: CameraConfig;
339
442
  entities: WorldEntity[];
443
+ /**
444
+ * Scene-level Y-sort opt-in (slice 8 — see design doc 09 §2.2).
445
+ *
446
+ * When `true`, the SDK installs a per-frame update hook in `loadWorldScene`
447
+ * that walks the entity registry and sets `sprite.depth = sprite.y` so
448
+ * closer-to-camera sprites overlap farther ones. Default `false` —
449
+ * side-scrollers, single-screen arcade, and chat-only Workflow A games
450
+ * don't pay the per-frame cost.
451
+ *
452
+ * Two opt-out mechanisms (orthogonal, cover different intents):
453
+ * - `entity.transform.depth` set to a number → that constant wins, ySort
454
+ * skips the entity. For "cloud always on top," "tilemap at -1000," etc.
455
+ * - `entity.properties.skipYSort: true` → ySort doesn't touch the entity's
456
+ * depth at all. For behavior code that manages depth dynamically.
457
+ *
458
+ * Top-down RPG / farming / iso / city-builder / MOBA / 2.5D platformer
459
+ * are the target genres.
460
+ */
461
+ ySort?: boolean;
340
462
  metadata?: {
341
463
  tags?: string[];
342
464
  author?: string;
@@ -15,3 +15,11 @@ export const SCHEMA_VERSION = 1;
15
15
  export function isPerFrameNinePatch(np) {
16
16
  return !!np && 'perFrame' in np;
17
17
  }
18
+ /**
19
+ * Type guard — true when `hitbox` is the per-frame variant. Used by SDK
20
+ * runtime to decide whether to install an `ANIMATION_UPDATE` listener that
21
+ * swaps body shape per frame.
22
+ */
23
+ export function isPerFrameHitbox(h) {
24
+ return !!h && 'default' in h;
25
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unboxy/phaser-sdk",
3
- "version": "0.2.43",
3
+ "version": "0.2.44",
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",