@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 +202 -2
- package/dist/editor/EditorBridge.js +76 -2
- package/dist/editor/EditorOverlayScene.d.ts +17 -0
- package/dist/editor/EditorOverlayScene.js +107 -1
- package/dist/editor/EditorState.d.ts +11 -0
- package/dist/editor/EditorState.js +14 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.js +2 -2
- package/dist/protocol.d.ts +36 -1
- package/dist/scene/SceneLoader.js +45 -0
- package/dist/scene/spawnEntity.d.ts +27 -0
- package/dist/scene/spawnEntity.js +101 -0
- package/dist/scene/types.d.ts +122 -0
- package/dist/scene/types.js +8 -0
- package/package.json +1 -1
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
|
-
##
|
|
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 {
|
|
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 = {
|
|
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';
|
package/dist/protocol.d.ts
CHANGED
|
@@ -191,7 +191,42 @@ export interface EditorSetEditModeMessage {
|
|
|
191
191
|
type: 'unboxy:editor:setEditMode';
|
|
192
192
|
mode: 'world' | 'hud';
|
|
193
193
|
}
|
|
194
|
-
|
|
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
|
+
}
|
package/dist/scene/types.d.ts
CHANGED
|
@@ -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;
|
package/dist/scene/types.js
CHANGED
|
@@ -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
|
+
}
|