@umicat/phaser-sdk 1.0.0
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 +1726 -0
- package/dist/core/Transport.d.ts +28 -0
- package/dist/core/Transport.js +7 -0
- package/dist/core/Umicat.d.ts +45 -0
- package/dist/core/Umicat.js +60 -0
- package/dist/core/UmicatGame.d.ts +43 -0
- package/dist/core/UmicatGame.js +64 -0
- package/dist/core/UmicatScene.d.ts +19 -0
- package/dist/core/UmicatScene.js +38 -0
- package/dist/core/transports/LocalStorageTransport.d.ts +22 -0
- package/dist/core/transports/LocalStorageTransport.js +78 -0
- package/dist/core/transports/PostMessageTransport.d.ts +28 -0
- package/dist/core/transports/PostMessageTransport.js +105 -0
- package/dist/editor/EditorBridge.d.ts +114 -0
- package/dist/editor/EditorBridge.js +2608 -0
- package/dist/editor/EditorOverlayScene.d.ts +333 -0
- package/dist/editor/EditorOverlayScene.js +1896 -0
- package/dist/editor/EditorState.d.ts +251 -0
- package/dist/editor/EditorState.js +197 -0
- package/dist/gamedata/GameDataModule.d.ts +45 -0
- package/dist/gamedata/GameDataModule.js +59 -0
- package/dist/index.d.ts +43 -0
- package/dist/index.js +43 -0
- package/dist/orientation.d.ts +5 -0
- package/dist/orientation.js +4 -0
- package/dist/protocol.d.ts +807 -0
- package/dist/protocol.js +3 -0
- package/dist/realtime/RealtimeModule.d.ts +93 -0
- package/dist/realtime/RealtimeModule.js +115 -0
- package/dist/realtime/UmicatRoom.d.ts +197 -0
- package/dist/realtime/UmicatRoom.js +353 -0
- package/dist/recording/RecordingManager.d.ts +11 -0
- package/dist/recording/RecordingManager.js +59 -0
- package/dist/saves/SavesModule.d.ts +23 -0
- package/dist/saves/SavesModule.js +37 -0
- package/dist/scene/EditorMode.d.ts +17 -0
- package/dist/scene/EditorMode.js +22 -0
- package/dist/scene/EntityRegistry.d.ts +39 -0
- package/dist/scene/EntityRegistry.js +103 -0
- package/dist/scene/GameConfig.d.ts +60 -0
- package/dist/scene/GameConfig.js +50 -0
- package/dist/scene/HudRuntime.d.ts +131 -0
- package/dist/scene/HudRuntime.js +1224 -0
- package/dist/scene/Prefabs.d.ts +92 -0
- package/dist/scene/Prefabs.js +175 -0
- package/dist/scene/Rules.d.ts +73 -0
- package/dist/scene/Rules.js +164 -0
- package/dist/scene/SceneLoader.d.ts +118 -0
- package/dist/scene/SceneLoader.js +615 -0
- package/dist/scene/Waves.d.ts +85 -0
- package/dist/scene/Waves.js +365 -0
- package/dist/scene/autotile.d.ts +103 -0
- package/dist/scene/autotile.js +321 -0
- package/dist/scene/renderScripts.d.ts +53 -0
- package/dist/scene/renderScripts.js +67 -0
- package/dist/scene/spawnEntity.d.ts +201 -0
- package/dist/scene/spawnEntity.js +1326 -0
- package/dist/scene/types.d.ts +1166 -0
- package/dist/scene/types.js +34 -0
- package/dist/screenshot/ScreenshotManager.d.ts +14 -0
- package/dist/screenshot/ScreenshotManager.js +33 -0
- package/package.json +35 -0
|
@@ -0,0 +1,1326 @@
|
|
|
1
|
+
import Phaser from 'phaser';
|
|
2
|
+
import { isPerFrameHitbox, } from './types.js';
|
|
3
|
+
import { getEntityRegistry } from './EntityRegistry.js';
|
|
4
|
+
/**
|
|
5
|
+
* Spawn one entity into the scene and register it. Returns the created
|
|
6
|
+
* GameObject so callers (notably `spawnEntity` itself, recursing on a
|
|
7
|
+
* group's children) can attach it to a parent container.
|
|
8
|
+
*/
|
|
9
|
+
export function spawnEntity(ctx, entity) {
|
|
10
|
+
let go;
|
|
11
|
+
switch (entity.kind) {
|
|
12
|
+
case 'sprite':
|
|
13
|
+
go = createSprite(ctx, entity);
|
|
14
|
+
break;
|
|
15
|
+
case 'rect':
|
|
16
|
+
go = createRect(ctx, entity);
|
|
17
|
+
break;
|
|
18
|
+
case 'circle':
|
|
19
|
+
go = createCircle(ctx, entity);
|
|
20
|
+
break;
|
|
21
|
+
case 'group':
|
|
22
|
+
go = createGroup(ctx, entity);
|
|
23
|
+
break;
|
|
24
|
+
case 'code-rendered':
|
|
25
|
+
go = createCodeRendered(ctx, entity);
|
|
26
|
+
break;
|
|
27
|
+
case 'tilemap':
|
|
28
|
+
go = createTilemap(ctx, entity);
|
|
29
|
+
break;
|
|
30
|
+
case 'trigger':
|
|
31
|
+
go = createTriggerStub(ctx, entity);
|
|
32
|
+
break;
|
|
33
|
+
default: {
|
|
34
|
+
const exhaustive = entity;
|
|
35
|
+
throw new Error(`[umicat/scene] unknown entity kind: ${JSON.stringify(exhaustive)}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
applyTransform(go, entity.transform);
|
|
39
|
+
tagGameObject(go, entity);
|
|
40
|
+
applyEntityPhysicsIfDeclared(ctx.scene, go, entity);
|
|
41
|
+
ctx.registry.register(entity.id, entity.role, go);
|
|
42
|
+
return go;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Apply a scene entity's optional `physics` block. Only renderable entity
|
|
46
|
+
* kinds (sprite / rect / circle / code-rendered) carry a body — they mirror
|
|
47
|
+
* `PrefabKind`. group / tilemap / trigger declare no body even though
|
|
48
|
+
* `physics` is on the shared `WorldEntityBase`. Routed through the same
|
|
49
|
+
* `applyEntityPhysics` the prefab path uses (via `prefabToEntity`), so
|
|
50
|
+
* authored entities and runtime instances get identical, correctly-anchored
|
|
51
|
+
* bodies.
|
|
52
|
+
*/
|
|
53
|
+
function applyEntityPhysicsIfDeclared(scene, go, entity) {
|
|
54
|
+
if (!entity.physics)
|
|
55
|
+
return;
|
|
56
|
+
if (entity.kind === 'sprite' ||
|
|
57
|
+
entity.kind === 'rect' ||
|
|
58
|
+
entity.kind === 'circle' ||
|
|
59
|
+
entity.kind === 'code-rendered') {
|
|
60
|
+
applyEntityPhysics(scene, go, entity, entity.physics);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Apply an Arcade physics body to a freshly spawned GameObject from a
|
|
65
|
+
* `PrefabPhysics` block. Shared by `spawnEntity` (authored scene entities)
|
|
66
|
+
* and `spawnPrefab` (runtime prefab instances, which route through
|
|
67
|
+
* `spawnEntity` via `prefabToEntity`) so both paths produce identical bodies.
|
|
68
|
+
*
|
|
69
|
+
* Body size defaults to the visual's drawn extent; offset defaults to
|
|
70
|
+
* centering the body inside that extent.
|
|
71
|
+
*
|
|
72
|
+
* Code-rendered visuals get an extra origin shift. A Phaser `Graphics` has
|
|
73
|
+
* `displayOrigin = (0, 0)` (it has no Origin component) while render scripts
|
|
74
|
+
* draw centered on local (0, 0). The Arcade body positions itself at
|
|
75
|
+
* `gameObject.x + offset.x` for a Graphics, so a default-offset body would
|
|
76
|
+
* land at the bottom-right of the visual — the "player floats above the
|
|
77
|
+
* platform" bug. Shifting the body frame by `(-visualW/2, -visualH/2)`
|
|
78
|
+
* measures offsets from the visual's top-left, matching sprite / primitive
|
|
79
|
+
* semantics (those have an Origin component that already accounts for the
|
|
80
|
+
* centering). This only touches the body, never the Graphics — so rendering
|
|
81
|
+
* is unaffected. (0.2.52 tried `g.setOrigin` on the Graphics instead, which
|
|
82
|
+
* shifted rendering and was reverted in 0.2.53; this is the body-only fix.)
|
|
83
|
+
*/
|
|
84
|
+
function applyEntityPhysics(scene, go, entity, physics) {
|
|
85
|
+
scene.physics.add.existing(go);
|
|
86
|
+
const body = go.body;
|
|
87
|
+
if (!body)
|
|
88
|
+
return; // shouldn't happen post `physics.add.existing` but guard
|
|
89
|
+
const visualW = renderableWidth(entity);
|
|
90
|
+
const visualH = renderableHeight(entity);
|
|
91
|
+
const bodyW = physics.bodyW ?? visualW;
|
|
92
|
+
const bodyH = physics.bodyH ?? visualH;
|
|
93
|
+
body.setSize(bodyW, bodyH);
|
|
94
|
+
// Code-rendered (Graphics) measures offsets from the visual's top-left —
|
|
95
|
+
// see the doc comment above. Sprite / primitive have an Origin component,
|
|
96
|
+
// so originShift stays 0 and behavior is identical to <= 0.2.53.
|
|
97
|
+
const isCodeRendered = entity.kind === 'code-rendered';
|
|
98
|
+
const originShiftX = isCodeRendered ? -visualW / 2 : 0;
|
|
99
|
+
const originShiftY = isCodeRendered ? -visualH / 2 : 0;
|
|
100
|
+
const defaultOffsetX = visualW > bodyW ? (visualW - bodyW) / 2 : 0;
|
|
101
|
+
const defaultOffsetY = visualH > bodyH ? (visualH - bodyH) / 2 : 0;
|
|
102
|
+
body.setOffset(originShiftX + (physics.offsetX ?? defaultOffsetX), originShiftY + (physics.offsetY ?? defaultOffsetY));
|
|
103
|
+
if (physics.immovable !== undefined)
|
|
104
|
+
body.setImmovable(physics.immovable);
|
|
105
|
+
if (physics.velocityX !== undefined || physics.velocityY !== undefined) {
|
|
106
|
+
body.setVelocity(physics.velocityX ?? body.velocity.x, physics.velocityY ?? body.velocity.y);
|
|
107
|
+
}
|
|
108
|
+
if (physics.collideWorldBounds)
|
|
109
|
+
body.setCollideWorldBounds(true);
|
|
110
|
+
if (physics.bounceX !== undefined || physics.bounceY !== undefined) {
|
|
111
|
+
body.setBounce(physics.bounceX ?? 0, physics.bounceY ?? 0);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Drawn width of a renderable entity, used as the default Arcade body width.
|
|
116
|
+
* Sprites return 0 so `body.setSize` falls back to the texture frame size.
|
|
117
|
+
*/
|
|
118
|
+
function renderableWidth(e) {
|
|
119
|
+
if (e.kind === 'sprite')
|
|
120
|
+
return 0; // body falls back to texture frame
|
|
121
|
+
if (e.kind === 'rect')
|
|
122
|
+
return e.width;
|
|
123
|
+
if (e.kind === 'circle')
|
|
124
|
+
return e.radius * 2;
|
|
125
|
+
if (e.kind === 'code-rendered')
|
|
126
|
+
return e.width ?? 64;
|
|
127
|
+
return 64;
|
|
128
|
+
}
|
|
129
|
+
/** Drawn height of a renderable entity — see `renderableWidth`. */
|
|
130
|
+
function renderableHeight(e) {
|
|
131
|
+
if (e.kind === 'sprite')
|
|
132
|
+
return 0; // body falls back to texture frame
|
|
133
|
+
if (e.kind === 'rect')
|
|
134
|
+
return e.height;
|
|
135
|
+
if (e.kind === 'circle')
|
|
136
|
+
return e.radius * 2;
|
|
137
|
+
if (e.kind === 'code-rendered')
|
|
138
|
+
return e.height ?? 64;
|
|
139
|
+
return 64;
|
|
140
|
+
}
|
|
141
|
+
function createSprite(ctx, entity) {
|
|
142
|
+
// Schema guard — flat schema (SDK 0.3.0). Sprite entities require
|
|
143
|
+
// `assetId` at the top level. Empty / missing → render a magenta
|
|
144
|
+
// placeholder so the rest of the scene loads + the agent sees a clear
|
|
145
|
+
// console message rather than the whole scene boot crashing.
|
|
146
|
+
if (typeof entity.assetId !== 'string') {
|
|
147
|
+
console.warn(`[umicat/scene] sprite entity '${entity.id}' is missing 'assetId'. ` +
|
|
148
|
+
`Sprite entities require { kind: 'sprite', assetId: '<id>', transform: {...} }. ` +
|
|
149
|
+
`Got: ${JSON.stringify(entity, null, 2)}`);
|
|
150
|
+
return missingAssetPlaceholder(ctx, '<missing assetId>');
|
|
151
|
+
}
|
|
152
|
+
let asset;
|
|
153
|
+
try {
|
|
154
|
+
asset = ctx.resolveAsset(entity.assetId);
|
|
155
|
+
}
|
|
156
|
+
catch (e) {
|
|
157
|
+
// Soft-fail when the assetId isn't in the manifest. SceneLoader's
|
|
158
|
+
// preloadSceneAssets logged the warning; here we render a clear
|
|
159
|
+
// magenta-bordered "?" placeholder so the entity is still visible
|
|
160
|
+
// and selectable in the editor. Same pattern as createCodeRendered's
|
|
161
|
+
// missing-render-script path.
|
|
162
|
+
return missingAssetPlaceholder(ctx, entity.assetId);
|
|
163
|
+
}
|
|
164
|
+
// Phaser's add.sprite handles both plain images and atlas/spritesheet
|
|
165
|
+
// textures uniformly when given (key, frame). For plain images, frame
|
|
166
|
+
// is undefined and Phaser uses the default frame.
|
|
167
|
+
const sprite = ctx.scene.add.sprite(0, 0, asset.textureKey, entity.frame);
|
|
168
|
+
if (entity.tint)
|
|
169
|
+
sprite.setTint(parseColor(entity.tint));
|
|
170
|
+
if (typeof entity.alpha === 'number')
|
|
171
|
+
sprite.setAlpha(entity.alpha);
|
|
172
|
+
if (entity.flipX)
|
|
173
|
+
sprite.setFlipX(true);
|
|
174
|
+
if (entity.flipY)
|
|
175
|
+
sprite.setFlipY(true);
|
|
176
|
+
// Slice 8: depth anchor → setOrigin so sprite.y aligns with the asset's
|
|
177
|
+
// footprint pixel (feet, trunk base, etc.). Without this, ySort compares
|
|
178
|
+
// geometric centers — characters draw behind walls they're standing in
|
|
179
|
+
// front of. Phaser's sprite.width / .height reflect frame dims (not the
|
|
180
|
+
// whole spritesheet texture).
|
|
181
|
+
if (asset.depthAnchor) {
|
|
182
|
+
const frameW = sprite.width || 1;
|
|
183
|
+
const frameH = sprite.height || 1;
|
|
184
|
+
sprite.setOrigin(asset.depthAnchor.x / frameW, asset.depthAnchor.y / frameH);
|
|
185
|
+
}
|
|
186
|
+
// Slice 8: apply asset.hitbox to the physics body IF a body exists. We do
|
|
187
|
+
// NOT auto-add a body — the agent's physics skill / behavior code decides
|
|
188
|
+
// when to wire collision. This call is a no-op when asset.hitbox is unset
|
|
189
|
+
// (chat-only Workflow A path stays untouched).
|
|
190
|
+
applyAssetHitbox(sprite, asset);
|
|
191
|
+
return sprite;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Magenta "?" placeholder for an entity whose assetId isn't in the manifest.
|
|
195
|
+
* Drawn as a Graphics so the editor's hit-test (which uses getBounds) lands
|
|
196
|
+
* on the visible square. Mirrors createCodeRendered's missing-script path.
|
|
197
|
+
*/
|
|
198
|
+
function missingAssetPlaceholder(ctx, assetId) {
|
|
199
|
+
const w = 64;
|
|
200
|
+
const h = 64;
|
|
201
|
+
const g = ctx.scene.add.graphics();
|
|
202
|
+
g.fillStyle(0x4d2244, 0.4);
|
|
203
|
+
g.fillRect(-w / 2, -h / 2, w, h);
|
|
204
|
+
g.lineStyle(2, 0xff00ff, 0.9);
|
|
205
|
+
g.strokeRect(-w / 2, -h / 2, w, h);
|
|
206
|
+
// Diagonal "no entry" lines + a "?" text so it's obvious from a glance.
|
|
207
|
+
g.lineBetween(-w / 2, -h / 2, w / 2, h / 2);
|
|
208
|
+
g.lineBetween(w / 2, -h / 2, -w / 2, h / 2);
|
|
209
|
+
// Phaser Graphics has no intrinsic size — sizeForHitTest sets bounds so
|
|
210
|
+
// the editor's hit-test (which uses getBounds) can find this entity.
|
|
211
|
+
sizeForHitTest(g, w, h);
|
|
212
|
+
g.setDataEnabled();
|
|
213
|
+
g.setData('unboxyMissingAssetId', assetId);
|
|
214
|
+
return g;
|
|
215
|
+
}
|
|
216
|
+
function createRect(ctx, e) {
|
|
217
|
+
if (typeof e.width !== 'number' || typeof e.height !== 'number') {
|
|
218
|
+
console.warn(`[umicat/scene] rect entity '${e.id}' is missing 'width' / 'height'. ` +
|
|
219
|
+
`Rect entities require { kind: 'rect', width: N, height: N, fillColor?: '#hex', transform: {...} }. ` +
|
|
220
|
+
`Got: ${JSON.stringify(e, null, 2)}`);
|
|
221
|
+
return ctx.scene.add.rectangle(0, 0, 64, 64, 0xff00ff);
|
|
222
|
+
}
|
|
223
|
+
const fill = e.fillColor ? parseColor(e.fillColor) : 0xffffff;
|
|
224
|
+
const rect = ctx.scene.add.rectangle(0, 0, e.width, e.height, fill);
|
|
225
|
+
if (e.strokeColor && e.strokeWidth) {
|
|
226
|
+
rect.setStrokeStyle(e.strokeWidth, parseColor(e.strokeColor));
|
|
227
|
+
}
|
|
228
|
+
if (typeof e.alpha === 'number')
|
|
229
|
+
rect.setAlpha(e.alpha);
|
|
230
|
+
return rect;
|
|
231
|
+
}
|
|
232
|
+
function createCircle(ctx, e) {
|
|
233
|
+
if (typeof e.radius !== 'number') {
|
|
234
|
+
console.warn(`[umicat/scene] circle entity '${e.id}' is missing 'radius'. ` +
|
|
235
|
+
`Circle entities require { kind: 'circle', radius: N, fillColor?: '#hex', transform: {...} }. ` +
|
|
236
|
+
`Got: ${JSON.stringify(e, null, 2)}`);
|
|
237
|
+
return ctx.scene.add.circle(0, 0, 32, 0xff00ff);
|
|
238
|
+
}
|
|
239
|
+
const fill = e.fillColor ? parseColor(e.fillColor) : 0xffffff;
|
|
240
|
+
const arc = ctx.scene.add.circle(0, 0, e.radius, fill);
|
|
241
|
+
if (e.strokeColor && e.strokeWidth) {
|
|
242
|
+
arc.setStrokeStyle(e.strokeWidth, parseColor(e.strokeColor));
|
|
243
|
+
}
|
|
244
|
+
if (typeof e.alpha === 'number')
|
|
245
|
+
arc.setAlpha(e.alpha);
|
|
246
|
+
return arc;
|
|
247
|
+
}
|
|
248
|
+
function createGroup(ctx, entity) {
|
|
249
|
+
const container = ctx.scene.add.container(0, 0);
|
|
250
|
+
for (const child of entity.children) {
|
|
251
|
+
if (child.kind === 'group') {
|
|
252
|
+
// v1 cap: groups can't nest. Surface a clear error rather than
|
|
253
|
+
// silently producing a half-attached hierarchy.
|
|
254
|
+
throw new Error(`[umicat/scene] group '${entity.id}' has a nested group child '${child.id}' — v1 supports one level of nesting only`);
|
|
255
|
+
}
|
|
256
|
+
const childGo = spawnEntity(ctx, child);
|
|
257
|
+
container.add(childGo);
|
|
258
|
+
}
|
|
259
|
+
return container;
|
|
260
|
+
}
|
|
261
|
+
function createCodeRendered(ctx, entity) {
|
|
262
|
+
// Schema guard — flat schema (SDK 0.3.0). Code-rendered entities require
|
|
263
|
+
// `script` at the top level. Without it, accessing `entity.script` is
|
|
264
|
+
// undefined and we can't resolve a renderer.
|
|
265
|
+
if (typeof entity.script !== 'string') {
|
|
266
|
+
console.warn(`[umicat/scene] code-rendered entity '${entity.id}' is missing 'script'. ` +
|
|
267
|
+
`Code-rendered entities require { kind: 'code-rendered', script: 'src/visuals/<name>.ts', transform: {...} }. ` +
|
|
268
|
+
`Got: ${JSON.stringify(entity, null, 2)}`);
|
|
269
|
+
return missingAssetPlaceholder(ctx, '<missing script>');
|
|
270
|
+
}
|
|
271
|
+
const w = entity.width ?? 64;
|
|
272
|
+
const h = entity.height ?? 64;
|
|
273
|
+
const render = ctx.resolveRenderScript?.(entity.script);
|
|
274
|
+
if (!render) {
|
|
275
|
+
// Render scripts have a registry, but a missing entry shouldn't crash
|
|
276
|
+
// the whole scene boot — the agent might be writing the script in a
|
|
277
|
+
// follow-up turn. Render a clear "?" placeholder so the user sees the
|
|
278
|
+
// entity exists but the visual is unresolved.
|
|
279
|
+
const ph = ctx.scene.add.graphics();
|
|
280
|
+
ph.fillStyle(0x666666, 0.4);
|
|
281
|
+
ph.fillRect(-w / 2, -h / 2, w, h);
|
|
282
|
+
ph.lineStyle(2, 0xff8800, 0.9);
|
|
283
|
+
ph.strokeRect(-w / 2, -h / 2, w, h);
|
|
284
|
+
sizeForHitTest(ph, w, h);
|
|
285
|
+
ph.setData('renderScriptMissing', entity.script);
|
|
286
|
+
return ph;
|
|
287
|
+
}
|
|
288
|
+
const g = ctx.scene.add.graphics();
|
|
289
|
+
// Install the auto-tracker (0.2.96) so the editor's hit area follows
|
|
290
|
+
// the script's actual drawn extent — not a stale `width/height` from
|
|
291
|
+
// the scene file. Critical for procedural visuals (snake, grids,
|
|
292
|
+
// tilemaps, dynamic UI) where the rendered area changes every frame.
|
|
293
|
+
const getBounds = installGraphicsTracker(g);
|
|
294
|
+
g.setData('__unboxyGraphicsBoundsGetter', getBounds);
|
|
295
|
+
const params = entity.params ?? {};
|
|
296
|
+
render(g, params);
|
|
297
|
+
applyTrackedHitArea(g, w, h);
|
|
298
|
+
// Stash data needed for live re-render from EditorBridge.applyEdit.
|
|
299
|
+
g.setData('renderScriptPath', entity.script);
|
|
300
|
+
g.setData('renderScriptParams', params);
|
|
301
|
+
return g;
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Read the auto-tracker's recorded draw extent and set editor hit-area
|
|
305
|
+
* data on the GameObject. Falls back to declared `visual.width/height`
|
|
306
|
+
* (centered on transform) when nothing was tracked (script didn't call
|
|
307
|
+
* any tracked methods, or called them through unwrapped paths).
|
|
308
|
+
*
|
|
309
|
+
* Exported so EditorBridge can call it after a re-render too —
|
|
310
|
+
* applyCodeRenderedParamsPatch reads new params, calls render again,
|
|
311
|
+
* then this updates the hit area to the new draw extent.
|
|
312
|
+
*/
|
|
313
|
+
export function applyTrackedHitArea(g, fallbackW, fallbackH) {
|
|
314
|
+
const getter = g.getData('__unboxyGraphicsBoundsGetter');
|
|
315
|
+
const bounds = getter ? getter() : null;
|
|
316
|
+
if (bounds && bounds.width > 0 && bounds.height > 0) {
|
|
317
|
+
g.setData('editorHitWidth', bounds.width);
|
|
318
|
+
g.setData('editorHitHeight', bounds.height);
|
|
319
|
+
g.setData('editorHitOffsetX', bounds.x);
|
|
320
|
+
g.setData('editorHitOffsetY', bounds.y);
|
|
321
|
+
}
|
|
322
|
+
else {
|
|
323
|
+
g.setData('editorHitWidth', fallbackW);
|
|
324
|
+
g.setData('editorHitHeight', fallbackH);
|
|
325
|
+
g.setData('editorHitOffsetX', undefined);
|
|
326
|
+
g.setData('editorHitOffsetY', undefined);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Wrap a Phaser Graphics object's draw methods so they record min/max
|
|
331
|
+
* XY of every shape drawn through them. Returns a getter that yields
|
|
332
|
+
* the current bounds (or null if no draws since last `clear()`).
|
|
333
|
+
*
|
|
334
|
+
* Covers the methods agents commonly reach for in render scripts:
|
|
335
|
+
* fillRect, strokeRect, fillRoundedRect, strokeRoundedRect, fillCircle,
|
|
336
|
+
* strokeCircle, fillEllipse, strokeEllipse, fillTriangle, strokeTriangle,
|
|
337
|
+
* lineBetween, fillPoint, arc, moveTo, lineTo. `clear()` resets the
|
|
338
|
+
* tracker so each redraw cycle starts fresh. Less-common methods
|
|
339
|
+
* (fillPath/strokePath after manual moveTo/lineTo, fillRectShape, etc.)
|
|
340
|
+
* partially covered via moveTo/lineTo tracking; pure-shape variants
|
|
341
|
+
* fall back to `visual.width/height` — acceptable for v1.
|
|
342
|
+
*/
|
|
343
|
+
function installGraphicsTracker(g) {
|
|
344
|
+
let minX = Infinity;
|
|
345
|
+
let minY = Infinity;
|
|
346
|
+
let maxX = -Infinity;
|
|
347
|
+
let maxY = -Infinity;
|
|
348
|
+
const track = (x, y) => {
|
|
349
|
+
if (x < minX)
|
|
350
|
+
minX = x;
|
|
351
|
+
if (x > maxX)
|
|
352
|
+
maxX = x;
|
|
353
|
+
if (y < minY)
|
|
354
|
+
minY = y;
|
|
355
|
+
if (y > maxY)
|
|
356
|
+
maxY = y;
|
|
357
|
+
};
|
|
358
|
+
const trackRect = (x, y, w, h) => {
|
|
359
|
+
track(x, y);
|
|
360
|
+
track(x + w, y + h);
|
|
361
|
+
};
|
|
362
|
+
const trackRadius = (x, y, r) => {
|
|
363
|
+
track(x - r, y - r);
|
|
364
|
+
track(x + r, y + r);
|
|
365
|
+
};
|
|
366
|
+
const gAny = g;
|
|
367
|
+
const wrap = (name, pre) => {
|
|
368
|
+
const orig = gAny[name].bind(g);
|
|
369
|
+
gAny[name] = (...args) => {
|
|
370
|
+
pre(...args);
|
|
371
|
+
return orig(...args);
|
|
372
|
+
};
|
|
373
|
+
};
|
|
374
|
+
wrap('fillRect', (x, y, w, h) => trackRect(x, y, w, h));
|
|
375
|
+
wrap('strokeRect', (x, y, w, h) => trackRect(x, y, w, h));
|
|
376
|
+
wrap('fillRoundedRect', (x, y, w, h) => trackRect(x, y, w, h));
|
|
377
|
+
wrap('strokeRoundedRect', (x, y, w, h) => trackRect(x, y, w, h));
|
|
378
|
+
wrap('fillCircle', (x, y, r) => trackRadius(x, y, r));
|
|
379
|
+
wrap('strokeCircle', (x, y, r) => trackRadius(x, y, r));
|
|
380
|
+
wrap('fillEllipse', (x, y, w, h) => trackRect(x - w / 2, y - h / 2, w, h));
|
|
381
|
+
wrap('strokeEllipse', (x, y, w, h) => trackRect(x - w / 2, y - h / 2, w, h));
|
|
382
|
+
wrap('fillTriangle', (x1, y1, x2, y2, x3, y3) => {
|
|
383
|
+
track(x1, y1);
|
|
384
|
+
track(x2, y2);
|
|
385
|
+
track(x3, y3);
|
|
386
|
+
});
|
|
387
|
+
wrap('strokeTriangle', (x1, y1, x2, y2, x3, y3) => {
|
|
388
|
+
track(x1, y1);
|
|
389
|
+
track(x2, y2);
|
|
390
|
+
track(x3, y3);
|
|
391
|
+
});
|
|
392
|
+
wrap('lineBetween', (x1, y1, x2, y2) => {
|
|
393
|
+
track(x1, y1);
|
|
394
|
+
track(x2, y2);
|
|
395
|
+
});
|
|
396
|
+
wrap('moveTo', (x, y) => track(x, y));
|
|
397
|
+
wrap('lineTo', (x, y) => track(x, y));
|
|
398
|
+
wrap('fillPoint', (x, y, size) => {
|
|
399
|
+
const s = (typeof size === 'number' ? size : 1) / 2;
|
|
400
|
+
trackRect(x - s, y - s, s * 2, s * 2);
|
|
401
|
+
});
|
|
402
|
+
wrap('arc', (x, y, r) => trackRadius(x, y, r));
|
|
403
|
+
// `clear()` resets the tracker so each redraw cycle starts fresh.
|
|
404
|
+
const origClear = gAny['clear'].bind(g);
|
|
405
|
+
gAny['clear'] = () => {
|
|
406
|
+
minX = Infinity;
|
|
407
|
+
minY = Infinity;
|
|
408
|
+
maxX = -Infinity;
|
|
409
|
+
maxY = -Infinity;
|
|
410
|
+
return origClear();
|
|
411
|
+
};
|
|
412
|
+
return () => {
|
|
413
|
+
if (minX === Infinity)
|
|
414
|
+
return null;
|
|
415
|
+
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Phaser Graphics doesn't track its drawn area — `getBounds()` returns
|
|
420
|
+
* a 0×0 rect because the Graphics class doesn't include the Size/Origin
|
|
421
|
+
* mixins by default. We:
|
|
422
|
+
*
|
|
423
|
+
* 1. Call `setSize(w, h)` so the GameObject has proper dimensions
|
|
424
|
+
* for Phaser arcade physics to size + position bodies correctly.
|
|
425
|
+
* Without this, `physics.add.existing(go)` creates a 0×0 body
|
|
426
|
+
* anchored to the GO's transform position, and any subsequent
|
|
427
|
+
* `body.setOffset(x, y)` is measured from sprite.x/sprite.y
|
|
428
|
+
* rather than from a top-left corner — that mismatch between the
|
|
429
|
+
* agent's mental model (Phaser docs: offset is from sprite
|
|
430
|
+
* top-left) and Phaser's actual math (offset is from
|
|
431
|
+
* `sprite.x - sprite.displayOriginX`) produces a "player floats
|
|
432
|
+
* above the platform" bug because the body sits to the bottom-
|
|
433
|
+
* right of the drawn visual.
|
|
434
|
+
*
|
|
435
|
+
* 2. Call `setOrigin(0.5, 0.5)` so the GO's transform position IS
|
|
436
|
+
* its visual center — matching the render-script convention of
|
|
437
|
+
* drawing around (0, 0). Combined with the explicit setSize above,
|
|
438
|
+
* this makes `body.setOffset` behave per standard Phaser arcade
|
|
439
|
+
* semantics (offset from GO top-left).
|
|
440
|
+
*
|
|
441
|
+
* 3. Stash the same dimensions on the data manager too, for the
|
|
442
|
+
* editor's hit-test path (some legacy editor code reads from
|
|
443
|
+
* `editorHitWidth`/`editorHitHeight` before falling back to
|
|
444
|
+
* `getBounds()`).
|
|
445
|
+
*
|
|
446
|
+
* Surfaced 2026-05-14 on game 7f624e76 (platformer test): player
|
|
447
|
+
* floated ~20 px above platforms because the agent's reasonable
|
|
448
|
+
* `body.setSize(20, 40); body.setOffset(4, 4)` (which assumes
|
|
449
|
+
* Phaser docs' "offset from top-left" semantics) didn't behave
|
|
450
|
+
* that way without explicit setSize / setOrigin on the Graphics.
|
|
451
|
+
* Per 0.2.23 changelog this was supposed to be done; the actual
|
|
452
|
+
* setSize / setOrigin calls were dropped in a later refactor when
|
|
453
|
+
* this helper was extracted.
|
|
454
|
+
*/
|
|
455
|
+
function sizeForHitTest(g, width, height) {
|
|
456
|
+
// Reverted in 0.2.53 (2026-05-14): 0.2.52 added g.setSize + g.setOrigin
|
|
457
|
+
// here to make Phaser arcade body offsets behave per standard Phaser
|
|
458
|
+
// docs ("offset from top-left"). But Phaser's `setOrigin` ALSO shifts
|
|
459
|
+
// Graphics rendering — Phaser computes `displayOriginX = originX *
|
|
460
|
+
// width` and renders the Graphics commands offset by -displayOrigin.
|
|
461
|
+
// Render scripts draw around (0, 0) assuming that's the visual center;
|
|
462
|
+
// adding setOrigin(0.5, 0.5) made those drawings render at
|
|
463
|
+
// (transform.x - w/2, transform.y - h/2) instead — i.e., a 14×24 shift
|
|
464
|
+
// up-left for the canonical 28×48 humanoid. Every code-rendered entity
|
|
465
|
+
// in every existing game appeared in the wrong world position.
|
|
466
|
+
//
|
|
467
|
+
// The right long-term fix is documentation: code-rendered entities have
|
|
468
|
+
// Graphics with width=0/height=0/origin=(0,0), so Phaser arcade body
|
|
469
|
+
// offsets are interpreted from `sprite.x` / `sprite.y` directly (not
|
|
470
|
+
// from a top-left). Agents writing physics for code-rendered entities
|
|
471
|
+
// should compute body.setOffset accounting for this — see the prefab
|
|
472
|
+
// skill's body-offset cookbook.
|
|
473
|
+
//
|
|
474
|
+
// For now, restore the data-manager mirror that was the only thing
|
|
475
|
+
// this helper ever actually did before 0.2.52.
|
|
476
|
+
g.setData('editorHitWidth', width);
|
|
477
|
+
g.setData('editorHitHeight', height);
|
|
478
|
+
}
|
|
479
|
+
function applyTransform(go, t) {
|
|
480
|
+
// Most game objects implement Transform; Container does too. Cast through
|
|
481
|
+
// a permissive shape so this works for sprite/rect/circle/container alike.
|
|
482
|
+
const target = go;
|
|
483
|
+
target.x = t.x;
|
|
484
|
+
target.y = t.y;
|
|
485
|
+
if (typeof t.rotation === 'number')
|
|
486
|
+
target.rotation = t.rotation;
|
|
487
|
+
if (typeof t.scaleX === 'number')
|
|
488
|
+
target.scaleX = t.scaleX;
|
|
489
|
+
if (typeof t.scaleY === 'number')
|
|
490
|
+
target.scaleY = t.scaleY;
|
|
491
|
+
if (typeof t.depth === 'number' && target.setDepth)
|
|
492
|
+
target.setDepth(t.depth);
|
|
493
|
+
}
|
|
494
|
+
function tagGameObject(go, entity) {
|
|
495
|
+
go.setData('entityId', entity.id);
|
|
496
|
+
// Slice 6 Phase B: paint-mode detection reads entityKind off the GO without
|
|
497
|
+
// a registry round-trip. Tag every entity so the check is uniform — only
|
|
498
|
+
// tilemap consumers branch on it today, but other kinds (trigger, group)
|
|
499
|
+
// may need similar fast-path checks in future slices.
|
|
500
|
+
go.setData('entityKind', entity.kind);
|
|
501
|
+
if (entity.role)
|
|
502
|
+
go.setData('entityRole', entity.role);
|
|
503
|
+
if (entity.properties)
|
|
504
|
+
go.setData('entityProperties', entity.properties);
|
|
505
|
+
// Slice 8: ySort loop reads transform.depth to skip explicit-depth entities.
|
|
506
|
+
// Stash the transform on the GO's data manager to avoid a round-trip back
|
|
507
|
+
// to the scene file every frame.
|
|
508
|
+
go.setData('entityTransform', entity.transform);
|
|
509
|
+
// Slice 8: assetUpdate handler walks the registry to find all instances of
|
|
510
|
+
// an asset whose hitbox/anchor changed. Stash the assetId for sprite
|
|
511
|
+
// entities so the lookup is O(n) on the registry rather than a per-entity
|
|
512
|
+
// re-parse of the scene file.
|
|
513
|
+
if (entity.kind === 'sprite') {
|
|
514
|
+
go.setData('entityAssetId', entity.assetId);
|
|
515
|
+
}
|
|
516
|
+
// Slice 6 Phase B: tilemap-specific stash so the editor's tilemap
|
|
517
|
+
// handler can read cellSize / size / per-layer dims without re-parsing
|
|
518
|
+
// the scene file every paint op.
|
|
519
|
+
if (entity.kind === 'tilemap') {
|
|
520
|
+
go.setData('tilemapTileSize', entity.tileSize);
|
|
521
|
+
go.setData('tilemapSize', entity.size);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* Trigger stub renderer — slice 3. Renders the trigger zone as a
|
|
526
|
+
* semi-transparent fill with cyan tint per design 03 §8.2 (in edit mode it
|
|
527
|
+
* is always visible; at play time the SDK will hide it once slice 5 wires
|
|
528
|
+
* the behavior layer).
|
|
529
|
+
*/
|
|
530
|
+
function createTriggerStub(ctx, entity) {
|
|
531
|
+
const TINT = 0x00bfff; // cyan-ish "neutral trigger" per design
|
|
532
|
+
const FILL_ALPHA = 0.25;
|
|
533
|
+
const STROKE_ALPHA = 0.7;
|
|
534
|
+
if (entity.shape.kind === 'rect') {
|
|
535
|
+
const r = ctx.scene.add.rectangle(0, 0, entity.shape.width, entity.shape.height, TINT, FILL_ALPHA);
|
|
536
|
+
r.setStrokeStyle(1.5, TINT, STROKE_ALPHA);
|
|
537
|
+
return r;
|
|
538
|
+
}
|
|
539
|
+
// circle
|
|
540
|
+
const c = ctx.scene.add.circle(0, 0, entity.shape.radius, TINT, FILL_ALPHA);
|
|
541
|
+
c.setStrokeStyle(1.5, TINT, STROKE_ALPHA);
|
|
542
|
+
return c;
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* Tilemap renderer — slice 6 Phase A (2026-05-18).
|
|
546
|
+
*
|
|
547
|
+
* Returns a `Container` parent holding (a) a grid-sketch Graphics drawn at
|
|
548
|
+
* low alpha as background so the tilemap's bounds remain visible even
|
|
549
|
+
* where layer data is empty, and (b) one Phaser `TilemapLayer` per
|
|
550
|
+
* authored layer (only when that layer has `data`). The container is
|
|
551
|
+
* positioned at the entity's `transform.x/y`; children are offset by
|
|
552
|
+
* `(-w/2, -h/2)` so the tilemap is centered on the entity origin —
|
|
553
|
+
* matches sprite / primitive / code-rendered convention.
|
|
554
|
+
*
|
|
555
|
+
* Soft-fail strategy mirrors the rest of the loader: a layer whose
|
|
556
|
+
* tileset isn't in the manifest or isn't loaded yet is skipped with a
|
|
557
|
+
* warn; the background grid still renders so the entity stays selectable.
|
|
558
|
+
* preloadSceneAssets already warned about missing assets — this is
|
|
559
|
+
* defense-in-depth.
|
|
560
|
+
*
|
|
561
|
+
* Phase B will add live-edit handlers (paint cell, add/remove layer,
|
|
562
|
+
* toggle visibility). Phase D will add Wang autotile resolution at
|
|
563
|
+
* paint time. Phase C wires per-tile collision via `setCollisionByProperty`
|
|
564
|
+
* once tileset metadata carries `tiles[<idx>].metadata.solid`.
|
|
565
|
+
*/
|
|
566
|
+
function createTilemap(ctx, entity) {
|
|
567
|
+
const tileW = entity.tileSize.width;
|
|
568
|
+
const tileH = entity.tileSize.height;
|
|
569
|
+
const cellsW = entity.size.width;
|
|
570
|
+
const cellsH = entity.size.height;
|
|
571
|
+
const pxW = cellsW * tileW;
|
|
572
|
+
const pxH = cellsH * tileH;
|
|
573
|
+
const left = -pxW / 2;
|
|
574
|
+
const top = -pxH / 2;
|
|
575
|
+
const container = ctx.scene.add.container(0, 0);
|
|
576
|
+
// Background grid sketch — drawn first so layer tiles render on top.
|
|
577
|
+
// Kept even when layers have data so empty cells show the grid; this
|
|
578
|
+
// also makes empty tilemaps (no painted data yet) visible + selectable.
|
|
579
|
+
// **Editor-only**: hidden by default; `enterEdit` walks tilemap entities
|
|
580
|
+
// and toggles visibility on. `exitEdit` toggles back off. Without this
|
|
581
|
+
// the grid bleeds through into Play mode where it has no purpose and
|
|
582
|
+
// visually conflicts with the actual painted tiles.
|
|
583
|
+
const grid = ctx.scene.add.graphics();
|
|
584
|
+
grid.fillStyle(0xffffff, 0.04);
|
|
585
|
+
grid.fillRect(left, top, pxW, pxH);
|
|
586
|
+
grid.lineStyle(2, 0xaaaaaa, 0.45);
|
|
587
|
+
grid.strokeRect(left, top, pxW, pxH);
|
|
588
|
+
if (tileW >= 8 && cellsW * cellsH < 4000) {
|
|
589
|
+
grid.lineStyle(1, 0xaaaaaa, 0.1);
|
|
590
|
+
for (let i = 1; i < cellsW; i++) {
|
|
591
|
+
const x = left + i * tileW;
|
|
592
|
+
grid.beginPath();
|
|
593
|
+
grid.moveTo(x, top);
|
|
594
|
+
grid.lineTo(x, top + pxH);
|
|
595
|
+
grid.strokePath();
|
|
596
|
+
}
|
|
597
|
+
for (let i = 1; i < cellsH; i++) {
|
|
598
|
+
const y = top + i * tileH;
|
|
599
|
+
grid.beginPath();
|
|
600
|
+
grid.moveTo(left, y);
|
|
601
|
+
grid.lineTo(left + pxW, y);
|
|
602
|
+
grid.strokePath();
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
grid.setData('unboxyTilemapGrid', true);
|
|
606
|
+
grid.setVisible(false);
|
|
607
|
+
container.add(grid);
|
|
608
|
+
// Create one Phaser TilemapLayer per authored layer.
|
|
609
|
+
//
|
|
610
|
+
// **Phaser quirk**: TilemapLayer does NOT inherit parent Container
|
|
611
|
+
// transforms at render time (the renderer treats `layer.x`/`layer.y` as
|
|
612
|
+
// world coords directly). If we made layers Container children, the cell
|
|
613
|
+
// grid would render at the wrong world position even though click→cell
|
|
614
|
+
// math worked. Symptom: paint clicks land in the editor void (near world
|
|
615
|
+
// origin) instead of inside the tilemap's bounds.
|
|
616
|
+
//
|
|
617
|
+
// Workaround: layers are scene-root siblings (NOT container children),
|
|
618
|
+
// positioned at WORLD coords matching the container's transform. The
|
|
619
|
+
// container holds only the grid sketch for visual bounds + hit-test.
|
|
620
|
+
// Layer refs are stashed on the container's data manager so the painter
|
|
621
|
+
// can find them by id. A per-frame sync hook (`installTilemapLayerSync`,
|
|
622
|
+
// installed by `loadWorldScene` when any tilemap entity exists) keeps
|
|
623
|
+
// layer positions in lockstep with the container's transform — so
|
|
624
|
+
// dragging the entity still visually moves the painted tiles.
|
|
625
|
+
const sortedLayers = [...entity.layers].sort((a, b) => (a.z ?? 0) - (b.z ?? 0));
|
|
626
|
+
const layerRefs = [];
|
|
627
|
+
for (const layer of sortedLayers) {
|
|
628
|
+
const layerObj = renderLayerInto(ctx, entity, layer, cellsW, cellsH, tileW, tileH);
|
|
629
|
+
if (!layerObj)
|
|
630
|
+
continue;
|
|
631
|
+
// Position in WORLD coords directly. The container's transform.x/y has
|
|
632
|
+
// already been applied to the container above; we mirror it to the
|
|
633
|
+
// layer so the layer renders at the same world position.
|
|
634
|
+
layerObj.x = entity.transform.x + left;
|
|
635
|
+
layerObj.y = entity.transform.y + top;
|
|
636
|
+
if (layer.visible === false)
|
|
637
|
+
layerObj.setVisible(false);
|
|
638
|
+
// Tag with layer id so the painter can locate by id (survives reorders).
|
|
639
|
+
layerObj.setData('tilemapLayerId', layer.id);
|
|
640
|
+
// Stamp the parent tilemap entity id so the sync hook can find which
|
|
641
|
+
// container each layer belongs to without a registry walk per frame.
|
|
642
|
+
layerObj.setData('tilemapEntityId', entity.id);
|
|
643
|
+
// Stash the layer's local-offset (left/top) so the sync hook can
|
|
644
|
+
// recompute world position from container.x/y + offset whenever the
|
|
645
|
+
// container moves. These are stable for a layer's lifetime.
|
|
646
|
+
layerObj.setData('tilemapLocalOffsetX', left);
|
|
647
|
+
layerObj.setData('tilemapLocalOffsetY', top);
|
|
648
|
+
// Slice 6 Phase C — per-tile metadata + auto-collision. Called here
|
|
649
|
+
// AFTER layer.x/y are set so `tile.getLeft()` returns correct world
|
|
650
|
+
// coords for sub-tile static body placement; calling this inside
|
|
651
|
+
// `renderLayerInto` would create bodies at world origin (visible as
|
|
652
|
+
// collision bodies stacked top-left of the canvas in earlier bug).
|
|
653
|
+
const tilesetForLayer = layerObj.tileset[0];
|
|
654
|
+
const tilesetIdForLayer = layer.tilesetIds?.[0];
|
|
655
|
+
if (tilesetForLayer && tilesetIdForLayer) {
|
|
656
|
+
try {
|
|
657
|
+
const layerAsset = ctx.resolveAsset(tilesetIdForLayer);
|
|
658
|
+
applyTilesetTileMetadata(tilesetForLayer, layerObj, layerAsset);
|
|
659
|
+
// Phase F — arm tile animations (water flow / lava bubble /
|
|
660
|
+
// torch flicker). Must run AFTER applyTilesetTileMetadata so
|
|
661
|
+
// the layer's tile.index field is fully populated when we scan
|
|
662
|
+
// for cells matching animation rootTileIndex.
|
|
663
|
+
applyTilesetAnimations(layerObj, layerAsset);
|
|
664
|
+
}
|
|
665
|
+
catch {
|
|
666
|
+
/* asset missing already warned by renderLayerInto */
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
layerRefs.push(layerObj);
|
|
670
|
+
}
|
|
671
|
+
// Stash layer refs on the container so the painter + structure-op handlers
|
|
672
|
+
// (addLayer / removeLayer / setLayerVisibility) can find them without a
|
|
673
|
+
// scene-wide walk. The container's `list` no longer holds them.
|
|
674
|
+
container.setData('tilemapLayers', layerRefs);
|
|
675
|
+
// Hit-test stash for the editor (Container reports 0×0 bounds otherwise).
|
|
676
|
+
sizeForHitTest(container, pxW, pxH);
|
|
677
|
+
return container;
|
|
678
|
+
}
|
|
679
|
+
/**
|
|
680
|
+
* Build one `TilemapLayer` for a single tilemap-entity layer. Returns
|
|
681
|
+
* `null` when the layer is unrenderable (no data, missing tileset, etc.)
|
|
682
|
+
* — caller skips and the background grid still indicates the bounds.
|
|
683
|
+
*/
|
|
684
|
+
function renderLayerInto(ctx, entity, layer, cellsW, cellsH, tileW, tileH) {
|
|
685
|
+
// Pick first tileset for Phase A (multi-tileset blending deferred).
|
|
686
|
+
const tilesetId = layer.tilesetIds?.[0];
|
|
687
|
+
if (!tilesetId) {
|
|
688
|
+
// Without a tileset there's nothing to paint with — skip even an empty
|
|
689
|
+
// layer. The grid sketch from the parent container still indicates
|
|
690
|
+
// bounds. Phase B's painter creates layers with a tileset attached;
|
|
691
|
+
// this branch only fires for hand-authored degenerate cases.
|
|
692
|
+
return null;
|
|
693
|
+
}
|
|
694
|
+
let asset;
|
|
695
|
+
try {
|
|
696
|
+
asset = ctx.resolveAsset(tilesetId);
|
|
697
|
+
}
|
|
698
|
+
catch {
|
|
699
|
+
// preloadSceneAssets already warned about this missing asset.
|
|
700
|
+
return null;
|
|
701
|
+
}
|
|
702
|
+
if (!ctx.scene.textures.exists(asset.textureKey)) {
|
|
703
|
+
// eslint-disable-next-line no-console
|
|
704
|
+
console.warn(`[umicat/scene] tilemap layer '${layer.id}' tileset '${tilesetId}' texture not loaded; skipping layer`);
|
|
705
|
+
return null;
|
|
706
|
+
}
|
|
707
|
+
// Cell size: prefer the asset's tileset metadata, then fall back to
|
|
708
|
+
// spritesheet's frame dims (if the user is using a spritesheet asset
|
|
709
|
+
// as a tileset), then fall back to the tilemap entity's tileSize.
|
|
710
|
+
// The Configure Tileset modal (Phase A.2) is what gets users to populate
|
|
711
|
+
// `asset.tileset.cellSize` deliberately — but for the SDK alone, these
|
|
712
|
+
// fallbacks let a hand-authored tilemap render against any image asset.
|
|
713
|
+
const cellSize = resolveTilesetCellSize(asset, tileW, tileH);
|
|
714
|
+
// Normalise data: Phaser's tilemap factory wants a rectangular 2D array
|
|
715
|
+
// of integers. Coerce missing rows / cells / non-numeric entries to -1
|
|
716
|
+
// (Phaser's "no tile" sentinel). Tile indices are 0-based in the source
|
|
717
|
+
// image (row-major), matching Phaser's native convention. An empty layer
|
|
718
|
+
// (no `data` field) materializes as an all-(-1) grid so the painter has
|
|
719
|
+
// something to mutate — Phase B's `putTileAt` calls then flip cells from
|
|
720
|
+
// -1 to real indices in place.
|
|
721
|
+
const grid = [];
|
|
722
|
+
const sourceData = layer.data ?? [];
|
|
723
|
+
for (let y = 0; y < cellsH; y++) {
|
|
724
|
+
const row = sourceData[y] ?? [];
|
|
725
|
+
const out = new Array(cellsW);
|
|
726
|
+
for (let x = 0; x < cellsW; x++) {
|
|
727
|
+
const v = row[x];
|
|
728
|
+
out[x] = typeof v === 'number' ? v : -1;
|
|
729
|
+
}
|
|
730
|
+
grid.push(out);
|
|
731
|
+
}
|
|
732
|
+
let map;
|
|
733
|
+
try {
|
|
734
|
+
map = ctx.scene.make.tilemap({
|
|
735
|
+
data: grid,
|
|
736
|
+
tileWidth: cellSize.width,
|
|
737
|
+
tileHeight: cellSize.height,
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
catch (e) {
|
|
741
|
+
// eslint-disable-next-line no-console
|
|
742
|
+
console.warn(`[umicat/scene] failed to construct tilemap for entity '${entity.id}' layer '${layer.id}': ${String(e)}`);
|
|
743
|
+
return null;
|
|
744
|
+
}
|
|
745
|
+
const tileset = map.addTilesetImage(tilesetId, asset.textureKey, cellSize.width, cellSize.height, asset.tileset?.margin ?? 0, asset.tileset?.spacing ?? 0);
|
|
746
|
+
if (!tileset) {
|
|
747
|
+
// eslint-disable-next-line no-console
|
|
748
|
+
console.warn(`[umicat/scene] addTilesetImage returned null for '${tilesetId}' on entity '${entity.id}' layer '${layer.id}'; skipping`);
|
|
749
|
+
return null;
|
|
750
|
+
}
|
|
751
|
+
const layerObj = map.createLayer(0, tileset, 0, 0);
|
|
752
|
+
if (!layerObj) {
|
|
753
|
+
// eslint-disable-next-line no-console
|
|
754
|
+
console.warn(`[umicat/scene] createLayer returned null for entity '${entity.id}' layer '${layer.id}'; skipping`);
|
|
755
|
+
return null;
|
|
756
|
+
}
|
|
757
|
+
// Slice 6 Phase D fix — prevent tile edge bleeding. Two-pronged:
|
|
758
|
+
// (1) NEAREST filter on tileset texture — kills bilinear interpolation
|
|
759
|
+
// that would sample neighbor-tile pixels at tile boundaries.
|
|
760
|
+
// (2) `roundPixels = true` on the world camera — snaps the camera to
|
|
761
|
+
// integer pixel positions so the GPU's NEAREST sampler can't pick
|
|
762
|
+
// "between" pixels at tile edges due to subpixel camera offsets.
|
|
763
|
+
// Together they kill the thin dark hairlines visible at tile edges on
|
|
764
|
+
// typical (non-extruded) tilesets like Sprout Lands. Tilemaps are
|
|
765
|
+
// virtually always pixel art; applying these unconditionally is safe.
|
|
766
|
+
// Editor camera roundPixels is set separately in EditorBridge.
|
|
767
|
+
const tex = ctx.scene.textures.get(asset.textureKey);
|
|
768
|
+
if (tex)
|
|
769
|
+
tex.setFilter(Phaser.Textures.FilterMode.NEAREST);
|
|
770
|
+
ctx.scene.cameras.main.setRoundPixels(true);
|
|
771
|
+
// If render tileSize differs from source cellSize, scale the layer so
|
|
772
|
+
// each cell renders at the entity's declared tileSize. Skipping this
|
|
773
|
+
// would render at source pixels and visually mismatch the grid sketch.
|
|
774
|
+
if (cellSize.width !== tileW || cellSize.height !== tileH) {
|
|
775
|
+
layerObj.setScale(tileW / cellSize.width, tileH / cellSize.height);
|
|
776
|
+
}
|
|
777
|
+
// Slice 6 Phase C — stash the manifest asset id so live `assetUpdate`
|
|
778
|
+
// can find every layer affected by a Tile Metadata Editor save. The
|
|
779
|
+
// Phaser Tileset object's `name` IS the manifest id (set via
|
|
780
|
+
// addTilesetImage's first arg), but stashing on the layer is cheaper
|
|
781
|
+
// and avoids depending on Phaser internals.
|
|
782
|
+
layerObj.setData('tilemapTilesetId', tilesetId);
|
|
783
|
+
// Note: `applyTilesetTileMetadata` is intentionally NOT called here —
|
|
784
|
+
// the sub-tile body half (`syncSubTileBodies`) needs world coords,
|
|
785
|
+
// which `tile.getLeft()` only reports correctly AFTER the caller sets
|
|
786
|
+
// `layerObj.x/y`. Caller (createTilemap) invokes it post-positioning.
|
|
787
|
+
return layerObj;
|
|
788
|
+
}
|
|
789
|
+
/**
|
|
790
|
+
* Slice 6 Phase C — wire per-tile metadata onto a Phaser tileset + layer.
|
|
791
|
+
*
|
|
792
|
+
* Sources `asset.tileset.tiles` (sparse map keyed by tile index) and:
|
|
793
|
+
* 1. Replaces `tileset.tileProperties` so any FUTURE `putTileAt` call
|
|
794
|
+
* auto-stamps the new tile's `properties` from the lookup. Phaser
|
|
795
|
+
* reads `tileset.tileProperties[index]` at tile-creation time.
|
|
796
|
+
* 2. Walks existing tiles via `layer.forEachTile` and refreshes each
|
|
797
|
+
* tile's `properties` — needed for tiles that were already painted
|
|
798
|
+
* before this fn ran (initial scene load, or `assetUpdate` replay).
|
|
799
|
+
* 3. Calls `layer.setCollisionByProperty({ solid: true })` so any tile
|
|
800
|
+
* whose `properties.solid === true` participates in Arcade collision.
|
|
801
|
+
* Idempotent + safe to call when no tile is solid (no-op).
|
|
802
|
+
*
|
|
803
|
+
* Exported because `EditorBridge.applyTilemapStructureOp` (addLayer) and
|
|
804
|
+
* `EditorBridge.handleAssetUpdate` both need to re-arm collision wiring
|
|
805
|
+
* without going through the full scene load. SDK 0.2.115+.
|
|
806
|
+
*/
|
|
807
|
+
export function applyTilesetTileMetadata(tileset, layer, asset) {
|
|
808
|
+
applyTilesetTileMetadataInternal(layer.scene, tileset, layer, asset);
|
|
809
|
+
}
|
|
810
|
+
function applyTilesetTileMetadataInternal(scene, tileset, layer, asset) {
|
|
811
|
+
const tiles = asset.tileset?.tiles;
|
|
812
|
+
// Always REPLACE (not merge) so the live `assetUpdate` path correctly
|
|
813
|
+
// clears properties on tiles that no longer have metadata. Empty {} is
|
|
814
|
+
// the "no per-tile properties" state Phaser expects.
|
|
815
|
+
const tileProperties = {};
|
|
816
|
+
if (tiles) {
|
|
817
|
+
for (const key of Object.keys(tiles)) {
|
|
818
|
+
const idx = Number(key);
|
|
819
|
+
if (!Number.isFinite(idx))
|
|
820
|
+
continue;
|
|
821
|
+
const meta = tiles[idx];
|
|
822
|
+
if (!meta)
|
|
823
|
+
continue;
|
|
824
|
+
const props = {};
|
|
825
|
+
if (meta.solid != null)
|
|
826
|
+
props.solid = meta.solid;
|
|
827
|
+
if (meta.oneWay != null)
|
|
828
|
+
props.oneWay = meta.oneWay;
|
|
829
|
+
if (meta.slope != null)
|
|
830
|
+
props.slope = meta.slope;
|
|
831
|
+
if (meta.damage != null)
|
|
832
|
+
props.damage = meta.damage;
|
|
833
|
+
if (meta.terrainTag != null)
|
|
834
|
+
props.terrainTag = meta.terrainTag;
|
|
835
|
+
if (meta.movement != null)
|
|
836
|
+
props.movement = meta.movement;
|
|
837
|
+
if (meta.groundType != null)
|
|
838
|
+
props.groundType = meta.groundType;
|
|
839
|
+
if (meta.customTag != null)
|
|
840
|
+
props.customTag = meta.customTag;
|
|
841
|
+
if (Object.keys(props).length > 0) {
|
|
842
|
+
tileProperties[idx] = props;
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
// Mutate the tileset's lookup so newly-painted tiles inherit properties.
|
|
847
|
+
// Phaser's TS types declare `tileProperties` as readonly object[]; the
|
|
848
|
+
// runtime accepts a plain numeric-keyed Record. Cast through unknown to
|
|
849
|
+
// bypass the structural mismatch without disabling the wider TS surface.
|
|
850
|
+
tileset.tileProperties = tileProperties;
|
|
851
|
+
// Refresh ALREADY-painted tiles. Phaser copies properties from the
|
|
852
|
+
// tileset at tile-creation time; tiles painted BEFORE this call won't
|
|
853
|
+
// see the new properties unless we stamp them directly.
|
|
854
|
+
layer.forEachTile((tile) => {
|
|
855
|
+
// -1 / null tiles are skipped by Phaser internally; only real tiles
|
|
856
|
+
// reach the callback. Use the index as the lookup key.
|
|
857
|
+
const props = tileProperties[tile.index];
|
|
858
|
+
// Always replace tile.properties so removed-metadata also clears.
|
|
859
|
+
// Phaser's collide-by-property re-reads this on each call.
|
|
860
|
+
tile.properties = props ? { ...props } : {};
|
|
861
|
+
});
|
|
862
|
+
// Arm collision. `setCollisionByProperty` walks all tiles, checks each
|
|
863
|
+
// tile's `properties.solid === true`, and flips `tile.collides`. Safe to
|
|
864
|
+
// call when zero tiles are solid (just sets nothing). Phaser's collide-
|
|
865
|
+
// by-property is the standard pattern; behavior code only needs
|
|
866
|
+
// `scene.physics.add.collider(player, layer)` to get the collision.
|
|
867
|
+
layer.setCollisionByProperty({ solid: true });
|
|
868
|
+
// Slice 6 Phase C follow-up — sub-tile collision rects. Godot/Tiled-style
|
|
869
|
+
// per-tile shape, painted ONCE on the tile in the editor + materialized
|
|
870
|
+
// as invisible Arcade static bodies per painted instance at runtime.
|
|
871
|
+
// Phaser's TilemapLayer has no native sub-tile collision; static bodies
|
|
872
|
+
// are the standard escape hatch the Phaser community uses for this case.
|
|
873
|
+
syncSubTileBodies(scene, layer, asset);
|
|
874
|
+
}
|
|
875
|
+
// --- Animated tiles (slice 6 Phase F) -------------------------------------
|
|
876
|
+
const ANIM_CLEANUP_KEY = 'unboxyTilesetAnimCleanup';
|
|
877
|
+
const ANIM_RESET_KEY = 'unboxyTilesetAnimReset';
|
|
878
|
+
/**
|
|
879
|
+
* Arm tile animations on this layer — Phase F (slice 6 — design doc 06 §8).
|
|
880
|
+
*
|
|
881
|
+
* Reads `asset.tileset.animations[]`, finds every painted cell whose source
|
|
882
|
+
* index matches an animation's `rootTileIndex`, installs a per-frame UPDATE
|
|
883
|
+
* listener that swaps each cell's `tile.index` to the current frame.
|
|
884
|
+
* All cells with the same root animate in lockstep (Sprout Lands water
|
|
885
|
+
* lake-cells flow together).
|
|
886
|
+
*
|
|
887
|
+
* Idempotent — re-calling tears down the prior listener and re-scans. Used
|
|
888
|
+
* by:
|
|
889
|
+
* - `createTilemap` at scene boot (initial arm)
|
|
890
|
+
* - `EditorBridge.handleAssetUpdate` (when Animation Editor saves)
|
|
891
|
+
* - `EditorBridge.applyTilemapStructureOp addLayer` (new layer arm)
|
|
892
|
+
* - any subsequent paint op (host's `handleEditTilemap` re-runs metadata)
|
|
893
|
+
*
|
|
894
|
+
* Edit-mode caveat: scenes are `setActive(false)` in edit mode → scene
|
|
895
|
+
* UPDATE doesn't fire → the swap handler stays dormant → animated cells
|
|
896
|
+
* stay on whatever frame they were on when edit was entered. To keep edit
|
|
897
|
+
* mode showing the static "data" view, `EditorBridge.enterEdit` calls
|
|
898
|
+
* `resetTilesetAnimationsToRoot` which walks all animated cells + sets
|
|
899
|
+
* them back to root. exitEdit → next UPDATE tick re-applies current frame.
|
|
900
|
+
*
|
|
901
|
+
* Save-path safety: the iframe's mutated `tile.index` per frame doesn't
|
|
902
|
+
* leak into the host's draft state (home-ui keeps its own copy in
|
|
903
|
+
* `useEditorDraft`'s baseline + commands). Mid-animation Cmd+S saves the
|
|
904
|
+
* root indices, not the displayed frame.
|
|
905
|
+
*
|
|
906
|
+
* Phaser API note: Phaser 3 parses Tiled's `tile.animation` field into
|
|
907
|
+
* `tileset.tileData[id].animation` but its TilemapLayer renderer does NOT
|
|
908
|
+
* consume it. The community `phaser-animated-tiles` plugin bridges that
|
|
909
|
+
* gap but has been bit-rotted since 3.80. This is a 50-line custom impl
|
|
910
|
+
* that does the swap directly — same algorithm, no external dep.
|
|
911
|
+
*/
|
|
912
|
+
export function applyTilesetAnimations(layer, asset) {
|
|
913
|
+
// Clear any prior animation arm on this layer (reset cells + uninstall).
|
|
914
|
+
const prevCleanup = layer.getData(ANIM_CLEANUP_KEY);
|
|
915
|
+
prevCleanup?.();
|
|
916
|
+
layer.setData(ANIM_CLEANUP_KEY, undefined);
|
|
917
|
+
const animations = asset.tileset?.animations ?? [];
|
|
918
|
+
if (animations.length === 0)
|
|
919
|
+
return;
|
|
920
|
+
// Index by rootTileIndex for O(1) match. Also collect every frame's
|
|
921
|
+
// tileIndex so re-application (cells already mid-animation) can map back
|
|
922
|
+
// to their root — needed because the previous cleanup happens BEFORE
|
|
923
|
+
// this scan, so cells are at root when we scan, but if some external
|
|
924
|
+
// path mutated indices we still want to catch them.
|
|
925
|
+
const animByRoot = new Map();
|
|
926
|
+
const frameToRoot = new Map();
|
|
927
|
+
for (const anim of animations) {
|
|
928
|
+
if (!anim.frames || anim.frames.length === 0)
|
|
929
|
+
continue;
|
|
930
|
+
animByRoot.set(anim.rootTileIndex, anim);
|
|
931
|
+
frameToRoot.set(anim.rootTileIndex, anim.rootTileIndex);
|
|
932
|
+
for (const f of anim.frames) {
|
|
933
|
+
if (!frameToRoot.has(f.tileIndex)) {
|
|
934
|
+
frameToRoot.set(f.tileIndex, anim.rootTileIndex);
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
if (animByRoot.size === 0)
|
|
939
|
+
return;
|
|
940
|
+
// Scan the layer once for cells that match a root (or any frame —
|
|
941
|
+
// defensive against cells left mid-frame from a prior arm). Store the
|
|
942
|
+
// Tile refs + which animation drives them — refs persist for the
|
|
943
|
+
// layer's lifetime (Phaser doesn't recreate Tile objects).
|
|
944
|
+
const cells = [];
|
|
945
|
+
layer.forEachTile((tile) => {
|
|
946
|
+
if (tile.index < 0)
|
|
947
|
+
return;
|
|
948
|
+
const rootIdx = frameToRoot.get(tile.index);
|
|
949
|
+
if (rootIdx === undefined)
|
|
950
|
+
return;
|
|
951
|
+
const anim = animByRoot.get(rootIdx);
|
|
952
|
+
if (!anim)
|
|
953
|
+
return;
|
|
954
|
+
cells.push({ tile, anim });
|
|
955
|
+
});
|
|
956
|
+
if (cells.length === 0)
|
|
957
|
+
return;
|
|
958
|
+
const scene = layer.scene;
|
|
959
|
+
const startTime = scene.time.now;
|
|
960
|
+
// Precompute total durations per animation — avoids re-summing each tick.
|
|
961
|
+
const totalDurByAnim = new Map();
|
|
962
|
+
for (const a of animByRoot.values()) {
|
|
963
|
+
totalDurByAnim.set(a, a.frames.reduce((sum, f) => sum + Math.max(16, f.duration), 0));
|
|
964
|
+
}
|
|
965
|
+
const handler = () => {
|
|
966
|
+
const now = scene.time.now;
|
|
967
|
+
for (const { tile, anim } of cells) {
|
|
968
|
+
const total = totalDurByAnim.get(anim);
|
|
969
|
+
if (!total)
|
|
970
|
+
continue;
|
|
971
|
+
const phase = (now - startTime) % total;
|
|
972
|
+
let acc = 0;
|
|
973
|
+
let frameTileIndex = anim.frames[0].tileIndex;
|
|
974
|
+
for (const f of anim.frames) {
|
|
975
|
+
acc += Math.max(16, f.duration);
|
|
976
|
+
if (phase < acc) {
|
|
977
|
+
frameTileIndex = f.tileIndex;
|
|
978
|
+
break;
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
if (tile.index !== frameTileIndex) {
|
|
982
|
+
tile.index = frameTileIndex;
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
};
|
|
986
|
+
scene.events.on(Phaser.Scenes.Events.UPDATE, handler);
|
|
987
|
+
// Two helpers stashed on layer.data:
|
|
988
|
+
// - reset: walks cells + sets index back to root WITHOUT touching the
|
|
989
|
+
// handler. Used by enterEdit to freeze the editor view on the
|
|
990
|
+
// authored frame; the scene-pause means the handler doesn't fire,
|
|
991
|
+
// and on exitEdit the next UPDATE tick will re-swap from elapsed
|
|
992
|
+
// time → correct frame seamlessly.
|
|
993
|
+
// - cleanup: full teardown — runs reset() AND uninstalls the UPDATE
|
|
994
|
+
// handler. Used by re-application (assetUpdate / paint-op metadata
|
|
995
|
+
// re-arm) and by scene SHUTDOWN.
|
|
996
|
+
const reset = () => {
|
|
997
|
+
for (const { tile, anim } of cells) {
|
|
998
|
+
if (tile.index !== anim.rootTileIndex) {
|
|
999
|
+
tile.index = anim.rootTileIndex;
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
};
|
|
1003
|
+
const cleanup = () => {
|
|
1004
|
+
scene.events.off(Phaser.Scenes.Events.UPDATE, handler);
|
|
1005
|
+
reset();
|
|
1006
|
+
};
|
|
1007
|
+
layer.setData(ANIM_CLEANUP_KEY, cleanup);
|
|
1008
|
+
layer.setData(ANIM_RESET_KEY, reset);
|
|
1009
|
+
// Tear down on scene shutdown so cross-scene navigation doesn't leak the
|
|
1010
|
+
// handler. Scene SHUTDOWN fires before scene START on the next visit.
|
|
1011
|
+
scene.events.once(Phaser.Scenes.Events.SHUTDOWN, () => {
|
|
1012
|
+
const c = layer.getData(ANIM_CLEANUP_KEY);
|
|
1013
|
+
c?.();
|
|
1014
|
+
layer.setData(ANIM_CLEANUP_KEY, undefined);
|
|
1015
|
+
layer.setData(ANIM_RESET_KEY, undefined);
|
|
1016
|
+
});
|
|
1017
|
+
}
|
|
1018
|
+
/**
|
|
1019
|
+
* Reset every animated cell on a tilemap container to its root tile index.
|
|
1020
|
+
* Called by `EditorBridge.enterEdit` so animated cells show the static
|
|
1021
|
+
* authored frame in the editor, not whatever was currently visible at the
|
|
1022
|
+
* moment the user toggled into edit mode. exitEdit doesn't need a paired
|
|
1023
|
+
* call — the next UPDATE tick after scenes resume swaps each cell back to
|
|
1024
|
+
* its current animation frame.
|
|
1025
|
+
*/
|
|
1026
|
+
export function resetTilesetAnimationsToRoot(container) {
|
|
1027
|
+
const layers = container.getData('tilemapLayers') ?? [];
|
|
1028
|
+
for (const layer of layers) {
|
|
1029
|
+
const reset = layer.getData(ANIM_RESET_KEY);
|
|
1030
|
+
// reset() ONLY touches tile indices — handler stays installed so
|
|
1031
|
+
// exitEdit picks up where animation left off on next UPDATE tick.
|
|
1032
|
+
reset?.();
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
const SUB_TILE_GROUP_KEY = 'unboxySubTileStaticGroup';
|
|
1036
|
+
/**
|
|
1037
|
+
* Rebuild the per-layer `StaticGroup` of invisible sub-tile collision
|
|
1038
|
+
* bodies (Phase C follow-up — sub-tile collision rects).
|
|
1039
|
+
*
|
|
1040
|
+
* Walks the layer's painted tiles, finds tiles whose tileset metadata
|
|
1041
|
+
* carries a `collisionRect`, disables Phaser's native cell-rect collision
|
|
1042
|
+
* for those tiles (`tile.setCollision(false, ...)`), and creates an
|
|
1043
|
+
* invisible Rectangle GameObject with an Arcade static body sized to the
|
|
1044
|
+
* sub-rect. Bodies live in a `Phaser.Physics.Arcade.StaticGroup` stashed
|
|
1045
|
+
* on the layer's data manager so subsequent calls (e.g. painter ops, live
|
|
1046
|
+
* `assetUpdate` replay) can wipe + rebuild cleanly.
|
|
1047
|
+
*
|
|
1048
|
+
* Cheap when no tile in the tileset has a `collisionRect` — single map
|
|
1049
|
+
* scan + early return without touching layer tiles.
|
|
1050
|
+
*
|
|
1051
|
+
* Behavior code: use `addTilemapCollider(scene, entityId, target)` so
|
|
1052
|
+
* both the layer's cell-collision AND the sub-tile group are wired up at
|
|
1053
|
+
* once. Doing only `physics.add.collider(player, layer)` covers the
|
|
1054
|
+
* cell-rect tiles but NOT the sub-rect bodies — a footgun without the
|
|
1055
|
+
* helper.
|
|
1056
|
+
*/
|
|
1057
|
+
function syncSubTileBodies(scene, layer, asset) {
|
|
1058
|
+
// Fast-path: no Arcade physics in this scene (e.g. a HUD-only scene)
|
|
1059
|
+
// means no static bodies to create. Bail without touching anything.
|
|
1060
|
+
if (!scene.physics?.add)
|
|
1061
|
+
return;
|
|
1062
|
+
// Get or create the group. Stashed on the layer's data manager so this
|
|
1063
|
+
// function is the SOLE owner — no other code touches it.
|
|
1064
|
+
let group = layer.getData(SUB_TILE_GROUP_KEY);
|
|
1065
|
+
if (!group) {
|
|
1066
|
+
group = scene.physics.add.staticGroup();
|
|
1067
|
+
layer.setData(SUB_TILE_GROUP_KEY, group);
|
|
1068
|
+
}
|
|
1069
|
+
// Clear existing bodies. `clear(true, true)` destroys GOs + their bodies
|
|
1070
|
+
// so we rebuild from a clean slate. Bounded — at most O(painted_cells).
|
|
1071
|
+
group.clear(true, true);
|
|
1072
|
+
// Build a fast index → collisionRects lookup. Most tiles in a tileset
|
|
1073
|
+
// won't have sub-rects, so this is sparse.
|
|
1074
|
+
const tiles = asset.tileset?.tiles;
|
|
1075
|
+
if (!tiles)
|
|
1076
|
+
return;
|
|
1077
|
+
const subRectsByIdx = new Map();
|
|
1078
|
+
for (const key of Object.keys(tiles)) {
|
|
1079
|
+
const idx = Number(key);
|
|
1080
|
+
if (!Number.isFinite(idx))
|
|
1081
|
+
continue;
|
|
1082
|
+
const meta = tiles[idx];
|
|
1083
|
+
if (meta?.collisionRects && meta.collisionRects.length > 0) {
|
|
1084
|
+
subRectsByIdx.set(idx, meta.collisionRects);
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
if (subRectsByIdx.size === 0)
|
|
1088
|
+
return;
|
|
1089
|
+
// Walk painted tiles. For each tile with one-or-more sub-rects: disable
|
|
1090
|
+
// its native cell collision (so it doesn't double-collide) + spawn one
|
|
1091
|
+
// invisible Rectangle with a static body per sub-rect. Position uses
|
|
1092
|
+
// `tile.getLeft()/getTop()` which returns WORLD coords accounting for
|
|
1093
|
+
// the layer's transform — correct when cellSize != tileSize and the
|
|
1094
|
+
// layer is scaled. Multi-rect lets one tile carry L/U/frame collision.
|
|
1095
|
+
layer.forEachTile((tile) => {
|
|
1096
|
+
const rects = subRectsByIdx.get(tile.index);
|
|
1097
|
+
if (!rects)
|
|
1098
|
+
return;
|
|
1099
|
+
// Turn OFF native cell collision so collider events fire only against
|
|
1100
|
+
// the sub-rect bodies (avoids double-collision: cell + sub).
|
|
1101
|
+
tile.setCollision(false, false, false, false, false);
|
|
1102
|
+
const worldLeft = tile.getLeft();
|
|
1103
|
+
const worldTop = tile.getTop();
|
|
1104
|
+
const sx = layer.scaleX;
|
|
1105
|
+
const sy = layer.scaleY;
|
|
1106
|
+
for (const rect of rects) {
|
|
1107
|
+
const cx = worldLeft + (rect.x + rect.w / 2) * sx;
|
|
1108
|
+
const cy = worldTop + (rect.y + rect.h / 2) * sy;
|
|
1109
|
+
const w = rect.w * sx;
|
|
1110
|
+
const h = rect.h * sy;
|
|
1111
|
+
const bodyGO = scene.add.rectangle(cx, cy, w, h);
|
|
1112
|
+
bodyGO.setVisible(false);
|
|
1113
|
+
scene.physics.add.existing(bodyGO, true /* static body */);
|
|
1114
|
+
group.add(bodyGO);
|
|
1115
|
+
}
|
|
1116
|
+
});
|
|
1117
|
+
}
|
|
1118
|
+
/**
|
|
1119
|
+
* Wire collision between `target` (a sprite, group, or anything with a
|
|
1120
|
+
* physics body) and all collision surfaces of a tilemap entity — both
|
|
1121
|
+
* the layer's native cell-rect collision AND any sub-tile static bodies
|
|
1122
|
+
* authored via the Tile Metadata Editor's collision-shape mode.
|
|
1123
|
+
*
|
|
1124
|
+
* The one-call alternative to:
|
|
1125
|
+
* ```ts
|
|
1126
|
+
* scene.physics.add.collider(player, layer);
|
|
1127
|
+
* scene.physics.add.collider(player, layer.getData('unboxySubTileStaticGroup'));
|
|
1128
|
+
* ```
|
|
1129
|
+
*
|
|
1130
|
+
* Behavior code in a typical game:
|
|
1131
|
+
* ```ts
|
|
1132
|
+
* import { addTilemapCollider } from '@umicat/phaser-sdk';
|
|
1133
|
+
* create() {
|
|
1134
|
+
* // ... spawn player ...
|
|
1135
|
+
* addTilemapCollider(this, 'world', this.player);
|
|
1136
|
+
* }
|
|
1137
|
+
* ```
|
|
1138
|
+
*
|
|
1139
|
+
* Returns the created Collider instances so callers can `.destroy()` them
|
|
1140
|
+
* if needed (e.g. on level transition).
|
|
1141
|
+
*/
|
|
1142
|
+
export function addTilemapCollider(scene, entityId, target, callback) {
|
|
1143
|
+
const out = [];
|
|
1144
|
+
if (!scene.physics?.add)
|
|
1145
|
+
return out;
|
|
1146
|
+
const registry = getEntityRegistry(scene);
|
|
1147
|
+
if (!registry)
|
|
1148
|
+
return out;
|
|
1149
|
+
const go = registry.byId(entityId);
|
|
1150
|
+
if (!go)
|
|
1151
|
+
return out;
|
|
1152
|
+
if (go.getData('entityKind') !== 'tilemap')
|
|
1153
|
+
return out;
|
|
1154
|
+
const container = go;
|
|
1155
|
+
const layers = container.getData('tilemapLayers') ?? [];
|
|
1156
|
+
for (const layer of layers) {
|
|
1157
|
+
out.push(scene.physics.add.collider(target, layer, callback));
|
|
1158
|
+
const group = layer.getData(SUB_TILE_GROUP_KEY);
|
|
1159
|
+
if (group) {
|
|
1160
|
+
out.push(scene.physics.add.collider(target, group, callback));
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
return out;
|
|
1164
|
+
}
|
|
1165
|
+
function resolveTilesetCellSize(asset, fallbackW, fallbackH) {
|
|
1166
|
+
if (asset.tileset?.cellSize)
|
|
1167
|
+
return asset.tileset.cellSize;
|
|
1168
|
+
// Spritesheet assets carry frame dims either nested in spriteSheetConfig
|
|
1169
|
+
// or top-level — both shapes appear in real workspaces (see queueAssetLoad
|
|
1170
|
+
// for the matching tolerance).
|
|
1171
|
+
const cfgW = asset.spriteSheetConfig?.frameWidth ?? asset.frameWidth;
|
|
1172
|
+
const cfgH = asset.spriteSheetConfig?.frameHeight ?? asset.frameHeight;
|
|
1173
|
+
if (typeof cfgW === 'number' && typeof cfgH === 'number') {
|
|
1174
|
+
return { width: cfgW, height: cfgH };
|
|
1175
|
+
}
|
|
1176
|
+
return { width: fallbackW, height: fallbackH };
|
|
1177
|
+
}
|
|
1178
|
+
/**
|
|
1179
|
+
* Accepts `'#rrggbb'`, `'rrggbb'`, or `'0xrrggbb'` and returns a number
|
|
1180
|
+
* suitable for Phaser's color APIs.
|
|
1181
|
+
*/
|
|
1182
|
+
export function parseColor(input) {
|
|
1183
|
+
const trimmed = input.trim();
|
|
1184
|
+
if (trimmed.startsWith('#'))
|
|
1185
|
+
return parseInt(trimmed.slice(1), 16);
|
|
1186
|
+
if (trimmed.startsWith('0x'))
|
|
1187
|
+
return parseInt(trimmed.slice(2), 16);
|
|
1188
|
+
return parseInt(trimmed, 16);
|
|
1189
|
+
}
|
|
1190
|
+
// --- Slice 8: hitbox application -----------------------------------------
|
|
1191
|
+
const HITBOX_LISTENER_KEY = 'unboxyHitboxListener';
|
|
1192
|
+
/**
|
|
1193
|
+
* Apply the asset's `hitbox` metadata to a sprite's physics body.
|
|
1194
|
+
*
|
|
1195
|
+
* Called automatically at spawn from `createSprite`; ALSO callable by
|
|
1196
|
+
* behavior code that attaches a body later (e.g. the agent's physics skill
|
|
1197
|
+
* does `scene.physics.add.existing(sprite); applyAssetHitbox(sprite, asset);`).
|
|
1198
|
+
*
|
|
1199
|
+
* Semantics:
|
|
1200
|
+
* - **No-op (silent)** when `asset.hitbox` is unset — that's the chat-only
|
|
1201
|
+
* path (primitives, AI-gen images without vision metadata). Workflow A
|
|
1202
|
+
* in design doc 09 §4.4 is unaffected.
|
|
1203
|
+
* - **Dev-warn (NOT throw)** when called on a sprite without a physics body.
|
|
1204
|
+
* The most likely agent mistake is calling this BEFORE
|
|
1205
|
+
* `scene.physics.add.existing(sprite)`; the warning surfaces in the
|
|
1206
|
+
* iframe console for the agent's post-turn diagnostic loop.
|
|
1207
|
+
* - **Per-frame variant** installs an `ANIMATION_UPDATE` listener that
|
|
1208
|
+
* swaps `body.setSize/setOffset` on every frame change during anim
|
|
1209
|
+
* playback. Listener is idempotent — calling `applyAssetHitbox` twice
|
|
1210
|
+
* on the same sprite tears down the prior listener before installing a
|
|
1211
|
+
* new one (handles asset hot-reload + Inspector live-edits).
|
|
1212
|
+
*
|
|
1213
|
+
* Caveat: per-frame swap fires on `Phaser.Animations.Events.ANIMATION_UPDATE`
|
|
1214
|
+
* only, which means manual `sprite.setFrame(idx)` calls outside of animation
|
|
1215
|
+
* playback do NOT swap the body. v1 accepts this — combat sheets are
|
|
1216
|
+
* animation-driven. A `setFrame` wrapper is a v2 candidate.
|
|
1217
|
+
*/
|
|
1218
|
+
export function applyAssetHitbox(sprite, asset) {
|
|
1219
|
+
if (!asset.hitbox)
|
|
1220
|
+
return;
|
|
1221
|
+
const body = sprite.body;
|
|
1222
|
+
if (!body) {
|
|
1223
|
+
// eslint-disable-next-line no-console
|
|
1224
|
+
console.warn(`[umicat/scene] applyAssetHitbox called on a sprite with no physics ` +
|
|
1225
|
+
`body (asset=${asset.id}). Call scene.physics.add.existing(sprite) ` +
|
|
1226
|
+
`first, or this is a no-op.`);
|
|
1227
|
+
return;
|
|
1228
|
+
}
|
|
1229
|
+
// Tear down any previously installed per-frame listener so re-applying is
|
|
1230
|
+
// idempotent. The listener reference is stashed on the sprite's data
|
|
1231
|
+
// manager because Phaser's emitter needs the exact function reference to
|
|
1232
|
+
// remove it.
|
|
1233
|
+
const prior = sprite.getData(HITBOX_LISTENER_KEY);
|
|
1234
|
+
if (prior) {
|
|
1235
|
+
sprite.off(Phaser.Animations.Events.ANIMATION_UPDATE, prior);
|
|
1236
|
+
sprite.setData(HITBOX_LISTENER_KEY, undefined);
|
|
1237
|
+
}
|
|
1238
|
+
if (isPerFrameHitbox(asset.hitbox)) {
|
|
1239
|
+
// Apply the default shape immediately — covers the current frame plus
|
|
1240
|
+
// every frame not present in the overrides map.
|
|
1241
|
+
applyShape(body, asset.hitbox.default);
|
|
1242
|
+
const overrides = asset.hitbox.frames;
|
|
1243
|
+
const defaultShape = asset.hitbox.default;
|
|
1244
|
+
const listener = (_anim, frame) => {
|
|
1245
|
+
applyShape(body, overrides[frame.index] ?? defaultShape);
|
|
1246
|
+
};
|
|
1247
|
+
sprite.setData(HITBOX_LISTENER_KEY, listener);
|
|
1248
|
+
sprite.on(Phaser.Animations.Events.ANIMATION_UPDATE, listener);
|
|
1249
|
+
}
|
|
1250
|
+
else {
|
|
1251
|
+
applyShape(body, asset.hitbox);
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
/**
|
|
1255
|
+
* Internal helper that maps a HitboxRect to Phaser's body API. v1 handles
|
|
1256
|
+
* `kind: 'rect'` only; v2 will widen the union to `HitboxRect | HitboxCircle`
|
|
1257
|
+
* and branch on `shape.kind` — see design doc 09 §9.2.1.
|
|
1258
|
+
*/
|
|
1259
|
+
function applyShape(body, shape) {
|
|
1260
|
+
body.setSize(shape.w, shape.h);
|
|
1261
|
+
body.setOffset(shape.x, shape.y);
|
|
1262
|
+
}
|
|
1263
|
+
/**
|
|
1264
|
+
* Look up the painted tile at a world coordinate inside a tilemap entity.
|
|
1265
|
+
*
|
|
1266
|
+
* Slice 6 Phase C (design doc 06 §5.2). Returns null when:
|
|
1267
|
+
* - the entity doesn't exist / isn't a tilemap;
|
|
1268
|
+
* - no layer in the tilemap has a painted (non-empty) tile at that
|
|
1269
|
+
* world coord;
|
|
1270
|
+
* - the world coord is outside every layer's bounds.
|
|
1271
|
+
*
|
|
1272
|
+
* Layer scan order (when `options.layerId` is omitted) is top-down — the
|
|
1273
|
+
* layer with the highest `z` is queried first, matching the visual stack.
|
|
1274
|
+
* Pass `options.layerId` to scope to one layer (e.g. for a "ground type"
|
|
1275
|
+
* lookup on the floor layer specifically).
|
|
1276
|
+
*
|
|
1277
|
+
* Behavior-code use:
|
|
1278
|
+
* ```ts
|
|
1279
|
+
* const hit = getTilemapAt(this, 'world', player.x, player.y);
|
|
1280
|
+
* if (hit?.metadata.damage) player.hp -= hit.metadata.damage * dt;
|
|
1281
|
+
* if (hit?.metadata.movement === 'swim') player.speed *= 0.5;
|
|
1282
|
+
* ```
|
|
1283
|
+
*
|
|
1284
|
+
* Per-frame perf: each layer query is a constant-time `worldToTileXY` +
|
|
1285
|
+
* `getTileAt` Phaser call. Safe for `update()` loops at typical layer
|
|
1286
|
+
* counts (1–4 layers per tilemap).
|
|
1287
|
+
*/
|
|
1288
|
+
export function getTilemapAt(scene, entityId, worldX, worldY, options) {
|
|
1289
|
+
const registry = getEntityRegistry(scene);
|
|
1290
|
+
if (!registry)
|
|
1291
|
+
return null;
|
|
1292
|
+
const go = registry.byId(entityId);
|
|
1293
|
+
if (!go)
|
|
1294
|
+
return null;
|
|
1295
|
+
if (go.getData('entityKind') !== 'tilemap')
|
|
1296
|
+
return null;
|
|
1297
|
+
const container = go;
|
|
1298
|
+
const layers = container.getData('tilemapLayers') ?? [];
|
|
1299
|
+
if (layers.length === 0)
|
|
1300
|
+
return null;
|
|
1301
|
+
// Top-down walk so the visually-front layer wins. Stable sort by the
|
|
1302
|
+
// layer's current depth (set by the per-frame sync hook + initial spawn
|
|
1303
|
+
// → matches container.depth or layer.z). Cheap — typical N=1–4.
|
|
1304
|
+
const candidates = options?.layerId
|
|
1305
|
+
? layers.filter((l) => l.getData('tilemapLayerId') === options.layerId)
|
|
1306
|
+
: [...layers].sort((a, b) => b.depth - a.depth);
|
|
1307
|
+
for (const layer of candidates) {
|
|
1308
|
+
if (!layer.visible)
|
|
1309
|
+
continue; // hidden layers participate in collision but not lookup; design 06 §5.2
|
|
1310
|
+
const tileXY = layer.worldToTileXY(worldX, worldY, true);
|
|
1311
|
+
if (!tileXY)
|
|
1312
|
+
continue;
|
|
1313
|
+
const tile = layer.getTileAt(tileXY.x, tileXY.y, false);
|
|
1314
|
+
if (!tile || tile.index < 0)
|
|
1315
|
+
continue;
|
|
1316
|
+
const props = tile.properties ?? {};
|
|
1317
|
+
return {
|
|
1318
|
+
layerId: layer.getData('tilemapLayerId') ?? '',
|
|
1319
|
+
tileIndex: tile.index,
|
|
1320
|
+
metadata: { ...props },
|
|
1321
|
+
tileX: tileXY.x,
|
|
1322
|
+
tileY: tileXY.y,
|
|
1323
|
+
};
|
|
1324
|
+
}
|
|
1325
|
+
return null;
|
|
1326
|
+
}
|