@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,615 @@
|
|
|
1
|
+
import Phaser from 'phaser';
|
|
2
|
+
import { SCHEMA_VERSION, } from './types.js';
|
|
3
|
+
import { spawnEntity } from './spawnEntity.js';
|
|
4
|
+
import { attachEntityRegistry } from './EntityRegistry.js';
|
|
5
|
+
import { resolveRenderScript } from './renderScripts.js';
|
|
6
|
+
/**
|
|
7
|
+
* Where scene files live in the workspace and at runtime. The Vite build
|
|
8
|
+
* copies `public/` to the served root, so paths are origin-relative
|
|
9
|
+
* (`scenes/manifest.json`). No leading slash — the iframe is sometimes
|
|
10
|
+
* served under a sub-path (`/preview/:gameId/`).
|
|
11
|
+
*/
|
|
12
|
+
export const SCENES_BASE = 'scenes/';
|
|
13
|
+
export const MANIFEST_PATH = `${SCENES_BASE}manifest.json`;
|
|
14
|
+
const MANIFEST_CACHE_KEY = '__unboxyManifestState';
|
|
15
|
+
/**
|
|
16
|
+
* Fetches the manifest via Phaser's loader. Must be called from a Phaser
|
|
17
|
+
* scene's `preload()` since it queues a load request. Subsequent calls
|
|
18
|
+
* (e.g. on scene transitions) hit the Phaser JSON cache.
|
|
19
|
+
*
|
|
20
|
+
* Resolves once Phaser fires `complete` for the load batch — callers
|
|
21
|
+
* typically await this in `create()` after `this.load.start()` is done.
|
|
22
|
+
*/
|
|
23
|
+
export function preloadManifest(scene) {
|
|
24
|
+
if (!scene.cache.json.exists('umicat:manifest')) {
|
|
25
|
+
scene.load.json('umicat:manifest', MANIFEST_PATH);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Read the manifest from Phaser's JSON cache. Throws if `preloadManifest`
|
|
30
|
+
* hasn't completed first.
|
|
31
|
+
*/
|
|
32
|
+
export function getManifest(scene) {
|
|
33
|
+
const raw = scene.cache.json.get('umicat:manifest');
|
|
34
|
+
if (!raw) {
|
|
35
|
+
throw new Error("[umicat/scene] manifest not loaded — call preloadManifest(scene) in BootScene.preload() and wait for the loader's 'complete' event");
|
|
36
|
+
}
|
|
37
|
+
const manifest = raw;
|
|
38
|
+
validateManifest(manifest);
|
|
39
|
+
return manifest;
|
|
40
|
+
}
|
|
41
|
+
function validateManifest(m) {
|
|
42
|
+
if (m.schemaVersion !== SCHEMA_VERSION) {
|
|
43
|
+
throw new Error(`[umicat/scene] manifest schemaVersion ${m.schemaVersion} but SDK expects ${SCHEMA_VERSION}`);
|
|
44
|
+
}
|
|
45
|
+
if (!m.scenes?.length) {
|
|
46
|
+
throw new Error('[umicat/scene] manifest has no scenes');
|
|
47
|
+
}
|
|
48
|
+
if (!m.scenes.find((s) => s.id === m.initialScene)) {
|
|
49
|
+
throw new Error(`[umicat/scene] manifest.initialScene '${m.initialScene}' is not in scenes[]`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function getOrInitState(scene, manifest) {
|
|
53
|
+
const game = scene.game;
|
|
54
|
+
const existing = game[MANIFEST_CACHE_KEY];
|
|
55
|
+
if (existing && existing.manifest === manifest)
|
|
56
|
+
return existing;
|
|
57
|
+
const assetsById = new Map();
|
|
58
|
+
for (const a of manifest.assets ?? [])
|
|
59
|
+
assetsById.set(a.id, a);
|
|
60
|
+
const state = {
|
|
61
|
+
manifest,
|
|
62
|
+
assetsById,
|
|
63
|
+
requestedAssetIds: new Set(),
|
|
64
|
+
};
|
|
65
|
+
game[MANIFEST_CACHE_KEY] = state;
|
|
66
|
+
return state;
|
|
67
|
+
}
|
|
68
|
+
// --- Lazy asset preload ----------------------------------------------------
|
|
69
|
+
/**
|
|
70
|
+
* Walk a scene file's entities and queue Phaser loads for any assets that
|
|
71
|
+
* aren't already in the texture cache. Caller is responsible for awaiting
|
|
72
|
+
* the loader (`scene.load.once('complete', ...)` or do it in `preload()`).
|
|
73
|
+
*
|
|
74
|
+
* Idempotent across scenes — once an asset is loaded, switching to another
|
|
75
|
+
* scene that uses it is free.
|
|
76
|
+
*/
|
|
77
|
+
export function preloadSceneAssets(scene, sceneFile, manifest) {
|
|
78
|
+
if (sceneFile.type !== 'world')
|
|
79
|
+
return; // HUD slice will add its own walker
|
|
80
|
+
const state = getOrInitState(scene, manifest);
|
|
81
|
+
const ids = collectAssetIds(sceneFile.entities);
|
|
82
|
+
for (const id of ids) {
|
|
83
|
+
if (state.requestedAssetIds.has(id))
|
|
84
|
+
continue;
|
|
85
|
+
const asset = state.assetsById.get(id);
|
|
86
|
+
if (!asset) {
|
|
87
|
+
// Soft-fail: warn, continue. The downstream spawn path renders a
|
|
88
|
+
// magenta "?" placeholder for the offending entity so the user sees
|
|
89
|
+
// *where* a missing asset would have been, without losing every
|
|
90
|
+
// other entity in the scene. Common cause is the editor adding a
|
|
91
|
+
// sprite that references an asset whose manifest entry never got
|
|
92
|
+
// synced (e.g. AssetShelf drag without manifest update).
|
|
93
|
+
// eslint-disable-next-line no-console
|
|
94
|
+
console.warn(`[umicat/scene] scene '${sceneFile.id}' references assetId '${id}' but the manifest has no such asset; entity will render as a placeholder`);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
queueAssetLoad(scene, asset);
|
|
98
|
+
state.requestedAssetIds.add(id);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function collectAssetIds(entities) {
|
|
102
|
+
const ids = new Set();
|
|
103
|
+
function walk(e) {
|
|
104
|
+
if (e.kind === 'sprite') {
|
|
105
|
+
ids.add(e.assetId);
|
|
106
|
+
}
|
|
107
|
+
else if (e.kind === 'group') {
|
|
108
|
+
for (const child of e.children)
|
|
109
|
+
walk(child);
|
|
110
|
+
}
|
|
111
|
+
else if (e.kind === 'tilemap') {
|
|
112
|
+
// Slice 6 Phase A: each layer's tilesetIds reference Asset records
|
|
113
|
+
// the same way sprite.assetId does. Walk every layer and queue each
|
|
114
|
+
// tileset's PNG for load. Phase A consumes one tileset per layer at
|
|
115
|
+
// render time but we collect the whole tilesetIds[] array so later
|
|
116
|
+
// phases (multi-tileset blended layers) need no change here.
|
|
117
|
+
for (const layer of e.layers) {
|
|
118
|
+
for (const id of layer.tilesetIds ?? [])
|
|
119
|
+
ids.add(id);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
// rect / circle / code-rendered / trigger don't reference assets.
|
|
123
|
+
}
|
|
124
|
+
for (const e of entities)
|
|
125
|
+
walk(e);
|
|
126
|
+
return Array.from(ids);
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Walk every `kind=spritesheet` asset in the manifest and register each one's
|
|
130
|
+
* `animations[]` as a Phaser animation, so behavior code can call
|
|
131
|
+
* `sprite.play('walk-down')` directly. Idempotent — uses `scene.anims.exists()`
|
|
132
|
+
* to skip duplicates (Phaser's anims.create throws otherwise).
|
|
133
|
+
*
|
|
134
|
+
* <p>Animation keys are scene-global. Two sheets registering the same name
|
|
135
|
+
* (e.g. two characters both with `walk-down`) → first wins, second logs a
|
|
136
|
+
* warning. Sheets that need disambiguation should namespace their keys
|
|
137
|
+
* (e.g. `cow:walk-down`) at metadata time.
|
|
138
|
+
*/
|
|
139
|
+
/**
|
|
140
|
+
* Walk every asset in the manifest and apply `Phaser.Textures.FilterMode.NEAREST`
|
|
141
|
+
* to each `pixelArt: true` entry's loaded texture. Without this, pixel-art
|
|
142
|
+
* sprites render bilinear-blurred — visible immediately on imported character
|
|
143
|
+
* sheets when the agent hasn't manually set Phaser game config's `pixelArt`.
|
|
144
|
+
*
|
|
145
|
+
* <p>Idempotent: setFilter is safe to call multiple times. Skip silently when
|
|
146
|
+
* a texture isn't loaded yet (next call after that asset is requested will
|
|
147
|
+
* pick it up). Wrap in try/catch so a single bad asset doesn't block the rest
|
|
148
|
+
* of scene init.
|
|
149
|
+
*
|
|
150
|
+
* <p>Per-asset NEAREST closes the visible blur gap; the game-wide flag
|
|
151
|
+
* (`Phaser.Game.config.pixelArt` + `camera.setRoundPixels`) is a separate
|
|
152
|
+
* concern (framebuffer-stretch crispness, perf) deferred to a follow-up.
|
|
153
|
+
*/
|
|
154
|
+
export function applyPixelArtFilters(scene, manifest) {
|
|
155
|
+
for (const asset of manifest.assets ?? []) {
|
|
156
|
+
if (!asset.pixelArt)
|
|
157
|
+
continue;
|
|
158
|
+
if (!scene.textures.exists(asset.textureKey))
|
|
159
|
+
continue;
|
|
160
|
+
try {
|
|
161
|
+
const tex = scene.textures.get(asset.textureKey);
|
|
162
|
+
tex.setFilter(Phaser.Textures.FilterMode.NEAREST);
|
|
163
|
+
}
|
|
164
|
+
catch (e) {
|
|
165
|
+
// eslint-disable-next-line no-console
|
|
166
|
+
console.warn(`[umicat/scene] failed to apply NEAREST filter to '${asset.id}': ${String(e)}`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
function registerSheetAnimations(scene, manifest) {
|
|
171
|
+
const assets = manifest.assets ?? [];
|
|
172
|
+
for (const asset of assets) {
|
|
173
|
+
if (asset.kind !== 'spritesheet')
|
|
174
|
+
continue;
|
|
175
|
+
if (!asset.animations || asset.animations.length === 0)
|
|
176
|
+
continue;
|
|
177
|
+
if (!scene.textures.exists(asset.textureKey))
|
|
178
|
+
continue; // not loaded — skip
|
|
179
|
+
const defaultFps = asset.fps && asset.fps > 0 ? asset.fps : 8;
|
|
180
|
+
for (const anim of asset.animations) {
|
|
181
|
+
if (!anim.name)
|
|
182
|
+
continue;
|
|
183
|
+
if (scene.anims.exists(anim.name)) {
|
|
184
|
+
// Don't error out on collision — first registration wins.
|
|
185
|
+
// eslint-disable-next-line no-console
|
|
186
|
+
console.warn(`[umicat/scene] animation key '${anim.name}' already registered; skipping (asset '${asset.id}'). Disambiguate by renaming in the asset's animations metadata.`);
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
const fps = anim.fps && anim.fps > 0 ? anim.fps : defaultFps;
|
|
190
|
+
const loop = anim.loop !== false; // default true
|
|
191
|
+
try {
|
|
192
|
+
scene.anims.create({
|
|
193
|
+
key: anim.name,
|
|
194
|
+
frames: scene.anims.generateFrameNumbers(asset.textureKey, {
|
|
195
|
+
start: anim.from,
|
|
196
|
+
end: anim.to,
|
|
197
|
+
}),
|
|
198
|
+
frameRate: fps,
|
|
199
|
+
repeat: loop ? -1 : 0,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
catch (e) {
|
|
203
|
+
// eslint-disable-next-line no-console
|
|
204
|
+
console.warn(`[umicat/scene] failed to register animation '${anim.name}' on '${asset.id}': ${String(e)}`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Atlas-json side-cache key. Phaser's `scene.load.atlas()` loads + parses the
|
|
211
|
+
* JSON but drops unknown per-frame fields (e.g. `ninePatch`) — they don't
|
|
212
|
+
* survive into the Phaser Frame object. To consume them at runtime we ALSO
|
|
213
|
+
* fetch the raw JSON via `scene.load.json()` and stash it under this key so
|
|
214
|
+
* `getAtlasFrameNinePatch` can read it back.
|
|
215
|
+
*
|
|
216
|
+
* Used by region-atlas assets (visual editor slice 10) where each tagged
|
|
217
|
+
* region can carry its own 9-slice cuts.
|
|
218
|
+
*/
|
|
219
|
+
function atlasNinePatchCacheKey(textureKey) {
|
|
220
|
+
return `umicat:atlas-ninepatch:${textureKey}`;
|
|
221
|
+
}
|
|
222
|
+
function queueAtlasNinePatchSidecar(scene, asset) {
|
|
223
|
+
if (!asset.atlasPath)
|
|
224
|
+
return;
|
|
225
|
+
const cacheKey = atlasNinePatchCacheKey(asset.textureKey);
|
|
226
|
+
if (scene.cache.json.exists(cacheKey))
|
|
227
|
+
return;
|
|
228
|
+
scene.load.json(cacheKey, asset.atlasPath);
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Look up per-frame 9-slice metadata for an atlas-format asset. Returns the
|
|
232
|
+
* `NinePatchConfig` declared inline on the named frame in the atlas JSON, or
|
|
233
|
+
* `undefined` if the asset isn't an atlas, the cache miss happened, or the
|
|
234
|
+
* frame has no `ninePatch` entry (plain stretched draw).
|
|
235
|
+
*/
|
|
236
|
+
export function getAtlasFrameNinePatch(scene, textureKey, frameName) {
|
|
237
|
+
const cacheKey = atlasNinePatchCacheKey(textureKey);
|
|
238
|
+
if (!scene.cache.json.exists(cacheKey))
|
|
239
|
+
return undefined;
|
|
240
|
+
const raw = scene.cache.json.get(cacheKey);
|
|
241
|
+
const entry = raw?.frames?.[frameName];
|
|
242
|
+
const np = entry?.ninePatch;
|
|
243
|
+
if (!np)
|
|
244
|
+
return undefined;
|
|
245
|
+
if (typeof np.leftWidth !== 'number' ||
|
|
246
|
+
typeof np.rightWidth !== 'number' ||
|
|
247
|
+
typeof np.topHeight !== 'number' ||
|
|
248
|
+
typeof np.bottomHeight !== 'number') {
|
|
249
|
+
return undefined;
|
|
250
|
+
}
|
|
251
|
+
return {
|
|
252
|
+
leftWidth: np.leftWidth,
|
|
253
|
+
rightWidth: np.rightWidth,
|
|
254
|
+
topHeight: np.topHeight,
|
|
255
|
+
bottomHeight: np.bottomHeight,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
function queueAssetLoad(scene, asset) {
|
|
259
|
+
if (scene.textures.exists(asset.textureKey))
|
|
260
|
+
return;
|
|
261
|
+
switch (asset.kind) {
|
|
262
|
+
case 'image':
|
|
263
|
+
scene.load.image(asset.textureKey, asset.path);
|
|
264
|
+
return;
|
|
265
|
+
case 'spritesheet': {
|
|
266
|
+
// Tolerant config resolution: prefer nested `spriteSheetConfig`, fall
|
|
267
|
+
// back to top-level `frameWidth/frameHeight/margin/spacing` if the
|
|
268
|
+
// agent put them there (common typo — both shapes appear in real
|
|
269
|
+
// workspaces). Soft-fail with warning when neither shape provides
|
|
270
|
+
// frame dims so the rest of the scene still boots.
|
|
271
|
+
const cfg = asset.spriteSheetConfig
|
|
272
|
+
?? (asset.frameWidth && asset.frameHeight
|
|
273
|
+
? {
|
|
274
|
+
frameWidth: asset.frameWidth,
|
|
275
|
+
frameHeight: asset.frameHeight,
|
|
276
|
+
margin: asset.margin,
|
|
277
|
+
spacing: asset.spacing,
|
|
278
|
+
}
|
|
279
|
+
: null);
|
|
280
|
+
if (!cfg) {
|
|
281
|
+
// eslint-disable-next-line no-console
|
|
282
|
+
console.warn(`[umicat/scene] asset '${asset.id}' kind=spritesheet missing spriteSheetConfig (and no top-level frameWidth/frameHeight); skipping load`);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
scene.load.spritesheet(asset.textureKey, asset.path, cfg);
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
case 'atlas':
|
|
289
|
+
if (!asset.atlasPath || !asset.atlasFormat) {
|
|
290
|
+
throw new Error(`[umicat/scene] asset '${asset.id}' kind=atlas missing atlasPath/atlasFormat`);
|
|
291
|
+
}
|
|
292
|
+
if (asset.atlasFormat === 'xml') {
|
|
293
|
+
scene.load.atlasXML(asset.textureKey, asset.path, asset.atlasPath);
|
|
294
|
+
}
|
|
295
|
+
else {
|
|
296
|
+
scene.load.atlas(asset.textureKey, asset.path, asset.atlasPath);
|
|
297
|
+
queueAtlasNinePatchSidecar(scene, asset);
|
|
298
|
+
}
|
|
299
|
+
return;
|
|
300
|
+
case 'audio':
|
|
301
|
+
scene.load.audio(asset.textureKey, asset.path);
|
|
302
|
+
return;
|
|
303
|
+
case 'json':
|
|
304
|
+
scene.load.json(asset.textureKey, asset.path);
|
|
305
|
+
return;
|
|
306
|
+
default: {
|
|
307
|
+
const exhaustive = asset.kind;
|
|
308
|
+
throw new Error(`[umicat/scene] unknown asset kind: ${JSON.stringify(exhaustive)}`);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Suspend the scene's update/physics/tween processing while async work
|
|
314
|
+
* runs inside `create()`. Phaser does not await an async `create()` —
|
|
315
|
+
* the scene transitions to RUNNING as soon as the call returns its
|
|
316
|
+
* Promise, and `update()` starts ticking on the next frame regardless of
|
|
317
|
+
* whether the awaited load has settled. Without this guard, any
|
|
318
|
+
* `update()` code that reads class fields populated AFTER the await
|
|
319
|
+
* (e.g. `this.player.body`) crashes on frame 1 with
|
|
320
|
+
* `Cannot read properties of undefined (reading 'body')`.
|
|
321
|
+
*
|
|
322
|
+
* Idempotent: if the scene is already paused (e.g. the editor has
|
|
323
|
+
* paused everything via `setupEditorModeListener`), we leave it that
|
|
324
|
+
* way and the returned release is a no-op.
|
|
325
|
+
*/
|
|
326
|
+
export function suspendSceneUpdates(scene) {
|
|
327
|
+
const wasActive = scene.sys.settings.active;
|
|
328
|
+
if (!wasActive)
|
|
329
|
+
return () => { };
|
|
330
|
+
scene.scene.pause();
|
|
331
|
+
return () => {
|
|
332
|
+
scene.scene.resume();
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* One-shot async loader: fetch scene JSON (if not already cached), lazy
|
|
337
|
+
* preload its assets, spawn entities, configure camera, and return the
|
|
338
|
+
* EntityRegistry. Idempotent re-loads of the same scene id reuse the
|
|
339
|
+
* Phaser JSON cache.
|
|
340
|
+
*
|
|
341
|
+
* Phaser's update loop is suspended for the duration of the await so
|
|
342
|
+
* `update()` can safely read fields the agent assigns AFTER `await
|
|
343
|
+
* loadWorldScene(...)` — no manual `if (!this.player) return;` guard
|
|
344
|
+
* required.
|
|
345
|
+
*
|
|
346
|
+
* Pattern (in `GameScene.create()`):
|
|
347
|
+
*
|
|
348
|
+
* ```ts
|
|
349
|
+
* const result = await loadWorldScene(this, sceneId);
|
|
350
|
+
* // entities live; behavior code can use result.registry.byRole('player')
|
|
351
|
+
* ```
|
|
352
|
+
*/
|
|
353
|
+
export async function loadWorldScene(scene, sceneId, options = {}) {
|
|
354
|
+
const release = suspendSceneUpdates(scene);
|
|
355
|
+
try {
|
|
356
|
+
return await loadWorldSceneImpl(scene, sceneId, options);
|
|
357
|
+
}
|
|
358
|
+
finally {
|
|
359
|
+
release();
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
async function loadWorldSceneImpl(scene, sceneId, options) {
|
|
363
|
+
const manifest = getManifest(scene);
|
|
364
|
+
const ref = manifest.scenes.find((s) => s.id === sceneId);
|
|
365
|
+
if (!ref)
|
|
366
|
+
throw new Error(`[umicat/scene] manifest has no scene with id '${sceneId}'`);
|
|
367
|
+
if (ref.type !== 'world') {
|
|
368
|
+
throw new Error(`[umicat/scene] scene '${sceneId}' is type=${ref.type}; loadWorldScene expects type=world`);
|
|
369
|
+
}
|
|
370
|
+
const sceneFile = (await loadSceneJson(scene, ref));
|
|
371
|
+
if (sceneFile.schemaVersion !== SCHEMA_VERSION) {
|
|
372
|
+
throw new Error(`[umicat/scene] scene '${sceneId}' schemaVersion ${sceneFile.schemaVersion} but SDK expects ${SCHEMA_VERSION}`);
|
|
373
|
+
}
|
|
374
|
+
if (sceneFile.type !== 'world') {
|
|
375
|
+
throw new Error(`[umicat/scene] scene '${sceneId}' has type=${sceneFile.type} in file body`);
|
|
376
|
+
}
|
|
377
|
+
// Apply gravity declared in scene data to the Arcade physics world before
|
|
378
|
+
// entities spawn. Scene-level `world.physics.gravity` wins over the
|
|
379
|
+
// manifest-wide `globals.physics.gravity`.
|
|
380
|
+
applyWorldGravity(scene, sceneFile, manifest);
|
|
381
|
+
// Lazy preload of any new assets this scene needs, then await loader.
|
|
382
|
+
preloadSceneAssets(scene, sceneFile, manifest);
|
|
383
|
+
await runLoader(scene);
|
|
384
|
+
// Per-asset NEAREST filter for `pixelArt: true` entries. Must run after
|
|
385
|
+
// textures are loaded — setFilter on a missing texture is a no-op.
|
|
386
|
+
applyPixelArtFilters(scene, manifest);
|
|
387
|
+
// Register Phaser animations declared in `manifest.assets[].animations`.
|
|
388
|
+
// Done after texture load + before entity spawn so behavior code can call
|
|
389
|
+
// `sprite.play('walk-down')` from frame 1 without manual `anims.create()`.
|
|
390
|
+
registerSheetAnimations(scene, manifest);
|
|
391
|
+
// Spawn entities.
|
|
392
|
+
const registry = attachEntityRegistry(scene);
|
|
393
|
+
const ctx = {
|
|
394
|
+
scene,
|
|
395
|
+
registry,
|
|
396
|
+
resolveAsset: (id) => resolveAsset(manifest, id),
|
|
397
|
+
resolveRenderScript: options.resolveRenderScript ??
|
|
398
|
+
((path) => resolveRenderScript(scene.game, path)),
|
|
399
|
+
};
|
|
400
|
+
for (const entity of sceneFile.entities)
|
|
401
|
+
spawnEntity(ctx, entity);
|
|
402
|
+
// Configure camera.
|
|
403
|
+
applyCamera(scene, sceneFile, registry);
|
|
404
|
+
// Slice 8: install per-frame Y-sort hook when the scene opts in. Walks
|
|
405
|
+
// the entity registry every frame and sets `sprite.depth = sprite.y` so
|
|
406
|
+
// closer-to-camera sprites overlap farther ones. Two opt-out signals
|
|
407
|
+
// (explicit transform.depth or properties.skipYSort) keep specific
|
|
408
|
+
// entities on their constant layer.
|
|
409
|
+
if (sceneFile.ySort) {
|
|
410
|
+
installYSortHook(scene, registry);
|
|
411
|
+
}
|
|
412
|
+
// Slice 6 Phase B fix — keep TilemapLayers visually anchored to their
|
|
413
|
+
// tilemap entity's container.x/y. Layers can't be Container children due
|
|
414
|
+
// to a Phaser render quirk (see createTilemap), so we sync world position
|
|
415
|
+
// every frame. **Edit-mode sync** runs from EditorOverlayScene.update()
|
|
416
|
+
// (which is always active during edit, unlike the world scene which is
|
|
417
|
+
// paused — `setActive(false)` per Phase A camera architecture suspends
|
|
418
|
+
// scene.events.UPDATE → a hook registered here wouldn't fire during edit
|
|
419
|
+
// mode, which is exactly when the user drags entities). **Play-mode
|
|
420
|
+
// sync** runs from scene.events.UPDATE (active scene fires events
|
|
421
|
+
// normally) — required for any future behavior code that moves a
|
|
422
|
+
// tilemap entity dynamically.
|
|
423
|
+
installTilemapLayerSync(scene, registry);
|
|
424
|
+
// Auto-launch the HUD scene attached to this world (slice 5). Imported
|
|
425
|
+
// lazily to avoid pulling HudRuntime into worlds that don't need it.
|
|
426
|
+
if (ref.hud) {
|
|
427
|
+
const { UMICAT_HUD_SCENE_KEY } = await import('./HudRuntime.js');
|
|
428
|
+
if (scene.scene.isActive(UMICAT_HUD_SCENE_KEY)) {
|
|
429
|
+
scene.scene.stop(UMICAT_HUD_SCENE_KEY);
|
|
430
|
+
}
|
|
431
|
+
scene.scene.launch(UMICAT_HUD_SCENE_KEY, { hudId: ref.hud });
|
|
432
|
+
}
|
|
433
|
+
return { sceneFile, registry };
|
|
434
|
+
}
|
|
435
|
+
async function loadSceneJson(scene, ref) {
|
|
436
|
+
const cacheKey = `umicat:scene:${ref.id}`;
|
|
437
|
+
if (scene.cache.json.exists(cacheKey)) {
|
|
438
|
+
return scene.cache.json.get(cacheKey);
|
|
439
|
+
}
|
|
440
|
+
scene.load.json(cacheKey, `${SCENES_BASE}${ref.file}`);
|
|
441
|
+
await runLoader(scene);
|
|
442
|
+
return scene.cache.json.get(cacheKey);
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* Phaser's `load` queue is fire-and-forget; this wraps it in a promise.
|
|
446
|
+
* If the loader is already idle and has nothing queued, resolves
|
|
447
|
+
* immediately on next tick.
|
|
448
|
+
*/
|
|
449
|
+
function runLoader(scene) {
|
|
450
|
+
return new Promise((resolve, reject) => {
|
|
451
|
+
if (!scene.load.isLoading() && scene.load.totalToLoad === 0) {
|
|
452
|
+
// Nothing to do — but `totalToLoad` resets to 0 on each `start()`
|
|
453
|
+
// so check whether anything was queued since last start.
|
|
454
|
+
if (scene.load.list.size === 0) {
|
|
455
|
+
queueMicrotask(resolve);
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
scene.load.once(Phaser.Loader.Events.COMPLETE, () => resolve());
|
|
460
|
+
scene.load.once(Phaser.Loader.Events.FILE_LOAD_ERROR, (file) => {
|
|
461
|
+
reject(new Error(`[umicat/scene] loader failed: ${file.key} (${file.url})`));
|
|
462
|
+
});
|
|
463
|
+
scene.load.start();
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
function resolveAsset(manifest, assetId) {
|
|
467
|
+
const asset = manifest.assets?.find((a) => a.id === assetId);
|
|
468
|
+
if (!asset) {
|
|
469
|
+
throw new Error(`[umicat/scene] manifest has no asset with id '${assetId}'`);
|
|
470
|
+
}
|
|
471
|
+
return asset;
|
|
472
|
+
}
|
|
473
|
+
/**
|
|
474
|
+
* Slice 8: per-frame Y-sort. Walks the entity registry and assigns
|
|
475
|
+
* `setDepth(y)` to every sprite/primitive/code-rendered entity that hasn't
|
|
476
|
+
* opted out via explicit `transform.depth` or `properties.skipYSort`.
|
|
477
|
+
*
|
|
478
|
+
* Iteration scope is the registry, not `scene.children.list` — registry
|
|
479
|
+
* holds slice-1 entities only (no editor overlay graphics, no transient
|
|
480
|
+
* Graphics drawn by behavior code). Cost is O(n) per frame where n =
|
|
481
|
+
* entity count; well within budget for a 200-entity top-down scene at
|
|
482
|
+
* 60 FPS.
|
|
483
|
+
*
|
|
484
|
+
* Opt-out priority:
|
|
485
|
+
* 1. `entity.transform.depth` is a number → that constant wins permanently;
|
|
486
|
+
* setDepth never overwrites. For "cloud always on top," "tilemap at
|
|
487
|
+
* -1000," etc.
|
|
488
|
+
* 2. `entity.properties.skipYSort: true` → behavior code manages depth;
|
|
489
|
+
* ySort doesn't touch the entity.
|
|
490
|
+
*
|
|
491
|
+
* Both checks read from the data manager (stashed by `tagGameObject` in
|
|
492
|
+
* spawnEntity.ts) to avoid round-tripping to the scene file every frame.
|
|
493
|
+
*/
|
|
494
|
+
function installYSortHook(scene, registry) {
|
|
495
|
+
scene.events.on(Phaser.Scenes.Events.UPDATE, () => {
|
|
496
|
+
for (const go of registry.all()) {
|
|
497
|
+
const transform = go.getData('entityTransform');
|
|
498
|
+
if (typeof transform?.depth === 'number')
|
|
499
|
+
continue;
|
|
500
|
+
const props = go.getData('entityProperties');
|
|
501
|
+
if (props?.skipYSort)
|
|
502
|
+
continue;
|
|
503
|
+
const target = go;
|
|
504
|
+
if (typeof target.y === 'number' && target.setDepth) {
|
|
505
|
+
target.setDepth(target.y);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* Slice 6 Phase B — per-frame sync of TilemapLayer world positions to
|
|
512
|
+
* their parent tilemap container's transform. TilemapLayer can't be a
|
|
513
|
+
* Container child due to a Phaser rendering quirk (parent transform isn't
|
|
514
|
+
* applied to tilemap render), so layers live in scene root and we manually
|
|
515
|
+
* keep their world position in step with the container's. Each layer's
|
|
516
|
+
* `tilemapLocalOffsetX/Y` stash records the centered offset relative to
|
|
517
|
+
* the container; world position = container.x/y + offset.
|
|
518
|
+
*
|
|
519
|
+
* Cheap loop — only iterates entities tagged `entityKind === 'tilemap'`.
|
|
520
|
+
* Skips silently when no layers stashed (e.g. tilemap with no tilesets).
|
|
521
|
+
*
|
|
522
|
+
* Without this: dragging a painted tilemap in the editor would visually
|
|
523
|
+
* separate the grid bounds (which DO move with the container) from the
|
|
524
|
+
* painted tiles (which would stay frozen at their initial world position).
|
|
525
|
+
*/
|
|
526
|
+
function installTilemapLayerSync(scene, registry) {
|
|
527
|
+
scene.events.on(Phaser.Scenes.Events.UPDATE, () => {
|
|
528
|
+
for (const go of registry.all()) {
|
|
529
|
+
if (go.getData('entityKind') !== 'tilemap')
|
|
530
|
+
continue;
|
|
531
|
+
const container = go;
|
|
532
|
+
const layers = go.getData('tilemapLayers');
|
|
533
|
+
if (!layers)
|
|
534
|
+
continue;
|
|
535
|
+
const containerDepth = container.depth ?? 0;
|
|
536
|
+
for (const layer of layers) {
|
|
537
|
+
const offX = layer.getData('tilemapLocalOffsetX') ?? 0;
|
|
538
|
+
const offY = layer.getData('tilemapLocalOffsetY') ?? 0;
|
|
539
|
+
const targetX = container.x + offX;
|
|
540
|
+
const targetY = container.y + offY;
|
|
541
|
+
if (layer.x !== targetX)
|
|
542
|
+
layer.x = targetX;
|
|
543
|
+
if (layer.y !== targetY)
|
|
544
|
+
layer.y = targetY;
|
|
545
|
+
// FB.10 — sync depth from container so user-driven z-order
|
|
546
|
+
// changes (Hierarchy drag → patchEntity → setDepth on container)
|
|
547
|
+
// actually move the layer's tiles. Without this, container.depth
|
|
548
|
+
// changes but layers stay at depth=0, so tilemap A "above" B
|
|
549
|
+
// doesn't actually render on top of B. Multiple layers within
|
|
550
|
+
// one tilemap share the container's depth; their relative order
|
|
551
|
+
// falls back to scene add order (= layer.z from createTilemap).
|
|
552
|
+
if (layer.depth !== containerDepth)
|
|
553
|
+
layer.setDepth(containerDepth);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
/**
|
|
559
|
+
* Apply gravity declared in scene data to the scene's Arcade physics world.
|
|
560
|
+
*
|
|
561
|
+
* Precedence: the scene's own `world.physics.gravity` wins over the
|
|
562
|
+
* manifest-wide `globals.physics.gravity`. When neither is set, the world's
|
|
563
|
+
* gravity is left untouched — whatever `createUmicatGame` (or a game.json
|
|
564
|
+
* `GameConfig` wired through it) already established.
|
|
565
|
+
*
|
|
566
|
+
* Before this, both fields were typed-but-dead: a game could declare gravity
|
|
567
|
+
* in scene data, the SDK would silently ignore it, and e.g. a platformer
|
|
568
|
+
* would run at zero gravity. Applied per scene-load so each world scene can
|
|
569
|
+
* carry its own gravity. Components are applied individually so a partial
|
|
570
|
+
* `{ y }` leaves the existing `x` (and vice versa) intact.
|
|
571
|
+
*/
|
|
572
|
+
function applyWorldGravity(scene, sceneFile, manifest) {
|
|
573
|
+
const gravity = sceneFile.world.physics?.gravity ?? manifest.globals?.physics?.gravity;
|
|
574
|
+
if (!gravity)
|
|
575
|
+
return;
|
|
576
|
+
const world = scene.physics?.world;
|
|
577
|
+
if (!world)
|
|
578
|
+
return; // Arcade physics not enabled on this scene — nothing to do.
|
|
579
|
+
if (typeof gravity.x === 'number')
|
|
580
|
+
world.gravity.x = gravity.x;
|
|
581
|
+
if (typeof gravity.y === 'number')
|
|
582
|
+
world.gravity.y = gravity.y;
|
|
583
|
+
}
|
|
584
|
+
function applyCamera(scene, sceneFile, registry) {
|
|
585
|
+
const cam = scene.cameras.main;
|
|
586
|
+
const cfg = sceneFile.camera ?? {};
|
|
587
|
+
// World bounds — default to scene world dims if not specified.
|
|
588
|
+
const bounds = cfg.bounds ?? { x: 0, y: 0, width: sceneFile.world.width, height: sceneFile.world.height };
|
|
589
|
+
cam.setBounds(bounds.x, bounds.y, bounds.width, bounds.height);
|
|
590
|
+
if (typeof cfg.zoom === 'number')
|
|
591
|
+
cam.setZoom(cfg.zoom);
|
|
592
|
+
if (cfg.pixelPerfect)
|
|
593
|
+
cam.setRoundPixels(true);
|
|
594
|
+
if (sceneFile.world.background?.color) {
|
|
595
|
+
cam.setBackgroundColor(sceneFile.world.background.color);
|
|
596
|
+
}
|
|
597
|
+
if (cfg.follow) {
|
|
598
|
+
const target = registry.byId(cfg.follow);
|
|
599
|
+
if (target) {
|
|
600
|
+
const lerp = typeof cfg.smoothing === 'number' ? cfg.smoothing : 1;
|
|
601
|
+
cam.startFollow(target, true, lerp, lerp);
|
|
602
|
+
if (cfg.deadzone)
|
|
603
|
+
cam.setDeadzone(cfg.deadzone.x * 2, cfg.deadzone.y * 2);
|
|
604
|
+
if (typeof cfg.lookahead === 'number') {
|
|
605
|
+
cam.setFollowOffset(-cfg.lookahead, 0);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
else {
|
|
609
|
+
// Soft warning — behavior code might create the follow target later.
|
|
610
|
+
// Surfacing a console.warn keeps the runtime running while flagging
|
|
611
|
+
// the data inconsistency to whoever is debugging.
|
|
612
|
+
console.warn(`[umicat/scene] camera.follow id='${cfg.follow}' has no matching entity in scene '${sceneFile.id}'`);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
}
|