@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,2608 @@
|
|
|
1
|
+
// 0.2.76 — no-op version bump to validate session-server's new
|
|
2
|
+
// "chore: update @umicat/phaser-sdk to <version>" commit message in
|
|
3
|
+
// the History panel. Refresh the browser after this lands and the
|
|
4
|
+
// commit should show the version inline. Remove this comment in the
|
|
5
|
+
// next functional patch.
|
|
6
|
+
import Phaser from 'phaser';
|
|
7
|
+
import { getEntityRegistry } from '../scene/EntityRegistry.js';
|
|
8
|
+
import { applyAssetHitbox, applyTilesetAnimations, applyTilesetTileMetadata, applyTrackedHitArea, parseColor, resetTilesetAnimationsToRoot, spawnEntity } from '../scene/spawnEntity.js';
|
|
9
|
+
import { applyAutotile, findTerrain, getAutotileKind, invalidateAutotileCells } from '../scene/autotile.js';
|
|
10
|
+
import { resolveRenderScript } from '../scene/renderScripts.js';
|
|
11
|
+
import { getManifest } from '../scene/SceneLoader.js';
|
|
12
|
+
import { getRules, patchRule } from '../scene/Rules.js';
|
|
13
|
+
import { EditorOverlayScene, EDITOR_OVERLAY_KEY } from './EditorOverlayScene.js';
|
|
14
|
+
import { getEditorState, setEditorActive, setSelection, getSelection, getEditorMode, setEditorMode, setDebugOverlayState, setTilemapToolState, } from './EditorState.js';
|
|
15
|
+
import { applyHudPatch, createHudEntityInScene, deleteHudEntityFromScene, findHudRegistry, findHudSceneFile, UMICAT_HUD_SCENE_KEY, } from '../scene/HudRuntime.js';
|
|
16
|
+
/**
|
|
17
|
+
* EditorBridge wires the host (home-ui) to the iframe's Phaser game during
|
|
18
|
+
* Edit mode. Manages:
|
|
19
|
+
*
|
|
20
|
+
* - Entering / exiting edit mode (pauses non-Boot scenes; launches the overlay)
|
|
21
|
+
* - Applying entity patches from the host (Inspector / Undo edits)
|
|
22
|
+
* - Tracking selection (host pushes; overlay reads)
|
|
23
|
+
* - Pan/zoom of the editor camera
|
|
24
|
+
* - Posting pickEntity + dragEnd back to the host
|
|
25
|
+
*
|
|
26
|
+
* Wired by `setupEditorModeListener` in EditorMode.ts.
|
|
27
|
+
*/
|
|
28
|
+
const BOOT_SCENE_KEYS = new Set(['BootScene', 'Boot']);
|
|
29
|
+
const EDITOR_LISTENER_FLAG = '__unboxyEditorBridgeListener';
|
|
30
|
+
export function setupEditorBridge(game) {
|
|
31
|
+
const flagged = game;
|
|
32
|
+
if (flagged[EDITOR_LISTENER_FLAG])
|
|
33
|
+
return;
|
|
34
|
+
flagged[EDITOR_LISTENER_FLAG] = true;
|
|
35
|
+
// Register the overlay scene class so it can be launched when needed.
|
|
36
|
+
// (Adding a class to game.scene.add() registers it without starting it.)
|
|
37
|
+
game.scene.add(EDITOR_OVERLAY_KEY, EditorOverlayScene, false);
|
|
38
|
+
window.addEventListener('message', (event) => {
|
|
39
|
+
const data = event.data;
|
|
40
|
+
if (!data || typeof data !== 'object' || typeof data.type !== 'string')
|
|
41
|
+
return;
|
|
42
|
+
if (!data.type.startsWith('umicat:editor:'))
|
|
43
|
+
return;
|
|
44
|
+
handleMessage(game, data);
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
function handleMessage(game, msg) {
|
|
48
|
+
switch (msg.type) {
|
|
49
|
+
case 'umicat:editor:enter':
|
|
50
|
+
enterEdit(game);
|
|
51
|
+
break;
|
|
52
|
+
case 'umicat:editor:exit':
|
|
53
|
+
exitEdit(game);
|
|
54
|
+
break;
|
|
55
|
+
case 'umicat:editor:getScene':
|
|
56
|
+
postSceneSnapshot(game);
|
|
57
|
+
break;
|
|
58
|
+
case 'umicat:editor:applyEdit':
|
|
59
|
+
void applyEdit(game, msg.entityId, msg.patch, msg.manifestAsset);
|
|
60
|
+
break;
|
|
61
|
+
case 'umicat:editor:setSelection':
|
|
62
|
+
setSelection(game, msg.entityIds[0] ?? null);
|
|
63
|
+
postSelectionRect(game);
|
|
64
|
+
break;
|
|
65
|
+
case 'umicat:editor:panZoom':
|
|
66
|
+
// applyEditorPanZoom now posts selectionRect itself, so no need
|
|
67
|
+
// to chain it here. RAF-coalesced anyway.
|
|
68
|
+
applyPanZoomToWorld(game, msg);
|
|
69
|
+
break;
|
|
70
|
+
case 'umicat:editor:createEntity':
|
|
71
|
+
void createEntity(game, msg.entity, msg.manifestAsset);
|
|
72
|
+
break;
|
|
73
|
+
case 'umicat:editor:deleteEntity':
|
|
74
|
+
deleteEntity(game, msg.entityId);
|
|
75
|
+
postSelectionRect(game);
|
|
76
|
+
break;
|
|
77
|
+
case 'umicat:editor:setEditMode':
|
|
78
|
+
// Slice 5 — World/HUD toggle. Mode change is purely state; the host
|
|
79
|
+
// already has both scene snapshots from enterEdit and just switches
|
|
80
|
+
// which one its UI renders. Selection may carry over between modes,
|
|
81
|
+
// but selectionRect re-posts so the ✨ button moves to the active
|
|
82
|
+
// mode's selected entity (or hides if none).
|
|
83
|
+
setEditorMode(game, msg.mode);
|
|
84
|
+
setSelection(game, null);
|
|
85
|
+
postSelectionRect(game);
|
|
86
|
+
// P1 infinite canvas — HUD scene visibility tied to mode: hidden
|
|
87
|
+
// in world edit (so it doesn't float wrong over pan/zoom view),
|
|
88
|
+
// shown in HUD edit (since that's the editing surface).
|
|
89
|
+
setHudVisibility(game, msg.mode === 'hud');
|
|
90
|
+
break;
|
|
91
|
+
case 'umicat:editor:assetUpdate':
|
|
92
|
+
// Slice 8 — Asset-level live update for hitbox / depthAnchor edits.
|
|
93
|
+
void handleAssetUpdate(game, msg.asset);
|
|
94
|
+
break;
|
|
95
|
+
case 'umicat:editor:setDebugOverlay':
|
|
96
|
+
// Slice 8 — "Show hitboxes" debug overlay toggle.
|
|
97
|
+
setDebugOverlay(game, { showHitboxes: msg.showHitboxes });
|
|
98
|
+
break;
|
|
99
|
+
case 'umicat:editor:editPrefab':
|
|
100
|
+
// Slice 11 Phase B.5 / editor P0.2 — prefab live-edit.
|
|
101
|
+
handleEditPrefab(game, msg.prefabId, msg.patch);
|
|
102
|
+
break;
|
|
103
|
+
case 'umicat:editor:patchRule':
|
|
104
|
+
// Slice 11 Phase B.5 / editor P0.3 — rule live-edit.
|
|
105
|
+
handlePatchRule(game, msg.path, msg.value);
|
|
106
|
+
break;
|
|
107
|
+
case 'umicat:editor:setTilemapTool':
|
|
108
|
+
// Slice 6 Phase B — tilemap painter active state push.
|
|
109
|
+
handleSetTilemapTool(game, msg);
|
|
110
|
+
break;
|
|
111
|
+
case 'umicat:editor:editTilemap':
|
|
112
|
+
// Slice 6 Phase B — host-driven tilemap mutation (agent writes,
|
|
113
|
+
// undo/redo replay, addLayer with new tileset). Live painter
|
|
114
|
+
// strokes go SDK→host via `tilemapEdited` instead — this path is
|
|
115
|
+
// for everything else. handleEditTilemap is async (awaits asset
|
|
116
|
+
// load when manifestAsset present); fire-and-forget the promise.
|
|
117
|
+
void handleEditTilemap(game, msg.entityId, msg.ops, msg.manifestAsset);
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// --- Enter / exit ---------------------------------------------------------
|
|
122
|
+
function enterEdit(game) {
|
|
123
|
+
// Brute-force idempotent enter (2026-05-17): edit→play→edit was still
|
|
124
|
+
// breaking after the conditional-cleanup fix in 0.2.79 — the cleanup
|
|
125
|
+
// only ran when `state.active` was already true. But the bug also
|
|
126
|
+
// manifests when state is false but Phaser's scale.scaleMode /
|
|
127
|
+
// gameSize / camera viewports got out of sync due to the previous
|
|
128
|
+
// edit/play cycle. Solution: ALWAYS run uninstall + restore first,
|
|
129
|
+
// unconditionally — they're idempotent no-ops when there's no stale
|
|
130
|
+
// state, and they reset Phaser to a clean baseline when there IS
|
|
131
|
+
// stale state. Net cost: a few cheap getter/setter calls per enter.
|
|
132
|
+
uninstallVoidFill(game);
|
|
133
|
+
uninstallEditorCameras(game);
|
|
134
|
+
restoreCanvasAfterEditor(game);
|
|
135
|
+
setEditorActive(game, true);
|
|
136
|
+
pauseActiveNonEditor(game);
|
|
137
|
+
// P1 infinite canvas — expand the Phaser canvas to fill its container
|
|
138
|
+
// (host's iframe). Without this, the editor surface stays locked to
|
|
139
|
+
// the game's intrinsic aspect ratio (720×1280 portrait, etc.) with
|
|
140
|
+
// letterbox bars around it that the user can't pan into — defeats the
|
|
141
|
+
// "whole iframe = world map" mental model. Switches scale mode to
|
|
142
|
+
// RESIZE; canvas grows; editor cam viewport tracks the new canvas size.
|
|
143
|
+
// Game's logical world stays at its declared size; editor just has
|
|
144
|
+
// more "screen area" to show the world + void around it.
|
|
145
|
+
expandCanvasForEditor(game);
|
|
146
|
+
// P1 infinite canvas (correct architecture, 2026-05-17): editor view
|
|
147
|
+
// is a SEPARATE camera over the world, NOT the game's runtime camera.
|
|
148
|
+
// Matches Godot 2D editor's "editor viewport vs Camera2D" split.
|
|
149
|
+
installEditorCameras(game);
|
|
150
|
+
// Add the "editor void" grey fill into the world scene at very low
|
|
151
|
+
// depth so it renders BELOW entities (drag-to-place outside world
|
|
152
|
+
// bounds now shows the sprite instead of being covered by overlay
|
|
153
|
+
// grey). Runs after install so we know which world scene + which
|
|
154
|
+
// editor cam are active.
|
|
155
|
+
installVoidFill(game);
|
|
156
|
+
// Slice 6 Phase B — show tilemap grid sketches (hidden by default in
|
|
157
|
+
// Play mode). `setTilemapGridsVisible` walks every tilemap container
|
|
158
|
+
// in the registry and toggles its grid child. Called again on exitEdit
|
|
159
|
+
// with `false`.
|
|
160
|
+
setTilemapGridsVisible(game, true);
|
|
161
|
+
// Slice 6 Phase F — reset animated tiles to their root indices. Scenes
|
|
162
|
+
// are paused via `setActive(false)` in edit mode → animation UPDATE
|
|
163
|
+
// handlers don't fire → cells freeze on whatever frame was current.
|
|
164
|
+
// Reset so the editor shows the static authored frame (matches
|
|
165
|
+
// layer.data). exitEdit doesn't need a paired re-arm — the next UPDATE
|
|
166
|
+
// tick after scene resume re-runs `applyTilesetAnimations` cleanup-
|
|
167
|
+
// and-rebuild via the post-paint metadata re-application path.
|
|
168
|
+
resetAllTilesetAnimations(game);
|
|
169
|
+
// World edit mode hides HUD scene — HUD widgets are canvas-relative
|
|
170
|
+
// (anchor-positioned to edges) and would visually float wrong when the
|
|
171
|
+
// editor pans/zooms the world view. HUD edit mode shows it normally
|
|
172
|
+
// and treats HUD as the editing surface. Toggle in setEditorMode handler.
|
|
173
|
+
setHudVisibility(game, getEditorMode(game) === 'hud');
|
|
174
|
+
// Launch the overlay AFTER pausing world scenes so it sits on top in render
|
|
175
|
+
// order (Phaser renders scenes in the order they were started).
|
|
176
|
+
const overlayInit = buildOverlayInit(game);
|
|
177
|
+
if (!game.scene.isActive(EDITOR_OVERLAY_KEY)) {
|
|
178
|
+
game.scene.run(EDITOR_OVERLAY_KEY, overlayInit);
|
|
179
|
+
}
|
|
180
|
+
// Send the initial scene snapshot so home-ui can populate Hierarchy /
|
|
181
|
+
// Inspector without a separate getScene round-trip.
|
|
182
|
+
postSceneSnapshot(game);
|
|
183
|
+
// Race-window catch: when home-ui sends `enter` right after iframe load
|
|
184
|
+
// (which is exactly the auto-flush rebuild path), BootScene may still be
|
|
185
|
+
// in preload / GameScene may not have started, so the synchronous pause
|
|
186
|
+
// above had nothing to pause and the snapshot found no scene file in
|
|
187
|
+
// cache yet. Re-attempt for ~3 seconds. Each attempt is cheap and stops
|
|
188
|
+
// automatically once the user exits edit mode.
|
|
189
|
+
let attempts = 0;
|
|
190
|
+
const reattempt = () => {
|
|
191
|
+
if (!getEditorState(game).active)
|
|
192
|
+
return;
|
|
193
|
+
pauseActiveNonEditor(game);
|
|
194
|
+
if (!hasPostedSnapshot(game))
|
|
195
|
+
postSceneSnapshot(game);
|
|
196
|
+
attempts += 1;
|
|
197
|
+
if (attempts < 30)
|
|
198
|
+
setTimeout(reattempt, 100);
|
|
199
|
+
};
|
|
200
|
+
setTimeout(reattempt, 100);
|
|
201
|
+
}
|
|
202
|
+
function pauseActiveNonEditor(game) {
|
|
203
|
+
// P1 infinite canvas (2026-05-17) — use `setActive(false)` instead of
|
|
204
|
+
// `pause()` so scenes STOP updating (game logic frozen) but KEEP
|
|
205
|
+
// rendering. `pause()` halts both — which makes the editor camera blind
|
|
206
|
+
// to entity positions because the world scene's renderer is also paused.
|
|
207
|
+
// We need render to continue so editor cam pan/zoom can show the entities
|
|
208
|
+
// at correctly-transformed positions.
|
|
209
|
+
//
|
|
210
|
+
// Pair this with `physics.pause()` + `tweens.pauseAll()` + `time.paused`
|
|
211
|
+
// because setActive(false) on its own doesn't halt those — the Arcade
|
|
212
|
+
// body would keep stepping if untouched, entities animate during edit,
|
|
213
|
+
// etc.
|
|
214
|
+
for (const scene of game.scene.getScenes(true)) {
|
|
215
|
+
const key = scene.scene.key;
|
|
216
|
+
if (BOOT_SCENE_KEYS.has(key))
|
|
217
|
+
continue;
|
|
218
|
+
if (key === EDITOR_OVERLAY_KEY)
|
|
219
|
+
continue;
|
|
220
|
+
if (!scene.scene.isActive())
|
|
221
|
+
continue;
|
|
222
|
+
scene.scene.setActive(false);
|
|
223
|
+
if (scene.physics)
|
|
224
|
+
scene.physics.pause();
|
|
225
|
+
scene.tweens.pauseAll();
|
|
226
|
+
scene.time.paused = true;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Inverse of `pauseActiveNonEditor` — resumes update + physics + tweens +
|
|
231
|
+
* timers on every scene we paused. Called in `exitEdit`.
|
|
232
|
+
*/
|
|
233
|
+
function resumeActiveNonEditor(game) {
|
|
234
|
+
for (const scene of game.scene.getScenes(false)) {
|
|
235
|
+
const key = scene.scene.key;
|
|
236
|
+
if (BOOT_SCENE_KEYS.has(key))
|
|
237
|
+
continue;
|
|
238
|
+
if (key === EDITOR_OVERLAY_KEY)
|
|
239
|
+
continue;
|
|
240
|
+
if (scene.scene.isActive())
|
|
241
|
+
continue;
|
|
242
|
+
scene.scene.setActive(true);
|
|
243
|
+
if (scene.physics)
|
|
244
|
+
scene.physics.resume();
|
|
245
|
+
scene.tweens.resumeAll();
|
|
246
|
+
scene.time.paused = false;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
function exitEdit(game) {
|
|
250
|
+
if (!getEditorState(game).active)
|
|
251
|
+
return;
|
|
252
|
+
setEditorActive(game, false);
|
|
253
|
+
setSelection(game, null);
|
|
254
|
+
// Allow next enter to re-post the snapshot (scene file may have changed).
|
|
255
|
+
delete game[SNAPSHOT_POSTED_FLAG];
|
|
256
|
+
if (game.scene.isActive(EDITOR_OVERLAY_KEY)) {
|
|
257
|
+
game.scene.stop(EDITOR_OVERLAY_KEY);
|
|
258
|
+
}
|
|
259
|
+
// P1 infinite canvas — tear down editor cameras + void fill + restore
|
|
260
|
+
// game cam visibility + restore canvas scale + restore HUD scene
|
|
261
|
+
// visibility BEFORE resuming scenes so the first resumed frame
|
|
262
|
+
// renders through the game's real cameras at the game's intended
|
|
263
|
+
// canvas size.
|
|
264
|
+
uninstallVoidFill(game);
|
|
265
|
+
setTilemapGridsVisible(game, false);
|
|
266
|
+
uninstallEditorCameras(game);
|
|
267
|
+
restoreCanvasAfterEditor(game);
|
|
268
|
+
setHudVisibility(game, true);
|
|
269
|
+
resumeActiveNonEditor(game);
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Slice 6 Phase B — toggle the tilemap entities' grid sketch visibility.
|
|
273
|
+
* The grid is an editor-only visualization aid (helps users see tile
|
|
274
|
+
* boundaries on empty / sparsely-painted tilemaps); it bleeds into Play
|
|
275
|
+
* mode if not toggled off. Walks every tilemap container in the world
|
|
276
|
+
* scene's registry and finds the tagged grid Graphics child.
|
|
277
|
+
*/
|
|
278
|
+
function setTilemapGridsVisible(game, visible) {
|
|
279
|
+
const scene = findWorldScene(game);
|
|
280
|
+
if (!scene)
|
|
281
|
+
return;
|
|
282
|
+
const registry = getEntityRegistry(scene);
|
|
283
|
+
if (!registry)
|
|
284
|
+
return;
|
|
285
|
+
for (const go of registry.all()) {
|
|
286
|
+
if (go.getData('entityKind') !== 'tilemap')
|
|
287
|
+
continue;
|
|
288
|
+
const container = go;
|
|
289
|
+
for (const child of container.list) {
|
|
290
|
+
if (child.getData?.('unboxyTilemapGrid')) {
|
|
291
|
+
child.setVisible(visible);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Phase F — reset every animated tile to its root index across all
|
|
298
|
+
* tilemap entities in the active world scene. Called by `enterEdit` so
|
|
299
|
+
* the editor view always shows the authored (static) frame instead of
|
|
300
|
+
* whatever was currently visible when the user toggled into edit mode.
|
|
301
|
+
*
|
|
302
|
+
* Doesn't uninstall the per-frame UPDATE handler — scene pause prevents
|
|
303
|
+
* it from firing, and exitEdit lets it resume naturally on the next
|
|
304
|
+
* tick (cells re-swap from elapsed-time math).
|
|
305
|
+
*/
|
|
306
|
+
function resetAllTilesetAnimations(game) {
|
|
307
|
+
const scene = findWorldScene(game);
|
|
308
|
+
if (!scene)
|
|
309
|
+
return;
|
|
310
|
+
const registry = getEntityRegistry(scene);
|
|
311
|
+
if (!registry)
|
|
312
|
+
return;
|
|
313
|
+
for (const go of registry.all()) {
|
|
314
|
+
if (go.getData('entityKind') !== 'tilemap')
|
|
315
|
+
continue;
|
|
316
|
+
resetTilesetAnimationsToRoot(go);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
function buildOverlayInit(game) {
|
|
320
|
+
const sceneFile = readActiveSceneFile(game);
|
|
321
|
+
// Fallback worldBounds for pure scene-as-code games (no scene file).
|
|
322
|
+
// Use the snapshot's intrinsic game dims so the white outline +
|
|
323
|
+
// selection math still work (outline at (0, 0, gameW, gameH)).
|
|
324
|
+
const snap = game[CANVAS_SCALE_SNAPSHOT_KEY];
|
|
325
|
+
const wbW = sceneFile?.world.width ?? snap?.gameWidth;
|
|
326
|
+
const wbH = sceneFile?.world.height ?? snap?.gameHeight;
|
|
327
|
+
return {
|
|
328
|
+
worldBounds: wbW != null && wbH != null
|
|
329
|
+
? { x: 0, y: 0, width: wbW, height: wbH }
|
|
330
|
+
: undefined,
|
|
331
|
+
hitTest: (worldX, worldY) => hitTest(game, worldX, worldY),
|
|
332
|
+
hitTestAll: (worldX, worldY) => hitTestAll(game, worldX, worldY),
|
|
333
|
+
postPick: (entityId, modifiers, prefabId) => postToHost({
|
|
334
|
+
type: 'umicat:editor:pickEntity',
|
|
335
|
+
entityId,
|
|
336
|
+
modifiers,
|
|
337
|
+
prefabId,
|
|
338
|
+
}),
|
|
339
|
+
postDragEnd: (entityId, before, after) => postToHost({
|
|
340
|
+
type: 'umicat:editor:dragEnd',
|
|
341
|
+
entityId,
|
|
342
|
+
before,
|
|
343
|
+
after,
|
|
344
|
+
}),
|
|
345
|
+
postShortcut: (action) => postToHost({ type: 'umicat:editor:shortcut', action }),
|
|
346
|
+
// P1 infinite canvas — route in-canvas pan/zoom through the same
|
|
347
|
+
// function host-driven `umicat:editor:panZoom` uses, so cap + mirror
|
|
348
|
+
// logic stays in one place.
|
|
349
|
+
applyPanZoom: (msg) => applyPanZoomToWorld(game, msg),
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
// --- Snapshot -------------------------------------------------------------
|
|
353
|
+
const SNAPSHOT_POSTED_FLAG = '__unboxyEditorSnapshotPostedFor';
|
|
354
|
+
function postSceneSnapshot(game) {
|
|
355
|
+
const manifest = readActiveManifest(game);
|
|
356
|
+
const sceneFile = readActiveSceneFile(game);
|
|
357
|
+
if (sceneFile) {
|
|
358
|
+
postToHost({
|
|
359
|
+
type: 'umicat:editor:sceneLoaded',
|
|
360
|
+
sceneId: sceneFile.id,
|
|
361
|
+
mode: 'world',
|
|
362
|
+
sceneFile,
|
|
363
|
+
manifest,
|
|
364
|
+
});
|
|
365
|
+
game[SNAPSHOT_POSTED_FLAG] = sceneFile.id;
|
|
366
|
+
}
|
|
367
|
+
// Also post the HUD scene if attached + loaded. The host stashes both
|
|
368
|
+
// snapshots so the World/HUD toggle is purely a UI switch — no fresh
|
|
369
|
+
// SDK round-trip needed when the user flips it.
|
|
370
|
+
const hudFile = findHudSceneFile(game);
|
|
371
|
+
if (hudFile) {
|
|
372
|
+
postToHost({
|
|
373
|
+
type: 'umicat:editor:sceneLoaded',
|
|
374
|
+
sceneId: hudFile.id,
|
|
375
|
+
mode: 'hud',
|
|
376
|
+
sceneFile: hudFile,
|
|
377
|
+
manifest,
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
// P0.3 — also post the rules snapshot so the Rules panel can render
|
|
381
|
+
// without a separate fetch. Rules live on `scene.cache.json` (one tree
|
|
382
|
+
// per game, not per-scene), populated by `preloadRules` in BootScene.
|
|
383
|
+
// Empty `{}` is the valid "game has no rules.json" state.
|
|
384
|
+
postRulesSnapshot(game);
|
|
385
|
+
// P1 infinite canvas — initial camera state so host renders the zoom
|
|
386
|
+
// indicator at the right value from the moment Edit opens.
|
|
387
|
+
postCameraState(game);
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Post the rules snapshot once, finding any non-Boot scene with a cached
|
|
391
|
+
* rules tree. The host stashes this as its baseline for the Rules form.
|
|
392
|
+
*
|
|
393
|
+
* P0.3 — `umicat:editor:rulesLoaded`. Tolerates a game with no rules at
|
|
394
|
+
* all (posts `{}`).
|
|
395
|
+
*/
|
|
396
|
+
function postRulesSnapshot(game) {
|
|
397
|
+
const scene = findWorldScene(game);
|
|
398
|
+
if (!scene)
|
|
399
|
+
return;
|
|
400
|
+
const rules = getRules(scene);
|
|
401
|
+
postToHost({ type: 'umicat:editor:rulesLoaded', rules });
|
|
402
|
+
}
|
|
403
|
+
// --- Selection rect (slice 4) ---------------------------------------------
|
|
404
|
+
//
|
|
405
|
+
// Emit the selected entity's DOM screen rect to the host so it can anchor
|
|
406
|
+
// the ✨ button + popover next to the entity. Coalesce multiple calls
|
|
407
|
+
// inside one RAF tick — Inspector spam (per-keystroke transform edits)
|
|
408
|
+
// would otherwise spam the postMessage channel.
|
|
409
|
+
const RECT_RAF_FLAG = '__unboxyEditorSelectionRectRafScheduled';
|
|
410
|
+
function postSelectionRect(game) {
|
|
411
|
+
const bag = game;
|
|
412
|
+
if (bag[RECT_RAF_FLAG])
|
|
413
|
+
return;
|
|
414
|
+
bag[RECT_RAF_FLAG] = true;
|
|
415
|
+
// requestAnimationFrame runs after Phaser's render → bounds reflect the
|
|
416
|
+
// freshly-applied transform. Guarantees one emit per frame max.
|
|
417
|
+
if (typeof requestAnimationFrame === 'function') {
|
|
418
|
+
requestAnimationFrame(() => {
|
|
419
|
+
bag[RECT_RAF_FLAG] = false;
|
|
420
|
+
doPostSelectionRect(game);
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
else {
|
|
424
|
+
bag[RECT_RAF_FLAG] = false;
|
|
425
|
+
doPostSelectionRect(game);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
function doPostSelectionRect(game) {
|
|
429
|
+
const id = getSelection(game);
|
|
430
|
+
if (!id) {
|
|
431
|
+
postToHost({ type: 'umicat:editor:selectionRect', entityId: null, rect: null });
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
const registry = findRegistry(game);
|
|
435
|
+
const go = registry?.byId(id);
|
|
436
|
+
if (!go) {
|
|
437
|
+
postToHost({ type: 'umicat:editor:selectionRect', entityId: id, rect: null });
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
const rect = computeScreenRect(game, go);
|
|
441
|
+
postToHost({ type: 'umicat:editor:selectionRect', entityId: id, rect });
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* Compute the entity's DOM screen rect — viewport pixels relative to the
|
|
445
|
+
* iframe's top-left. The host adds the iframe's bounding rect to translate
|
|
446
|
+
* into page coords.
|
|
447
|
+
*
|
|
448
|
+
* Path: world coords → camera transform → canvas pixels → iframe pixels.
|
|
449
|
+
* Phaser's Scale.FIT may letterbox/pillarbox the canvas inside the iframe,
|
|
450
|
+
* so we factor in the canvas's parent offset too.
|
|
451
|
+
*/
|
|
452
|
+
function computeScreenRect(game, go) {
|
|
453
|
+
// Pull entity-local bounds — same logic the editor uses for hit-test.
|
|
454
|
+
const hitW = go.getData('editorHitWidth');
|
|
455
|
+
const hitH = go.getData('editorHitHeight');
|
|
456
|
+
const positioned = go;
|
|
457
|
+
let entRect;
|
|
458
|
+
const liveBounds = readLiveTrackedBounds(go);
|
|
459
|
+
if (liveBounds) {
|
|
460
|
+
entRect = {
|
|
461
|
+
x: positioned.x + liveBounds.x,
|
|
462
|
+
y: positioned.y + liveBounds.y,
|
|
463
|
+
width: liveBounds.width,
|
|
464
|
+
height: liveBounds.height,
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
else if (typeof hitW === 'number' && typeof hitH === 'number') {
|
|
468
|
+
const offX = go.getData('editorHitOffsetX');
|
|
469
|
+
const offY = go.getData('editorHitOffsetY');
|
|
470
|
+
if (typeof offX === 'number' && typeof offY === 'number') {
|
|
471
|
+
entRect = {
|
|
472
|
+
x: positioned.x + offX,
|
|
473
|
+
y: positioned.y + offY,
|
|
474
|
+
width: hitW,
|
|
475
|
+
height: hitH,
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
else {
|
|
479
|
+
entRect = {
|
|
480
|
+
x: positioned.x - hitW / 2,
|
|
481
|
+
y: positioned.y - hitH / 2,
|
|
482
|
+
width: hitW,
|
|
483
|
+
height: hitH,
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
else {
|
|
488
|
+
const withBounds = go;
|
|
489
|
+
if (typeof withBounds.getBounds !== 'function')
|
|
490
|
+
return null;
|
|
491
|
+
const r = withBounds.getBounds();
|
|
492
|
+
entRect = { x: r.x, y: r.y, width: r.width, height: r.height };
|
|
493
|
+
}
|
|
494
|
+
// HUD mode — entity coords are already in canvas-pixel space (the HUD
|
|
495
|
+
// scene has identity camera). Skip the world→canvas camera transform.
|
|
496
|
+
let canvasX, canvasY, canvasW, canvasH;
|
|
497
|
+
if (getEditorMode(game) === 'hud') {
|
|
498
|
+
canvasX = entRect.x;
|
|
499
|
+
canvasY = entRect.y;
|
|
500
|
+
canvasW = entRect.width;
|
|
501
|
+
canvasH = entRect.height;
|
|
502
|
+
}
|
|
503
|
+
else {
|
|
504
|
+
// World mode — convert via the EDITOR camera (P1 infinite canvas).
|
|
505
|
+
// `cameras.main` is hidden during edit and its scroll/zoom never
|
|
506
|
+
// change while the user pans/zooms the editor — using it here pins
|
|
507
|
+
// the popover anchor to (0,0)/zoom=1 regardless of editor view,
|
|
508
|
+
// which is why the AI sparkle button only landed correctly at 100%
|
|
509
|
+
// / scroll(0,0). Editor cam IS what the user is looking through;
|
|
510
|
+
// its scroll/zoom track pan + pinch in real time, so popover
|
|
511
|
+
// follows the entity correctly. Falls back to cameras.main on the
|
|
512
|
+
// narrow boot-race window before editor cam is installed.
|
|
513
|
+
const cam = getEditorCamera(game) ?? (() => {
|
|
514
|
+
for (const scene of game.scene.getScenes(false)) {
|
|
515
|
+
const key = scene.scene.key;
|
|
516
|
+
if (BOOT_SCENE_KEYS.has(key))
|
|
517
|
+
continue;
|
|
518
|
+
if (key === EDITOR_OVERLAY_KEY)
|
|
519
|
+
continue;
|
|
520
|
+
if (key === UMICAT_HUD_SCENE_KEY)
|
|
521
|
+
continue;
|
|
522
|
+
if (getEntityRegistry(scene))
|
|
523
|
+
return scene.cameras.main;
|
|
524
|
+
}
|
|
525
|
+
return null;
|
|
526
|
+
})();
|
|
527
|
+
if (!cam)
|
|
528
|
+
return null;
|
|
529
|
+
// Include cam.x/cam.y — Phaser cam math is
|
|
530
|
+
// canvas_pixel = (world - scroll) * zoom + viewport_offset
|
|
531
|
+
// The previous formula dropped `viewport_offset`, which is fine
|
|
532
|
+
// when setViewport(0, 0, ...) but breaks if any code path shifts
|
|
533
|
+
// the viewport. Defense-in-depth: read what's actually there.
|
|
534
|
+
canvasX = (entRect.x - cam.scrollX) * cam.zoom + cam.x;
|
|
535
|
+
canvasY = (entRect.y - cam.scrollY) * cam.zoom + cam.y;
|
|
536
|
+
canvasW = entRect.width * cam.zoom;
|
|
537
|
+
canvasH = entRect.height * cam.zoom;
|
|
538
|
+
}
|
|
539
|
+
// Convert logical canvas pixels → CSS pixels using the canvas's actual
|
|
540
|
+
// displayed size (avoids depending on which direction Phaser's displayScale
|
|
541
|
+
// goes — we just measure it). Canvas may be letterboxed/pillarboxed inside
|
|
542
|
+
// the iframe under Scale.FIT; getBoundingClientRect gives both the size
|
|
543
|
+
// and the offset.
|
|
544
|
+
const canvas = game.canvas;
|
|
545
|
+
if (!canvas || typeof canvas.getBoundingClientRect !== 'function')
|
|
546
|
+
return null;
|
|
547
|
+
const cssRect = canvas.getBoundingClientRect();
|
|
548
|
+
const logicalW = game.scale.width;
|
|
549
|
+
const logicalH = game.scale.height;
|
|
550
|
+
if (!logicalW || !logicalH)
|
|
551
|
+
return null;
|
|
552
|
+
const sx = cssRect.width / logicalW;
|
|
553
|
+
const sy = cssRect.height / logicalH;
|
|
554
|
+
return {
|
|
555
|
+
x: cssRect.left + canvasX * sx,
|
|
556
|
+
y: cssRect.top + canvasY * sy,
|
|
557
|
+
width: canvasW * sx,
|
|
558
|
+
height: canvasH * sy,
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
function readActiveManifest(game) {
|
|
562
|
+
for (const scene of game.scene.getScenes(false)) {
|
|
563
|
+
const cache = scene.cache.json;
|
|
564
|
+
const m = cache.entries?.entries?.['umicat:manifest'];
|
|
565
|
+
if (m)
|
|
566
|
+
return m;
|
|
567
|
+
}
|
|
568
|
+
return undefined;
|
|
569
|
+
}
|
|
570
|
+
function hasPostedSnapshot(game) {
|
|
571
|
+
return !!game[SNAPSHOT_POSTED_FLAG];
|
|
572
|
+
}
|
|
573
|
+
function readActiveSceneFile(game) {
|
|
574
|
+
// The active world scene cached its scene file in Phaser's JSON cache via
|
|
575
|
+
// SceneLoader. Pull it from there. We pick the first non-Boot scene we
|
|
576
|
+
// find with a JSON entry matching `umicat:scene:<id>`.
|
|
577
|
+
for (const scene of game.scene.getScenes(false)) {
|
|
578
|
+
const key = scene.scene.key;
|
|
579
|
+
if (BOOT_SCENE_KEYS.has(key))
|
|
580
|
+
continue;
|
|
581
|
+
if (key === EDITOR_OVERLAY_KEY)
|
|
582
|
+
continue;
|
|
583
|
+
// Scan cache for any 'umicat:scene:*' entry. There's typically one.
|
|
584
|
+
const cache = scene.cache.json;
|
|
585
|
+
const entries = cache.entries?.entries ?? {};
|
|
586
|
+
for (const cacheKey of Object.keys(entries)) {
|
|
587
|
+
if (cacheKey.startsWith('umicat:scene:')) {
|
|
588
|
+
const candidate = entries[cacheKey];
|
|
589
|
+
if (isWorldScene(candidate))
|
|
590
|
+
return candidate;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
return null;
|
|
595
|
+
}
|
|
596
|
+
function isWorldScene(v) {
|
|
597
|
+
return !!v && typeof v === 'object' && v.type === 'world';
|
|
598
|
+
}
|
|
599
|
+
// --- Hit test -------------------------------------------------------------
|
|
600
|
+
function hitTest(game, worldX, worldY) {
|
|
601
|
+
const registry = findRegistry(game);
|
|
602
|
+
if (!registry)
|
|
603
|
+
return null;
|
|
604
|
+
let topmost = null;
|
|
605
|
+
let topmostDepth = -Infinity;
|
|
606
|
+
for (const go of registry.all()) {
|
|
607
|
+
const r = entityHitRect(go);
|
|
608
|
+
if (!r)
|
|
609
|
+
continue;
|
|
610
|
+
if (worldX < r.x || worldX > r.x + r.width)
|
|
611
|
+
continue;
|
|
612
|
+
if (worldY < r.y || worldY > r.y + r.height)
|
|
613
|
+
continue;
|
|
614
|
+
const withDepth = go;
|
|
615
|
+
const depth = typeof withDepth.depth === 'number' ? withDepth.depth : 0;
|
|
616
|
+
if (depth >= topmostDepth) {
|
|
617
|
+
topmostDepth = depth;
|
|
618
|
+
topmost = go;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
return topmost;
|
|
622
|
+
}
|
|
623
|
+
/**
|
|
624
|
+
* FB.9a — return ALL entities at the given world coords, sorted by depth
|
|
625
|
+
* DESC (topmost first). Used by EditorOverlayScene's Alt+click handler
|
|
626
|
+
* to cycle through overlapping entities. When no Alt held, the regular
|
|
627
|
+
* hitTest (returning just topmost) handles normal selection.
|
|
628
|
+
*/
|
|
629
|
+
export function hitTestAll(game, worldX, worldY) {
|
|
630
|
+
const registry = findRegistry(game);
|
|
631
|
+
if (!registry)
|
|
632
|
+
return [];
|
|
633
|
+
const hits = [];
|
|
634
|
+
for (const go of registry.all()) {
|
|
635
|
+
const r = entityHitRect(go);
|
|
636
|
+
if (!r)
|
|
637
|
+
continue;
|
|
638
|
+
if (worldX < r.x || worldX > r.x + r.width)
|
|
639
|
+
continue;
|
|
640
|
+
if (worldY < r.y || worldY > r.y + r.height)
|
|
641
|
+
continue;
|
|
642
|
+
const withDepth = go;
|
|
643
|
+
const depth = typeof withDepth.depth === 'number' ? withDepth.depth : 0;
|
|
644
|
+
hits.push({ go, depth });
|
|
645
|
+
}
|
|
646
|
+
hits.sort((a, b) => b.depth - a.depth);
|
|
647
|
+
return hits.map((h) => h.go);
|
|
648
|
+
}
|
|
649
|
+
/**
|
|
650
|
+
* Compute a hit-test rectangle for a spawned entity in world coords.
|
|
651
|
+
*
|
|
652
|
+
* 1. If the entity has `editorHitWidth` / `editorHitHeight` set in data
|
|
653
|
+
* (code-rendered entities — Graphics has no intrinsic bounds), use
|
|
654
|
+
* those centered on the entity's x/y.
|
|
655
|
+
* 2. Otherwise fall back to Phaser's `getBounds()` (works for Sprite,
|
|
656
|
+
* Rectangle, Arc, Container — anything with a Size component).
|
|
657
|
+
*
|
|
658
|
+
* Returns null if neither path yields a usable rect.
|
|
659
|
+
*/
|
|
660
|
+
function entityHitRect(go) {
|
|
661
|
+
// Live tracker query (0.2.97) — for code-rendered entities, the
|
|
662
|
+
// Graphics tracker's getter is stashed on data and reflects the
|
|
663
|
+
// LATEST draw extent (after every redraw the game tick triggered).
|
|
664
|
+
// Without this lookup, hit-test used stale data set once at spawn
|
|
665
|
+
// — fine for static visuals but wrong for procedural ones (snake
|
|
666
|
+
// growing, tilemap painting, dynamic UI). The getter is cheap
|
|
667
|
+
// (just reads 4 numbers), so calling it per hit-test is fine.
|
|
668
|
+
const positioned = go;
|
|
669
|
+
const liveBounds = readLiveTrackedBounds(go);
|
|
670
|
+
if (liveBounds) {
|
|
671
|
+
return {
|
|
672
|
+
x: positioned.x + liveBounds.x,
|
|
673
|
+
y: positioned.y + liveBounds.y,
|
|
674
|
+
width: liveBounds.width,
|
|
675
|
+
height: liveBounds.height,
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
const hitW = go.getData('editorHitWidth');
|
|
679
|
+
const hitH = go.getData('editorHitHeight');
|
|
680
|
+
if (typeof hitW === 'number' && typeof hitH === 'number') {
|
|
681
|
+
// editorHitOffsetX/Y (0.2.96) — relative top-left of the drawn
|
|
682
|
+
// area when offset is known (set by `applyTrackedHitArea` at
|
|
683
|
+
// spawn). Falls back to "centered on transform" for legacy
|
|
684
|
+
// entities (sprite/primitive/hud widgets that don't run through
|
|
685
|
+
// the tracker).
|
|
686
|
+
const offX = go.getData('editorHitOffsetX');
|
|
687
|
+
const offY = go.getData('editorHitOffsetY');
|
|
688
|
+
if (typeof offX === 'number' && typeof offY === 'number') {
|
|
689
|
+
return {
|
|
690
|
+
x: positioned.x + offX,
|
|
691
|
+
y: positioned.y + offY,
|
|
692
|
+
width: hitW,
|
|
693
|
+
height: hitH,
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
return {
|
|
697
|
+
x: positioned.x - hitW / 2,
|
|
698
|
+
y: positioned.y - hitH / 2,
|
|
699
|
+
width: hitW,
|
|
700
|
+
height: hitH,
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
const withBounds = go;
|
|
704
|
+
if (typeof withBounds.getBounds !== 'function')
|
|
705
|
+
return null;
|
|
706
|
+
const r = withBounds.getBounds();
|
|
707
|
+
if (r.width === 0 && r.height === 0)
|
|
708
|
+
return null;
|
|
709
|
+
return { x: r.x, y: r.y, width: r.width, height: r.height };
|
|
710
|
+
}
|
|
711
|
+
/**
|
|
712
|
+
* Read live bounds from the Graphics tracker getter stashed on the GO's
|
|
713
|
+
* data manager by `createCodeRendered` (0.2.96). Returns null when the
|
|
714
|
+
* entity isn't code-rendered (no getter stashed) or when nothing has
|
|
715
|
+
* been drawn since the last `clear()`. Used by hit-test + selection
|
|
716
|
+
* rect + popover anchor so they all see the most recent draw extent.
|
|
717
|
+
*/
|
|
718
|
+
function readLiveTrackedBounds(go) {
|
|
719
|
+
const getter = go.getData('__unboxyGraphicsBoundsGetter');
|
|
720
|
+
if (typeof getter !== 'function')
|
|
721
|
+
return null;
|
|
722
|
+
return getter();
|
|
723
|
+
}
|
|
724
|
+
/**
|
|
725
|
+
* Find the entity registry for the current editor mode. World mode picks the
|
|
726
|
+
* first non-Boot non-Editor scene with a registry; HUD mode picks the
|
|
727
|
+
* HUD scene's registry. Slice 5+.
|
|
728
|
+
*/
|
|
729
|
+
function findRegistry(game) {
|
|
730
|
+
if (getEditorMode(game) === 'hud')
|
|
731
|
+
return findHudRegistry(game);
|
|
732
|
+
for (const scene of game.scene.getScenes(false)) {
|
|
733
|
+
const key = scene.scene.key;
|
|
734
|
+
if (key === UMICAT_HUD_SCENE_KEY)
|
|
735
|
+
continue;
|
|
736
|
+
const reg = getEntityRegistry(scene);
|
|
737
|
+
if (reg)
|
|
738
|
+
return reg;
|
|
739
|
+
}
|
|
740
|
+
return undefined;
|
|
741
|
+
}
|
|
742
|
+
// --- applyEdit ------------------------------------------------------------
|
|
743
|
+
async function applyEdit(game, entityId, patch, manifestAsset) {
|
|
744
|
+
// Lazy-load + manifest-upsert path. When the host's Inspector picks a Bg
|
|
745
|
+
// image asset that the iframe never preloaded (e.g. a per-game upload that
|
|
746
|
+
// was never dragged into the scene) OR an asset whose `kind` just flipped
|
|
747
|
+
// (e.g. region-atlas — slice 10 — flipping from 'image' to 'atlas'), the
|
|
748
|
+
// host pipelines the new manifest entry along with the patch so the
|
|
749
|
+
// resulting re-spawn sees a consistent runtime state. Without this, the
|
|
750
|
+
// re-spawn reads the stale cached manifest and falls back to the
|
|
751
|
+
// colored-rect placeholder.
|
|
752
|
+
if (manifestAsset) {
|
|
753
|
+
const targetScene = getEditorMode(game) === 'hud'
|
|
754
|
+
? game.scene.getScene(UMICAT_HUD_SCENE_KEY)
|
|
755
|
+
: findWorldScene(game);
|
|
756
|
+
if (targetScene) {
|
|
757
|
+
try {
|
|
758
|
+
upsertCachedManifestAsset(targetScene, manifestAsset);
|
|
759
|
+
await ensureAssetLoaded(targetScene, manifestAsset);
|
|
760
|
+
}
|
|
761
|
+
catch (e) {
|
|
762
|
+
console.warn('[umicat/editor] applyEdit asset upsert/load failed:', e);
|
|
763
|
+
// Fall through — applyHudPatch will render its placeholder.
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
// HUD mode — patches modify anchor / HUD field values. World-style transform
|
|
768
|
+
// patches don't apply to HUD widgets, so we dispatch through a HUD-specific
|
|
769
|
+
// helper. SDK 0.3.0: render fields live at the patch root, so we forward
|
|
770
|
+
// them under `fields` (the HUD helper's contract).
|
|
771
|
+
if (getEditorMode(game) === 'hud') {
|
|
772
|
+
applyHudPatch(game, entityId, {
|
|
773
|
+
anchor: patch.anchor,
|
|
774
|
+
layer: patch.layer,
|
|
775
|
+
z: patch.z,
|
|
776
|
+
fields: extractRenderFields(patch),
|
|
777
|
+
role: patch.role,
|
|
778
|
+
properties: patch.properties,
|
|
779
|
+
});
|
|
780
|
+
if (getSelection(game) === entityId)
|
|
781
|
+
postSelectionRect(game);
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
const registry = findRegistry(game);
|
|
785
|
+
if (!registry)
|
|
786
|
+
return;
|
|
787
|
+
const go = registry.byId(entityId);
|
|
788
|
+
if (!go)
|
|
789
|
+
return;
|
|
790
|
+
applyTransformPatch(go, patch.transform);
|
|
791
|
+
applyVisualPatch(go, patch);
|
|
792
|
+
applyCodeRenderedParamsPatch(game, go, patch.params);
|
|
793
|
+
if (patch.role !== undefined) {
|
|
794
|
+
if (patch.role === null)
|
|
795
|
+
go.setData('entityRole', undefined);
|
|
796
|
+
else
|
|
797
|
+
go.setData('entityRole', patch.role);
|
|
798
|
+
}
|
|
799
|
+
if (patch.properties !== undefined) {
|
|
800
|
+
go.setData('entityProperties', patch.properties);
|
|
801
|
+
}
|
|
802
|
+
// If this edit moved the selected entity, the host's ✨ button + popover
|
|
803
|
+
// anchor needs the new rect. Cheap RAF-coalesced.
|
|
804
|
+
if (getSelection(game) === entityId)
|
|
805
|
+
postSelectionRect(game);
|
|
806
|
+
}
|
|
807
|
+
// --- Slice 8: asset-level live update ------------------------------------
|
|
808
|
+
/**
|
|
809
|
+
* Handle an `umicat:editor:assetUpdate` postMessage — used by the Hitbox
|
|
810
|
+
* Editor to push freshly-saved hitbox / depthAnchor metadata into the
|
|
811
|
+
* running iframe without a scene reload.
|
|
812
|
+
*
|
|
813
|
+
* Flow:
|
|
814
|
+
* 1. Upsert the asset into the iframe's cached manifest (so future spawns
|
|
815
|
+
* see the new metadata).
|
|
816
|
+
* 2. Lazy-load texture if it isn't cached yet (defensive — Hitbox Editor
|
|
817
|
+
* is typically opened on already-loaded assets, but the same code path
|
|
818
|
+
* is reused if a user edits hitbox on an asset that was never in this
|
|
819
|
+
* scene's manifest).
|
|
820
|
+
* 3. Walk the entity registry, find every sprite with `entityAssetId`
|
|
821
|
+
* matching the updated asset's id, and re-apply `setOrigin` (from new
|
|
822
|
+
* depthAnchor) + `applyAssetHitbox` (which is idempotent and re-installs
|
|
823
|
+
* the per-frame listener cleanly).
|
|
824
|
+
*
|
|
825
|
+
* HUD-mode is a no-op — HUD widgets don't carry world hitboxes / depthAnchors.
|
|
826
|
+
* The Hitbox Editor is launched only on world-scene assets.
|
|
827
|
+
*/
|
|
828
|
+
async function handleAssetUpdate(game, asset) {
|
|
829
|
+
if (getEditorMode(game) === 'hud')
|
|
830
|
+
return;
|
|
831
|
+
const scene = findWorldScene(game);
|
|
832
|
+
if (!scene)
|
|
833
|
+
return;
|
|
834
|
+
try {
|
|
835
|
+
upsertCachedManifestAsset(scene, asset);
|
|
836
|
+
await ensureAssetLoaded(scene, asset);
|
|
837
|
+
}
|
|
838
|
+
catch (e) {
|
|
839
|
+
console.warn('[umicat/editor] assetUpdate upsert/load failed:', e);
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
const registry = findRegistry(game);
|
|
843
|
+
if (!registry)
|
|
844
|
+
return;
|
|
845
|
+
for (const go of registry.all()) {
|
|
846
|
+
if (go.getData('entityAssetId') !== asset.id)
|
|
847
|
+
continue;
|
|
848
|
+
const sprite = go;
|
|
849
|
+
// Re-apply origin from new depthAnchor (no-op if anchor unset; spawn-time
|
|
850
|
+
// default origin of 0.5/0.5 stays). Cleared anchor reverts to center.
|
|
851
|
+
if (asset.depthAnchor) {
|
|
852
|
+
const frameW = sprite.width || 1;
|
|
853
|
+
const frameH = sprite.height || 1;
|
|
854
|
+
sprite.setOrigin?.(asset.depthAnchor.x / frameW, asset.depthAnchor.y / frameH);
|
|
855
|
+
}
|
|
856
|
+
else if (typeof sprite.setOrigin === 'function') {
|
|
857
|
+
sprite.setOrigin(0.5, 0.5);
|
|
858
|
+
}
|
|
859
|
+
// Re-apply hitbox. Idempotent — tears down the prior per-frame listener
|
|
860
|
+
// before installing the new one. Silent no-op when sprite has no body.
|
|
861
|
+
applyAssetHitbox(sprite, asset);
|
|
862
|
+
}
|
|
863
|
+
// Slice 6 Phase C — re-apply per-tile metadata + auto-collision on every
|
|
864
|
+
// tilemap layer whose layer.tilesetIds[0] matches this asset. Without
|
|
865
|
+
// this, a Tile Metadata Editor save only takes effect after a workspace
|
|
866
|
+
// rebuild / scene reload. Walks every tilemap entity, finds matching
|
|
867
|
+
// layers (via the GO's `entityKind === 'tilemap'` tag + the layer's
|
|
868
|
+
// `tilemapEntityId` data), then re-runs the same metadata wire that
|
|
869
|
+
// initial scene load uses.
|
|
870
|
+
for (const go of registry.all()) {
|
|
871
|
+
if (go.getData('entityKind') !== 'tilemap')
|
|
872
|
+
continue;
|
|
873
|
+
const container = go;
|
|
874
|
+
const layers = container.getData('tilemapLayers') ?? [];
|
|
875
|
+
for (const layer of layers) {
|
|
876
|
+
// Look up which tileset this layer was created against — stashed on
|
|
877
|
+
// the layer's data manager. Cheap match — `layer.tileset[0]` is the
|
|
878
|
+
// Phaser Tileset object, not the asset id, so we route through the
|
|
879
|
+
// host-stashed id.
|
|
880
|
+
const layerTilesetId = layer.getData('tilemapTilesetId');
|
|
881
|
+
if (!layerTilesetId || layerTilesetId !== asset.id)
|
|
882
|
+
continue;
|
|
883
|
+
const tilesetPhaser = layer.tileset[0];
|
|
884
|
+
if (!tilesetPhaser)
|
|
885
|
+
continue;
|
|
886
|
+
applyTilesetTileMetadata(tilesetPhaser, layer, asset);
|
|
887
|
+
// Slice 6 Phase D — autotile rule-map edits invalidate the cached
|
|
888
|
+
// vertex grid so the next paint reverse-derives against the new
|
|
889
|
+
// ruleMap. Without this, the AutotileEditorModal's save shows in
|
|
890
|
+
// the palette but new paint clicks still cascade against the
|
|
891
|
+
// pre-save ruleMap (vertex grid is keyed by terrain id but holds
|
|
892
|
+
// bits derived from old indices).
|
|
893
|
+
invalidateAutotileCells(layer);
|
|
894
|
+
// Slice 6 Phase F — re-arm tile animations against the new
|
|
895
|
+
// animations metadata. Tears down the previous UPDATE handler +
|
|
896
|
+
// re-scans cells; new animations defined via the Animation Editor
|
|
897
|
+
// start animating live without a scene reload.
|
|
898
|
+
applyTilesetAnimations(layer, asset);
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
// --- Slice 8: debug overlay toggle ---------------------------------------
|
|
903
|
+
/**
|
|
904
|
+
* Forward the "Show hitboxes" toggle into the editor overlay scene. The
|
|
905
|
+
* overlay reads this flag each frame from the editor state attached to the
|
|
906
|
+
* Phaser game (see EditorState.setDebugOverlay).
|
|
907
|
+
*/
|
|
908
|
+
function setDebugOverlay(game, state) {
|
|
909
|
+
setDebugOverlayState(game, state);
|
|
910
|
+
// Mirror the toggle into Phaser's Arcade physics debug so the same
|
|
911
|
+
// "Show hitboxes" control works in BOTH modes:
|
|
912
|
+
// - Edit mode: EditorOverlayScene reads `debugOverlay.showHitboxes`
|
|
913
|
+
// each frame and draws authored hitbox metadata (slice 8 asset
|
|
914
|
+
// hitboxes + slice 6 tile collision rects).
|
|
915
|
+
// - Play mode: Phaser draws the actual Arcade body rects via the
|
|
916
|
+
// world's debugGraphic. Useful for verifying that `body.setSize` /
|
|
917
|
+
// `addTilemapCollider` / `setCollideWorldBounds` are wired
|
|
918
|
+
// correctly at runtime.
|
|
919
|
+
// The two layers can show simultaneously when the user toggles ON
|
|
920
|
+
// during edit and then clicks Play — both layers visualize "what
|
|
921
|
+
// counts as solid", just from different sources (authored data vs.
|
|
922
|
+
// engine state). They don't conflict.
|
|
923
|
+
for (const scene of game.scene.getScenes(false)) {
|
|
924
|
+
const key = scene.scene.key;
|
|
925
|
+
if (key === EDITOR_OVERLAY_KEY)
|
|
926
|
+
continue;
|
|
927
|
+
if (key === UMICAT_HUD_SCENE_KEY)
|
|
928
|
+
continue;
|
|
929
|
+
if (key === 'BootScene' || key === 'Boot')
|
|
930
|
+
continue;
|
|
931
|
+
const world = scene
|
|
932
|
+
.physics?.world;
|
|
933
|
+
if (!world)
|
|
934
|
+
continue;
|
|
935
|
+
world.drawDebug = state.showHitboxes;
|
|
936
|
+
if (state.showHitboxes) {
|
|
937
|
+
// createDebugGraphic is idempotent-ish — Phaser stores it on
|
|
938
|
+
// world.debugGraphic; calling twice creates a second graphic.
|
|
939
|
+
// Guard so a re-fire doesn't stack.
|
|
940
|
+
if (!world.debugGraphic) {
|
|
941
|
+
world.createDebugGraphic();
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
else if (world.debugGraphic) {
|
|
945
|
+
world.debugGraphic.clear();
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
/**
|
|
950
|
+
* Re-render a code-rendered entity's visual when its params change.
|
|
951
|
+
* Slice 3.5 — Inspector params editor produces these patches.
|
|
952
|
+
*
|
|
953
|
+
* No-op for non-code-rendered entities (no renderScriptPath in data) so
|
|
954
|
+
* sprite/primitive patches that incidentally include `visual.params` (the
|
|
955
|
+
* type allows it) flow through harmlessly.
|
|
956
|
+
*/
|
|
957
|
+
function applyCodeRenderedParamsPatch(game, go, newParams) {
|
|
958
|
+
if (newParams === undefined)
|
|
959
|
+
return;
|
|
960
|
+
const scriptPath = go.getData('renderScriptPath');
|
|
961
|
+
if (!scriptPath)
|
|
962
|
+
return; // not a code-rendered entity
|
|
963
|
+
const render = resolveRenderScript(game, scriptPath);
|
|
964
|
+
if (!render)
|
|
965
|
+
return;
|
|
966
|
+
const g = go;
|
|
967
|
+
render(g, newParams);
|
|
968
|
+
// Re-read tracker bounds after re-render — params changes can grow
|
|
969
|
+
// or shrink the drawn area. Falls back to existing hit dims when
|
|
970
|
+
// tracker has no data (script didn't call any tracked methods).
|
|
971
|
+
const fallbackW = go.getData('editorHitWidth') ?? 64;
|
|
972
|
+
const fallbackH = go.getData('editorHitHeight') ?? 64;
|
|
973
|
+
applyTrackedHitArea(g, fallbackW, fallbackH);
|
|
974
|
+
go.setData('renderScriptParams', newParams);
|
|
975
|
+
}
|
|
976
|
+
function applyTransformPatch(go, t) {
|
|
977
|
+
if (!t)
|
|
978
|
+
return;
|
|
979
|
+
const target = go;
|
|
980
|
+
if (typeof t.x === 'number')
|
|
981
|
+
target.x = t.x;
|
|
982
|
+
if (typeof t.y === 'number')
|
|
983
|
+
target.y = t.y;
|
|
984
|
+
if (typeof t.rotation === 'number')
|
|
985
|
+
target.rotation = t.rotation;
|
|
986
|
+
if (typeof t.scaleX === 'number')
|
|
987
|
+
target.scaleX = t.scaleX;
|
|
988
|
+
if (typeof t.scaleY === 'number')
|
|
989
|
+
target.scaleY = t.scaleY;
|
|
990
|
+
if (typeof t.depth === 'number' && target.setDepth)
|
|
991
|
+
target.setDepth(t.depth);
|
|
992
|
+
}
|
|
993
|
+
/**
|
|
994
|
+
* Pull every "render field" off a flat `EditorEntityPatch` for forwarding
|
|
995
|
+
* to `applyHudPatch.fields`. Anything not on this allowlist (transform /
|
|
996
|
+
* anchor / layer / z / role / properties) flows through HUD's own slots.
|
|
997
|
+
*/
|
|
998
|
+
function extractRenderFields(patch) {
|
|
999
|
+
const fields = {};
|
|
1000
|
+
const keys = [
|
|
1001
|
+
'tint', 'alpha', 'flipX', 'flipY', 'frame',
|
|
1002
|
+
'width', 'height', 'radius', 'fillColor', 'strokeColor', 'strokeWidth',
|
|
1003
|
+
'params', 'source', 'fontFamily', 'fontSize', 'color', 'align',
|
|
1004
|
+
'assetId', 'iconAssetId', 'backgroundAssetId', 'backgroundFrame', 'backgroundRegion',
|
|
1005
|
+
'label', 'shape', 'pressedFillColor', 'textColor',
|
|
1006
|
+
'value', 'max', 'backgroundColor', 'backgroundAlpha',
|
|
1007
|
+
'borderColor', 'borderWidth', 'borderRadius',
|
|
1008
|
+
];
|
|
1009
|
+
let any = false;
|
|
1010
|
+
for (const k of keys) {
|
|
1011
|
+
const v = patch[k];
|
|
1012
|
+
if (v !== undefined) {
|
|
1013
|
+
fields[k] = v;
|
|
1014
|
+
any = true;
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
return any ? fields : undefined;
|
|
1018
|
+
}
|
|
1019
|
+
/**
|
|
1020
|
+
* Apply the flat render fields from an `EditorEntityPatch` to a world
|
|
1021
|
+
* entity's GameObject. SDK 0.3.0 — fields live at the patch root, not
|
|
1022
|
+
* nested under `visual`.
|
|
1023
|
+
*/
|
|
1024
|
+
function applyVisualPatch(go, v) {
|
|
1025
|
+
if (!v)
|
|
1026
|
+
return;
|
|
1027
|
+
const target = go;
|
|
1028
|
+
if (v.tint !== undefined) {
|
|
1029
|
+
if (v.tint === null)
|
|
1030
|
+
target.clearTint?.();
|
|
1031
|
+
else
|
|
1032
|
+
target.setTint?.(parseColor(v.tint));
|
|
1033
|
+
}
|
|
1034
|
+
if (typeof v.alpha === 'number')
|
|
1035
|
+
target.setAlpha?.(v.alpha);
|
|
1036
|
+
if (typeof v.flipX === 'boolean')
|
|
1037
|
+
target.setFlipX?.(v.flipX);
|
|
1038
|
+
if (typeof v.flipY === 'boolean')
|
|
1039
|
+
target.setFlipY?.(v.flipY);
|
|
1040
|
+
if (v.frame !== undefined)
|
|
1041
|
+
target.setFrame?.(v.frame);
|
|
1042
|
+
if (typeof v.width === 'number' && typeof v.height === 'number') {
|
|
1043
|
+
target.setSize?.(v.width, v.height);
|
|
1044
|
+
}
|
|
1045
|
+
if (typeof v.radius === 'number') {
|
|
1046
|
+
// Phaser's Arc doesn't expose setRadius reliably; mutate + redraw.
|
|
1047
|
+
if ('radius' in target)
|
|
1048
|
+
target.radius = v.radius;
|
|
1049
|
+
}
|
|
1050
|
+
if (v.fillColor !== undefined) {
|
|
1051
|
+
target.setFillStyle?.(parseColor(v.fillColor));
|
|
1052
|
+
}
|
|
1053
|
+
if (v.strokeColor !== undefined && typeof v.strokeWidth === 'number') {
|
|
1054
|
+
if (v.strokeColor === null) {
|
|
1055
|
+
// Phaser doesn't expose a clearStroke; setting width=0 + black is the
|
|
1056
|
+
// closest approximation.
|
|
1057
|
+
target.setStrokeStyle?.(0, 0);
|
|
1058
|
+
}
|
|
1059
|
+
else {
|
|
1060
|
+
target.setStrokeStyle?.(v.strokeWidth, parseColor(v.strokeColor));
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
// --- Create / delete ------------------------------------------------------
|
|
1065
|
+
/**
|
|
1066
|
+
* Spawn a new entity into the active world scene. Slice 3.
|
|
1067
|
+
*
|
|
1068
|
+
* If the entity references an asset whose texture isn't yet in the Phaser
|
|
1069
|
+
* cache, we lazy-load it before spawn — same pattern as SceneLoader's
|
|
1070
|
+
* preloadSceneAssets, but on a single asset and at edit time. Without this
|
|
1071
|
+
* the host would have to coordinate a build before showing a dropped
|
|
1072
|
+
* sprite, which defeats the point of drag-to-place.
|
|
1073
|
+
*/
|
|
1074
|
+
async function createEntity(game, entity, manifestAsset) {
|
|
1075
|
+
// HUD mode dispatches to the HUD-runtime spawner (slice 5). The host
|
|
1076
|
+
// sends HudEntity records (not WorldEntity), so we type-erase.
|
|
1077
|
+
if (getEditorMode(game) === 'hud') {
|
|
1078
|
+
// Lazy-load asset if the HUD widget needs one and it's not in cache yet.
|
|
1079
|
+
const hud = game.scene.getScene(UMICAT_HUD_SCENE_KEY);
|
|
1080
|
+
if (manifestAsset && hud && !hud.textures.exists(manifestAsset.textureKey)) {
|
|
1081
|
+
await loadAssetIntoScene(hud, manifestAsset);
|
|
1082
|
+
}
|
|
1083
|
+
createHudEntityInScene(game, entity);
|
|
1084
|
+
return;
|
|
1085
|
+
}
|
|
1086
|
+
const scene = findWorldScene(game);
|
|
1087
|
+
if (!scene) {
|
|
1088
|
+
console.warn('[umicat/editor] createEntity: no world scene to spawn into');
|
|
1089
|
+
return;
|
|
1090
|
+
}
|
|
1091
|
+
const registry = getEntityRegistry(scene);
|
|
1092
|
+
if (!registry) {
|
|
1093
|
+
console.warn('[umicat/editor] createEntity: world scene has no entity registry');
|
|
1094
|
+
return;
|
|
1095
|
+
}
|
|
1096
|
+
// Always upsert the manifestAsset into the scene's cached manifest so
|
|
1097
|
+
// resolveAsset / re-spawns / subsequent layer adds find a consistent
|
|
1098
|
+
// entry. Mirrors the addLayer + assetUpdate paths.
|
|
1099
|
+
if (manifestAsset) {
|
|
1100
|
+
upsertCachedManifestAsset(scene, manifestAsset);
|
|
1101
|
+
}
|
|
1102
|
+
// Collect every asset whose texture this entity needs lazy-loaded.
|
|
1103
|
+
// - sprite: one asset (entity.assetId, or manifestAsset).
|
|
1104
|
+
// - tilemap: one per layer's tileset.
|
|
1105
|
+
// - other kinds (rect / circle / code-rendered / trigger): no asset.
|
|
1106
|
+
const assetsToLoad = [];
|
|
1107
|
+
const sceneRef = scene; // local non-nullable for closures below
|
|
1108
|
+
function queueIfMissing(a) {
|
|
1109
|
+
if (!a)
|
|
1110
|
+
return;
|
|
1111
|
+
if (sceneRef.textures.exists(a.textureKey))
|
|
1112
|
+
return;
|
|
1113
|
+
if (assetsToLoad.find((x) => x.id === a.id))
|
|
1114
|
+
return;
|
|
1115
|
+
assetsToLoad.push(a);
|
|
1116
|
+
}
|
|
1117
|
+
function resolveById(id) {
|
|
1118
|
+
if (manifestAsset && manifestAsset.id === id)
|
|
1119
|
+
return manifestAsset;
|
|
1120
|
+
try {
|
|
1121
|
+
const manifest = getManifest(sceneRef);
|
|
1122
|
+
return manifest.assets.find((a) => a.id === id);
|
|
1123
|
+
}
|
|
1124
|
+
catch {
|
|
1125
|
+
return undefined;
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
if (entity.kind === 'sprite') {
|
|
1129
|
+
let a = manifestAsset;
|
|
1130
|
+
if (!a) {
|
|
1131
|
+
const assetId = entity.assetId;
|
|
1132
|
+
if (assetId)
|
|
1133
|
+
a = resolveById(assetId);
|
|
1134
|
+
}
|
|
1135
|
+
queueIfMissing(a);
|
|
1136
|
+
}
|
|
1137
|
+
else if (entity.kind === 'tilemap') {
|
|
1138
|
+
const tilemap = entity;
|
|
1139
|
+
for (const layer of tilemap.layers ?? []) {
|
|
1140
|
+
for (const tid of layer.tilesetIds ?? []) {
|
|
1141
|
+
queueIfMissing(resolveById(tid));
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
for (const a of assetsToLoad) {
|
|
1146
|
+
await loadAssetIntoScene(scene, a);
|
|
1147
|
+
}
|
|
1148
|
+
const ctx = {
|
|
1149
|
+
scene,
|
|
1150
|
+
registry,
|
|
1151
|
+
resolveAsset: (id) => {
|
|
1152
|
+
if (manifestAsset && manifestAsset.id === id)
|
|
1153
|
+
return manifestAsset;
|
|
1154
|
+
// Fallback: look up in the manifest cache. Covers re-drag of an
|
|
1155
|
+
// already-in-manifest asset where the host omitted manifestAsset.
|
|
1156
|
+
try {
|
|
1157
|
+
const manifest = getManifest(scene);
|
|
1158
|
+
const found = manifest.assets.find((a) => a.id === id);
|
|
1159
|
+
if (found)
|
|
1160
|
+
return found;
|
|
1161
|
+
}
|
|
1162
|
+
catch {
|
|
1163
|
+
/* fall through to throw */
|
|
1164
|
+
}
|
|
1165
|
+
throw new Error(`[umicat/editor] createEntity: asset '${id}' not found in manifest`);
|
|
1166
|
+
},
|
|
1167
|
+
resolveRenderScript: undefined,
|
|
1168
|
+
};
|
|
1169
|
+
try {
|
|
1170
|
+
spawnEntity(ctx, entity);
|
|
1171
|
+
}
|
|
1172
|
+
catch (e) {
|
|
1173
|
+
console.warn('[umicat/editor] spawnEntity failed:', e);
|
|
1174
|
+
}
|
|
1175
|
+
// Tilemap grids are created hidden (Play mode default); enterEdit walks
|
|
1176
|
+
// existing tilemaps once and toggles them visible. New tilemaps dropped
|
|
1177
|
+
// AFTER enterEdit miss that walk and stay invisible — an empty tilemap
|
|
1178
|
+
// with hidden grid renders nothing, so the user sees nothing on the
|
|
1179
|
+
// canvas + thinks the drop failed. Re-run the visibility walk after
|
|
1180
|
+
// every editor-driven spawn so newly-dropped tilemaps show their bounds.
|
|
1181
|
+
if (entity.kind === 'tilemap') {
|
|
1182
|
+
setTilemapGridsVisible(game, true);
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
/**
|
|
1186
|
+
* Merge an asset record into the scene's cached manifest (the one
|
|
1187
|
+
* {@code getManifest(scene)} returns). Used by applyEdit's pipelined-asset
|
|
1188
|
+
* path so subsequent re-spawns see the updated entry (kind / atlasPath /
|
|
1189
|
+
* atlasFormat / ninePatch / etc.) without a full scene reload.
|
|
1190
|
+
*
|
|
1191
|
+
* <p>The manifest object lives in `scene.cache.json` and is shared by
|
|
1192
|
+
* reference — mutating its `assets[]` is sufficient. We replace in place
|
|
1193
|
+
* when an entry with the same id exists (so a stale {@code kind: 'image'}
|
|
1194
|
+
* gets fully overwritten by a fresh {@code kind: 'atlas'} record).
|
|
1195
|
+
*/
|
|
1196
|
+
function upsertCachedManifestAsset(scene, asset) {
|
|
1197
|
+
let manifest;
|
|
1198
|
+
try {
|
|
1199
|
+
manifest = getManifest(scene);
|
|
1200
|
+
}
|
|
1201
|
+
catch {
|
|
1202
|
+
return; // manifest not in cache — nothing to merge against
|
|
1203
|
+
}
|
|
1204
|
+
if (!manifest.assets)
|
|
1205
|
+
manifest.assets = [];
|
|
1206
|
+
const idx = manifest.assets.findIndex((a) => a.id === asset.id);
|
|
1207
|
+
if (idx >= 0)
|
|
1208
|
+
manifest.assets[idx] = asset;
|
|
1209
|
+
else
|
|
1210
|
+
manifest.assets.push(asset);
|
|
1211
|
+
}
|
|
1212
|
+
/**
|
|
1213
|
+
* Idempotent asset-loaded check: returns immediately if the texture is in
|
|
1214
|
+
* the cache. For atlas-format assets, ALSO ensures the per-frame ninePatch
|
|
1215
|
+
* sidecar JSON is loaded so the SDK's region-9-slice path can read it back.
|
|
1216
|
+
*/
|
|
1217
|
+
function ensureAssetLoaded(scene, asset) {
|
|
1218
|
+
if (asset.kind === 'audio')
|
|
1219
|
+
return Promise.resolve();
|
|
1220
|
+
const needsTexture = !scene.textures.exists(asset.textureKey);
|
|
1221
|
+
const sidecarKey = `umicat:atlas-ninepatch:${asset.textureKey}`;
|
|
1222
|
+
const needsSidecar = asset.kind === 'atlas' &&
|
|
1223
|
+
asset.atlasFormat === 'json' &&
|
|
1224
|
+
!!asset.atlasPath &&
|
|
1225
|
+
!scene.cache.json.exists(sidecarKey);
|
|
1226
|
+
if (!needsTexture && !needsSidecar)
|
|
1227
|
+
return Promise.resolve();
|
|
1228
|
+
return loadAssetIntoScene(scene, asset);
|
|
1229
|
+
}
|
|
1230
|
+
/**
|
|
1231
|
+
* Pick the URL to load asset bytes from. Drag-to-place uses the CDN
|
|
1232
|
+
* URL directly because the asset bytes aren't yet in the workspace's
|
|
1233
|
+
* `public/uploaded/` (they only get synced on SAVE by session-server's
|
|
1234
|
+
* `syncWorkspaceAssetsFromManifest`). Without this, the iframe's
|
|
1235
|
+
* lazy-load 404s for fresh drops, the texture never lands in
|
|
1236
|
+
* `scene.textures`, and `add.sprite(...)` falls back to Phaser's empty
|
|
1237
|
+
* `__DEFAULT` texture — visible only as the editor's selection rect
|
|
1238
|
+
* outline with no sprite content inside. `loadUrl` is an off-record
|
|
1239
|
+
* field set by `EditorBridge.createEntity`'s `manifestAsset` (home-ui
|
|
1240
|
+
* passes `AssetSummary.url`); not part of the persisted manifest
|
|
1241
|
+
* schema (which keeps `path: uploaded/<filename>` for build-time
|
|
1242
|
+
* correctness — session-server syncs bytes there on save).
|
|
1243
|
+
*/
|
|
1244
|
+
function pickLoadUrl(asset) {
|
|
1245
|
+
const loadUrl = asset.loadUrl;
|
|
1246
|
+
return loadUrl || asset.path;
|
|
1247
|
+
}
|
|
1248
|
+
function loadAssetIntoScene(scene, asset) {
|
|
1249
|
+
return new Promise((resolve, reject) => {
|
|
1250
|
+
if (scene.textures.exists(asset.textureKey)) {
|
|
1251
|
+
resolve();
|
|
1252
|
+
return;
|
|
1253
|
+
}
|
|
1254
|
+
const onComplete = () => {
|
|
1255
|
+
cleanup();
|
|
1256
|
+
resolve();
|
|
1257
|
+
};
|
|
1258
|
+
const onError = (file) => {
|
|
1259
|
+
if (file.key !== asset.textureKey)
|
|
1260
|
+
return;
|
|
1261
|
+
cleanup();
|
|
1262
|
+
reject(new Error(`failed to load asset ${asset.id}: ${file.url}`));
|
|
1263
|
+
};
|
|
1264
|
+
const cleanup = () => {
|
|
1265
|
+
scene.load.off(Phaser.Loader.Events.FILE_COMPLETE, perFileComplete);
|
|
1266
|
+
scene.load.off(Phaser.Loader.Events.FILE_LOAD_ERROR, onError);
|
|
1267
|
+
scene.load.off(Phaser.Loader.Events.COMPLETE, onComplete);
|
|
1268
|
+
};
|
|
1269
|
+
const perFileComplete = (key) => {
|
|
1270
|
+
if (key === asset.textureKey) {
|
|
1271
|
+
cleanup();
|
|
1272
|
+
resolve();
|
|
1273
|
+
}
|
|
1274
|
+
};
|
|
1275
|
+
scene.load.on(Phaser.Loader.Events.FILE_COMPLETE, perFileComplete);
|
|
1276
|
+
scene.load.on(Phaser.Loader.Events.FILE_LOAD_ERROR, onError);
|
|
1277
|
+
scene.load.on(Phaser.Loader.Events.COMPLETE, onComplete);
|
|
1278
|
+
const loadFrom = pickLoadUrl(asset);
|
|
1279
|
+
switch (asset.kind) {
|
|
1280
|
+
case 'image':
|
|
1281
|
+
scene.load.image(asset.textureKey, loadFrom);
|
|
1282
|
+
break;
|
|
1283
|
+
case 'spritesheet':
|
|
1284
|
+
if (asset.spriteSheetConfig) {
|
|
1285
|
+
scene.load.spritesheet(asset.textureKey, loadFrom, asset.spriteSheetConfig);
|
|
1286
|
+
}
|
|
1287
|
+
else {
|
|
1288
|
+
scene.load.image(asset.textureKey, loadFrom);
|
|
1289
|
+
}
|
|
1290
|
+
break;
|
|
1291
|
+
case 'atlas':
|
|
1292
|
+
if (asset.atlasPath && asset.atlasFormat === 'xml') {
|
|
1293
|
+
scene.load.atlasXML(asset.textureKey, loadFrom, asset.atlasPath);
|
|
1294
|
+
}
|
|
1295
|
+
else if (asset.atlasPath) {
|
|
1296
|
+
scene.load.atlas(asset.textureKey, loadFrom, asset.atlasPath);
|
|
1297
|
+
// Also fetch the raw JSON into the side-cache for per-region
|
|
1298
|
+
// ninePatch lookups (slice 10). Phaser's atlas parser drops
|
|
1299
|
+
// unknown per-frame fields, so we keep the original JSON around.
|
|
1300
|
+
const sidecarKey = `umicat:atlas-ninepatch:${asset.textureKey}`;
|
|
1301
|
+
if (!scene.cache.json.exists(sidecarKey)) {
|
|
1302
|
+
scene.load.json(sidecarKey, asset.atlasPath);
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
break;
|
|
1306
|
+
case 'audio':
|
|
1307
|
+
scene.load.audio(asset.textureKey, loadFrom);
|
|
1308
|
+
break;
|
|
1309
|
+
default:
|
|
1310
|
+
cleanup();
|
|
1311
|
+
reject(new Error(`unsupported asset kind for editor lazy-load: ${asset.kind}`));
|
|
1312
|
+
return;
|
|
1313
|
+
}
|
|
1314
|
+
if (!scene.load.isLoading())
|
|
1315
|
+
scene.load.start();
|
|
1316
|
+
});
|
|
1317
|
+
}
|
|
1318
|
+
function deleteEntity(game, entityId) {
|
|
1319
|
+
// HUD mode — dispatch to the HUD-scene helper, which destroys + unregisters
|
|
1320
|
+
// + drops the entity from the cached scene file.
|
|
1321
|
+
if (getEditorMode(game) === 'hud') {
|
|
1322
|
+
deleteHudEntityFromScene(game, entityId);
|
|
1323
|
+
if (getSelection(game) === entityId)
|
|
1324
|
+
setSelection(game, null);
|
|
1325
|
+
return;
|
|
1326
|
+
}
|
|
1327
|
+
const scene = findWorldScene(game);
|
|
1328
|
+
if (!scene)
|
|
1329
|
+
return;
|
|
1330
|
+
const registry = getEntityRegistry(scene);
|
|
1331
|
+
if (!registry)
|
|
1332
|
+
return;
|
|
1333
|
+
const go = registry.byId(entityId);
|
|
1334
|
+
if (!go)
|
|
1335
|
+
return;
|
|
1336
|
+
// Slice 6 Phase B — tilemap entities have associated TilemapLayer
|
|
1337
|
+
// GameObjects in scene ROOT (not Container children, due to the Phaser
|
|
1338
|
+
// Container/TilemapLayer transform quirk). Destroying the Container
|
|
1339
|
+
// alone orphans those layers — they stay visible in the canvas until
|
|
1340
|
+
// the next scene reload. Walk the container's stashed layer refs and
|
|
1341
|
+
// destroy each before destroying the container itself. Also destroys
|
|
1342
|
+
// the sub-tile static body group (slice 6 Phase C follow-up) since
|
|
1343
|
+
// those bodies are scene-root members too.
|
|
1344
|
+
if (go.getData('entityKind') === 'tilemap') {
|
|
1345
|
+
const container = go;
|
|
1346
|
+
const layers = container.getData('tilemapLayers') ?? [];
|
|
1347
|
+
for (const layer of layers) {
|
|
1348
|
+
const subGroup = layer.getData('unboxySubTileStaticGroup');
|
|
1349
|
+
if (subGroup) {
|
|
1350
|
+
subGroup.clear(true, true);
|
|
1351
|
+
subGroup.destroy(true);
|
|
1352
|
+
}
|
|
1353
|
+
layer.destroy();
|
|
1354
|
+
}
|
|
1355
|
+
container.setData('tilemapLayers', []);
|
|
1356
|
+
}
|
|
1357
|
+
go.destroy();
|
|
1358
|
+
registry.unregister(entityId);
|
|
1359
|
+
if (getSelection(game) === entityId)
|
|
1360
|
+
setSelection(game, null);
|
|
1361
|
+
}
|
|
1362
|
+
function findWorldScene(game) {
|
|
1363
|
+
// Prefer a scene with an EntityRegistry (scene-as-data game) — this
|
|
1364
|
+
// is the canonical "world scene" with metadata the editor can fully
|
|
1365
|
+
// interact with (Inspector, drag-to-place, prefab edits, etc.).
|
|
1366
|
+
// But fall back to ANY non-Boot/HUD/Overlay scene for pure
|
|
1367
|
+
// scene-as-code games. Without the fallback, the editor cam never
|
|
1368
|
+
// installs for code-only games (Snake, breakout, etc.) and pan/zoom
|
|
1369
|
+
// is silently broken — see game 3167e9ee (2026-05-17). Downstream
|
|
1370
|
+
// callers that NEED a registry (createEntity, deleteEntity, prefab
|
|
1371
|
+
// patches) check `getEntityRegistry(scene)` themselves and soft-fail
|
|
1372
|
+
// when it's missing, so relaxing this fallback is safe.
|
|
1373
|
+
let firstNonRegistry;
|
|
1374
|
+
for (const scene of game.scene.getScenes(false)) {
|
|
1375
|
+
const key = scene.scene.key;
|
|
1376
|
+
if (BOOT_SCENE_KEYS.has(key))
|
|
1377
|
+
continue;
|
|
1378
|
+
if (key === UMICAT_HUD_SCENE_KEY)
|
|
1379
|
+
continue;
|
|
1380
|
+
if (key === EDITOR_OVERLAY_KEY)
|
|
1381
|
+
continue;
|
|
1382
|
+
if (getEntityRegistry(scene))
|
|
1383
|
+
return scene;
|
|
1384
|
+
if (!firstNonRegistry)
|
|
1385
|
+
firstNonRegistry = scene;
|
|
1386
|
+
}
|
|
1387
|
+
return firstNonRegistry;
|
|
1388
|
+
}
|
|
1389
|
+
// --- Pan / zoom (P1 infinite canvas) -------------------------------------
|
|
1390
|
+
const ZOOM_MIN = 0.25;
|
|
1391
|
+
const ZOOM_MAX = 4;
|
|
1392
|
+
/**
|
|
1393
|
+
* Cap zoom uniformly across host-driven + SDK-input-driven paths so the
|
|
1394
|
+
* limits stay consistent everywhere.
|
|
1395
|
+
*/
|
|
1396
|
+
function clampZoom(z) {
|
|
1397
|
+
return Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, z));
|
|
1398
|
+
}
|
|
1399
|
+
/**
|
|
1400
|
+
* Apply pan/zoom to the world scene's camera + mirror to the editor
|
|
1401
|
+
* overlay scene's camera so the overlay graphics (selection rect, world-
|
|
1402
|
+
* bounds rect, hitbox debug) draw in the right world position.
|
|
1403
|
+
*
|
|
1404
|
+
* Scoped to world + overlay only — HUD has an identity camera by design
|
|
1405
|
+
* (widgets are anchor-positioned to canvas edges), so panning/zooming it
|
|
1406
|
+
* would visually rip HUD widgets off their anchors.
|
|
1407
|
+
*
|
|
1408
|
+
* Exported for `EditorOverlayScene` so the in-canvas wheel + middle-click
|
|
1409
|
+
* + space+drag handlers route through the same mutation function as the
|
|
1410
|
+
* host-driven `umicat:editor:panZoom` message.
|
|
1411
|
+
*/
|
|
1412
|
+
/**
|
|
1413
|
+
* Apply pan/zoom to the editor camera (NOT the game's runtime camera).
|
|
1414
|
+
*
|
|
1415
|
+
* P1 infinite canvas (2026-05-17). Mutates `editorCam` on the world scene
|
|
1416
|
+
* + the overlay scene's mirror. `cameras.main` (the game's runtime cam)
|
|
1417
|
+
* is never touched in edit mode — the game's intended camera position is
|
|
1418
|
+
* preserved exactly as the game code left it, and surfaces in the editor
|
|
1419
|
+
* as a dashed "Camera" rect (drawn by `EditorOverlayScene.update`). This
|
|
1420
|
+
* matches Godot 2D editor's "editor viewport vs Camera2D" split.
|
|
1421
|
+
*
|
|
1422
|
+
* Exported for `EditorOverlayScene` so the in-canvas wheel + middle-click
|
|
1423
|
+
* + space+drag handlers route through the same mutation function as the
|
|
1424
|
+
* host-driven `umicat:editor:panZoom` message. Renamed from the misleading
|
|
1425
|
+
* `applyPanZoomToWorld` (which sounded like "pan the world camera").
|
|
1426
|
+
*/
|
|
1427
|
+
export function applyEditorPanZoom(game, msg) {
|
|
1428
|
+
const editorCam = getEditorCamera(game);
|
|
1429
|
+
const overlayScene = game.scene.getScene(EDITOR_OVERLAY_KEY);
|
|
1430
|
+
const cams = [];
|
|
1431
|
+
if (editorCam)
|
|
1432
|
+
cams.push(editorCam);
|
|
1433
|
+
if (overlayScene)
|
|
1434
|
+
cams.push(overlayScene.cameras.main);
|
|
1435
|
+
for (const cam of cams) {
|
|
1436
|
+
if (msg.relative) {
|
|
1437
|
+
if (typeof msg.scrollX === 'number')
|
|
1438
|
+
cam.scrollX += msg.scrollX;
|
|
1439
|
+
if (typeof msg.scrollY === 'number')
|
|
1440
|
+
cam.scrollY += msg.scrollY;
|
|
1441
|
+
}
|
|
1442
|
+
else {
|
|
1443
|
+
if (typeof msg.scrollX === 'number')
|
|
1444
|
+
cam.scrollX = msg.scrollX;
|
|
1445
|
+
if (typeof msg.scrollY === 'number')
|
|
1446
|
+
cam.scrollY = msg.scrollY;
|
|
1447
|
+
}
|
|
1448
|
+
if (typeof msg.zoom === 'number') {
|
|
1449
|
+
cam.setZoom(clampZoom(msg.zoom));
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
// Keep HUD scene's cam viewport tracking the camera-viewport rect
|
|
1453
|
+
// on the editor canvas — pan/zoom moves the rect, HUD follows.
|
|
1454
|
+
syncHudCameraToEditorCam(game);
|
|
1455
|
+
postCameraState(game);
|
|
1456
|
+
// Re-anchor the host's popover/sparkle button on every pan/zoom so
|
|
1457
|
+
// it tracks the selected entity. Previously only the postMessage
|
|
1458
|
+
// panZoom path posted the rect; the SDK-internal wheel/middle-drag
|
|
1459
|
+
// path bypassed it and the popover lagged behind. Baking the call
|
|
1460
|
+
// in here covers both paths uniformly. RAF-coalesced inside, so
|
|
1461
|
+
// many wheel events per gesture still emit one rect per frame.
|
|
1462
|
+
postSelectionRect(game);
|
|
1463
|
+
}
|
|
1464
|
+
/**
|
|
1465
|
+
* Back-compat alias for callers (e.g. older host messages, internal call
|
|
1466
|
+
* sites) referencing the old name. Keep until we're sure nothing still
|
|
1467
|
+
* relies on it.
|
|
1468
|
+
*/
|
|
1469
|
+
export const applyPanZoomToWorld = applyEditorPanZoom;
|
|
1470
|
+
/**
|
|
1471
|
+
* Post the editor camera's current state to the host so it can render the
|
|
1472
|
+
* zoom indicator + reset button, and decide which Hierarchy entries are
|
|
1473
|
+
* off-screen. Falls back to the game's cameras.main when editorCam isn't
|
|
1474
|
+
* installed yet (race during enterEdit).
|
|
1475
|
+
*/
|
|
1476
|
+
export function postCameraState(game) {
|
|
1477
|
+
const worldScene = findWorldScene(game);
|
|
1478
|
+
if (!worldScene)
|
|
1479
|
+
return;
|
|
1480
|
+
const cam = getEditorCamera(game) ?? worldScene.cameras.main;
|
|
1481
|
+
const canvasW = game.scale.width;
|
|
1482
|
+
const canvasH = game.scale.height;
|
|
1483
|
+
// Visible world rect = (scrollX, scrollY) → (scrollX + canvasW/zoom, ...).
|
|
1484
|
+
// Convenience for the host so it doesn't have to re-derive zoom math.
|
|
1485
|
+
const viewportWorld = {
|
|
1486
|
+
x: cam.scrollX,
|
|
1487
|
+
y: cam.scrollY,
|
|
1488
|
+
width: canvasW / cam.zoom,
|
|
1489
|
+
height: canvasH / cam.zoom,
|
|
1490
|
+
};
|
|
1491
|
+
postToHost({
|
|
1492
|
+
type: 'umicat:editor:cameraState',
|
|
1493
|
+
zoom: cam.zoom,
|
|
1494
|
+
scrollX: cam.scrollX,
|
|
1495
|
+
scrollY: cam.scrollY,
|
|
1496
|
+
viewportWorld,
|
|
1497
|
+
});
|
|
1498
|
+
}
|
|
1499
|
+
// --- Canvas expand / restore (P1 infinite canvas: whole iframe = editor) -
|
|
1500
|
+
const CANVAS_SCALE_SNAPSHOT_KEY = '__unboxyEditorScaleSnapshot';
|
|
1501
|
+
/**
|
|
1502
|
+
* Switch Phaser to RESIZE scale mode so the canvas fills its parent (the
|
|
1503
|
+
* iframe body). Pairs with a home-ui CSS rule that drops the iframe's
|
|
1504
|
+
* aspect-ratio constraint in edit mode — together the iframe is the
|
|
1505
|
+
* entire editor surface, matching the Figma/Godot mental model the user
|
|
1506
|
+
* pushed back on 2026-05-17.
|
|
1507
|
+
*
|
|
1508
|
+
* The game's world coordinate system stays at its declared size (e.g.
|
|
1509
|
+
* 720×1280) — `cameras.main` is invisible in edit mode and never reads
|
|
1510
|
+
* the new canvas dims; only the editor camera (installed AFTER this
|
|
1511
|
+
* call) uses the new size to set its viewport. On exit, scale mode is
|
|
1512
|
+
* restored to the original (typically `FIT`) and the canvas snaps back
|
|
1513
|
+
* to game intrinsic size.
|
|
1514
|
+
*
|
|
1515
|
+
* Idempotent: re-entering edit while a snapshot exists is a no-op (the
|
|
1516
|
+
* race-window re-attempt loop in `enterEdit` calls back into the
|
|
1517
|
+
* install chain every 100ms for ~3s).
|
|
1518
|
+
*/
|
|
1519
|
+
function expandCanvasForEditor(game) {
|
|
1520
|
+
const bag = game;
|
|
1521
|
+
// Idempotent expand (2026-05-17): if a stale snapshot exists from a
|
|
1522
|
+
// prior cycle that didn't fully restore, restore first to a clean
|
|
1523
|
+
// baseline, then take a fresh snapshot. Without this, second enter
|
|
1524
|
+
// skipped the entire expand path on stale state.
|
|
1525
|
+
if (bag[CANVAS_SCALE_SNAPSHOT_KEY]) {
|
|
1526
|
+
restoreCanvasAfterEditor(game);
|
|
1527
|
+
}
|
|
1528
|
+
bag[CANVAS_SCALE_SNAPSHOT_KEY] = {
|
|
1529
|
+
scaleMode: game.scale.scaleMode,
|
|
1530
|
+
autoCenter: game.scale.autoCenter,
|
|
1531
|
+
gameWidth: game.scale.gameSize.width,
|
|
1532
|
+
gameHeight: game.scale.gameSize.height,
|
|
1533
|
+
};
|
|
1534
|
+
// Force html/body to fill the viewport with grey bg. Inline styles win
|
|
1535
|
+
// over the template's `<style>` (which sets `body { display: flex;
|
|
1536
|
+
// align-items: center; background: #000 }` — that flex-centers the
|
|
1537
|
+
// canvas at its intrinsic size and shows a black background around
|
|
1538
|
+
// it, exactly what the user pushed back on). Grey body bg also acts
|
|
1539
|
+
// as the editor "void" — if any pixel inside the iframe isn't covered
|
|
1540
|
+
// by the SDK overlay's outside-fill graphics (e.g. fast pan, low
|
|
1541
|
+
// zoom + huge canvas), it falls through to grey instead of black.
|
|
1542
|
+
if (typeof document !== 'undefined') {
|
|
1543
|
+
const html = document.documentElement;
|
|
1544
|
+
const body = document.body;
|
|
1545
|
+
if (html) {
|
|
1546
|
+
html.style.setProperty('width', '100%', 'important');
|
|
1547
|
+
html.style.setProperty('height', '100%', 'important');
|
|
1548
|
+
html.style.setProperty('margin', '0', 'important');
|
|
1549
|
+
html.style.setProperty('padding', '0', 'important');
|
|
1550
|
+
html.style.setProperty('overflow', 'hidden', 'important');
|
|
1551
|
+
}
|
|
1552
|
+
if (body) {
|
|
1553
|
+
body.style.setProperty('width', '100%', 'important');
|
|
1554
|
+
body.style.setProperty('height', '100%', 'important');
|
|
1555
|
+
body.style.setProperty('margin', '0', 'important');
|
|
1556
|
+
body.style.setProperty('padding', '0', 'important');
|
|
1557
|
+
body.style.setProperty('display', 'block', 'important');
|
|
1558
|
+
body.style.setProperty('background', '#2a2a2e', 'important');
|
|
1559
|
+
body.style.setProperty('overflow', 'hidden', 'important');
|
|
1560
|
+
// Force a layout flush so the body's new dims are committed before
|
|
1561
|
+
// we read them via getBoundingClientRect below.
|
|
1562
|
+
void body.offsetHeight;
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
// Switch to NONE (explicit canvas size) + NO_CENTER (no autoCenter
|
|
1566
|
+
// margins) so we have full control. RESIZE alone was fighting both
|
|
1567
|
+
// the template's body flex and the original `autoCenter: CENTER_BOTH`
|
|
1568
|
+
// — net effect was the canvas would settle at game intrinsic size
|
|
1569
|
+
// shifted/centered inside body, regardless of what scale.refresh()
|
|
1570
|
+
// computed. NONE + setGameSize sidesteps all of that.
|
|
1571
|
+
game.scale.autoCenter = Phaser.Scale.NO_CENTER;
|
|
1572
|
+
game.scale.scaleMode = Phaser.Scale.NONE;
|
|
1573
|
+
resizeCanvasToIframe(game);
|
|
1574
|
+
// Re-measure on next frame too — React's iframe inline-style update
|
|
1575
|
+
// and the iframe's internal layout might not be settled at the moment
|
|
1576
|
+
// `enterEdit` fires. A second pass catches late layout. Reads the
|
|
1577
|
+
// snapshot to confirm we're still in editor mode (user could have
|
|
1578
|
+
// toggled out before the rAF tick).
|
|
1579
|
+
if (typeof requestAnimationFrame !== 'undefined') {
|
|
1580
|
+
requestAnimationFrame(() => {
|
|
1581
|
+
if (!bag[CANVAS_SCALE_SNAPSHOT_KEY])
|
|
1582
|
+
return;
|
|
1583
|
+
resizeCanvasToIframe(game);
|
|
1584
|
+
});
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
/**
|
|
1588
|
+
* Read iframe viewport via `window.innerWidth/innerHeight` (most direct
|
|
1589
|
+
* available measurement inside an iframe — equals the iframe ELEMENT's
|
|
1590
|
+
* inner content area, settled by the browser regardless of body layout).
|
|
1591
|
+
* Falls back to body rect when window dims are 0 (some sandboxes).
|
|
1592
|
+
*/
|
|
1593
|
+
function resizeCanvasToIframe(game) {
|
|
1594
|
+
if (typeof window === 'undefined')
|
|
1595
|
+
return;
|
|
1596
|
+
// Use `window.innerWidth/Height` as the iframe viewport size, then
|
|
1597
|
+
// FORCE the canvas's display CSS to exactly that in pixels via
|
|
1598
|
+
// setProperty. This guarantees `cssRect.width === game.scale.width`
|
|
1599
|
+
// regardless of what body styling, flex constraints, or CSS
|
|
1600
|
+
// transforms might otherwise cause canvas's `width: 100%` to
|
|
1601
|
+
// resolve to. Empirically, body's resolved width can be smaller
|
|
1602
|
+
// than `window.innerWidth` due to template's `display: flex` /
|
|
1603
|
+
// container queries / etc., even after we set `body.width: 100%`
|
|
1604
|
+
// inline — when that happens `100%` on canvas = 100% of (shrunk)
|
|
1605
|
+
// body, and we get the mismatch the user saw at 53% zoom (sparkle
|
|
1606
|
+
// landed in top-left of the iframe instead of on the entity).
|
|
1607
|
+
const w = window.innerWidth;
|
|
1608
|
+
const h = window.innerHeight;
|
|
1609
|
+
if (w <= 0 || h <= 0)
|
|
1610
|
+
return;
|
|
1611
|
+
game.scale.setGameSize(w, h);
|
|
1612
|
+
game.scale.refresh();
|
|
1613
|
+
if (game.canvas) {
|
|
1614
|
+
// Explicit pixel display sizing — beats both `width: 100%`
|
|
1615
|
+
// resolution AND any CSS-transform-induced scaling. Phaser's
|
|
1616
|
+
// own `style.width` write happens in `refresh()` above, but
|
|
1617
|
+
// without `!important` so any stylesheet rule with `!important`
|
|
1618
|
+
// could win. We re-set with `!important` here.
|
|
1619
|
+
game.canvas.style.setProperty('position', 'absolute', 'important');
|
|
1620
|
+
game.canvas.style.setProperty('top', '0', 'important');
|
|
1621
|
+
game.canvas.style.setProperty('left', '0', 'important');
|
|
1622
|
+
game.canvas.style.setProperty('width', w + 'px', 'important');
|
|
1623
|
+
game.canvas.style.setProperty('height', h + 'px', 'important');
|
|
1624
|
+
game.canvas.style.setProperty('margin', '0', 'important');
|
|
1625
|
+
}
|
|
1626
|
+
// Sync editor + overlay camera viewports to the new canvas dims so
|
|
1627
|
+
// pan/zoom + outside-fill render across the entire surface. The
|
|
1628
|
+
// installEditorCameras call earlier in `enterEdit` may have set the
|
|
1629
|
+
// viewport based on a stale first measurement; this rAF-deferred
|
|
1630
|
+
// path catches the corrected size.
|
|
1631
|
+
const editorCam = getEditorCamera(game);
|
|
1632
|
+
if (editorCam && typeof editorCam.setViewport === 'function') {
|
|
1633
|
+
editorCam.setViewport(0, 0, w, h);
|
|
1634
|
+
}
|
|
1635
|
+
const overlayScene = game.scene.getScene(EDITOR_OVERLAY_KEY);
|
|
1636
|
+
// Overlay scene's `cameras.main` is undefined during the boot window
|
|
1637
|
+
// between scene.run() and scene.create() — Phaser hasn't constructed
|
|
1638
|
+
// the camera manager yet. Race-safe guard: only call setViewport if
|
|
1639
|
+
// both cameras and cameras.main exist.
|
|
1640
|
+
if (overlayScene && overlayScene.cameras && overlayScene.cameras.main) {
|
|
1641
|
+
overlayScene.cameras.main.setViewport(0, 0, w, h);
|
|
1642
|
+
}
|
|
1643
|
+
// Re-sync HUD cam viewport to the camera-viewport rect on the new
|
|
1644
|
+
// canvas dims (Phaser auto-resized HUD cam to the expanded canvas;
|
|
1645
|
+
// we want it positioned inside the cam viewport rect instead).
|
|
1646
|
+
syncHudCameraToEditorCam(game);
|
|
1647
|
+
}
|
|
1648
|
+
/**
|
|
1649
|
+
* Inverse of `expandCanvasForEditor`. Restores the original scale mode
|
|
1650
|
+
* and game size; Phaser resizes the canvas back to the game's intrinsic
|
|
1651
|
+
* dimensions (with whatever FIT letterboxing the host CSS now allows).
|
|
1652
|
+
*/
|
|
1653
|
+
function restoreCanvasAfterEditor(game) {
|
|
1654
|
+
const bag = game;
|
|
1655
|
+
const snap = bag[CANVAS_SCALE_SNAPSHOT_KEY];
|
|
1656
|
+
if (!snap)
|
|
1657
|
+
return;
|
|
1658
|
+
// Clear the inline html/body styles we added so Play mode reverts to
|
|
1659
|
+
// the template's original styling (flex-centered canvas, black bg).
|
|
1660
|
+
if (typeof document !== 'undefined') {
|
|
1661
|
+
const html = document.documentElement;
|
|
1662
|
+
const body = document.body;
|
|
1663
|
+
if (html) {
|
|
1664
|
+
['width', 'height', 'margin', 'padding', 'overflow'].forEach((p) => html.style.removeProperty(p));
|
|
1665
|
+
}
|
|
1666
|
+
if (body) {
|
|
1667
|
+
['width', 'height', 'margin', 'padding', 'display', 'background', 'overflow'].forEach((p) => body.style.removeProperty(p));
|
|
1668
|
+
}
|
|
1669
|
+
}
|
|
1670
|
+
// Clear the inline canvas styles we forced on enter. Phaser's
|
|
1671
|
+
// scaleMode restore (FIT etc.) will re-write style.width/height to
|
|
1672
|
+
// the appropriate values for its own layout.
|
|
1673
|
+
if (game.canvas) {
|
|
1674
|
+
['position', 'top', 'left', 'width', 'height', 'margin'].forEach((p) => game.canvas.style.removeProperty(p));
|
|
1675
|
+
}
|
|
1676
|
+
game.scale.autoCenter = snap.autoCenter;
|
|
1677
|
+
game.scale.scaleMode = snap.scaleMode;
|
|
1678
|
+
game.scale.setGameSize(snap.gameWidth, snap.gameHeight);
|
|
1679
|
+
game.scale.refresh();
|
|
1680
|
+
delete bag[CANVAS_SCALE_SNAPSHOT_KEY];
|
|
1681
|
+
}
|
|
1682
|
+
// --- Editor camera install / uninstall (P1 infinite canvas) -------------
|
|
1683
|
+
const EDITOR_CAM_KEY = '__unboxyEditorCam';
|
|
1684
|
+
const GAME_CAM_HIDDEN_KEY = '__unboxyEditorGameCamWasVisible';
|
|
1685
|
+
const VOID_FILL_KEY = '__unboxyEditorVoidFill';
|
|
1686
|
+
// Editor "void" grey — matches what EditorOverlayScene used to draw via
|
|
1687
|
+
// `OUTSIDE_FILL_COLOR`. Moved into world scene Graphics at depth=-1e9 so
|
|
1688
|
+
// entities dropped outside world bounds render on top of the fill (was
|
|
1689
|
+
// behind it when drawn in overlay scene, hiding the sprite).
|
|
1690
|
+
const VOID_FILL_COLOR = 0x3a3a45;
|
|
1691
|
+
const VOID_FILL_ALPHA = 1;
|
|
1692
|
+
const VOID_FILL_DEPTH = -1e9;
|
|
1693
|
+
const VOID_FILL_EXTENT = 1000000;
|
|
1694
|
+
/**
|
|
1695
|
+
* Install a SEPARATE editor camera on the world scene + hide the game's
|
|
1696
|
+
* runtime camera so the editor view doesn't double-render. Initial state
|
|
1697
|
+
* copied from cameras.main so the user opens edit mode looking at "what
|
|
1698
|
+
* the player would see right now."
|
|
1699
|
+
*
|
|
1700
|
+
* Idempotent — calling twice is a no-op (the editor cam is keyed onto the
|
|
1701
|
+
* game object). Editor cam reference is also exposed via `getEditorCamera`
|
|
1702
|
+
* for `applyEditorPanZoom` + the overlay scene's mirror.
|
|
1703
|
+
*/
|
|
1704
|
+
function installEditorCameras(game) {
|
|
1705
|
+
const worldScene = findWorldScene(game);
|
|
1706
|
+
if (!worldScene)
|
|
1707
|
+
return;
|
|
1708
|
+
// Idempotent install (2026-05-17): if a stale editor cam reference
|
|
1709
|
+
// exists (e.g. an exit cycle didn't fully clear it), remove it
|
|
1710
|
+
// before installing a fresh one. The previous early-return left the
|
|
1711
|
+
// game cam visible (since `gameCam.setVisible(false)` below was
|
|
1712
|
+
// skipped), producing the "game renders top-left, no editor
|
|
1713
|
+
// decorations" bug on second enter.
|
|
1714
|
+
const existing = game[EDITOR_CAM_KEY];
|
|
1715
|
+
if (existing) {
|
|
1716
|
+
try {
|
|
1717
|
+
worldScene.cameras.remove(existing);
|
|
1718
|
+
}
|
|
1719
|
+
catch { /* stale cam already gone */ }
|
|
1720
|
+
delete game[EDITOR_CAM_KEY];
|
|
1721
|
+
}
|
|
1722
|
+
const gameCam = worldScene.cameras.main;
|
|
1723
|
+
// Remember whether the game cam was visible so we can restore it exactly.
|
|
1724
|
+
game[GAME_CAM_HIDDEN_KEY] = gameCam.visible;
|
|
1725
|
+
gameCam.setVisible(false);
|
|
1726
|
+
const canvasW = game.scale.width;
|
|
1727
|
+
const canvasH = game.scale.height;
|
|
1728
|
+
const editorCam = worldScene.cameras.add(0, 0, canvasW, canvasH);
|
|
1729
|
+
// Editor cam's bg shows through to canvas behind it — we want the editor
|
|
1730
|
+
// overlay's outside-fill graphics to be visible, so make the camera bg
|
|
1731
|
+
// transparent.
|
|
1732
|
+
editorCam.setBackgroundColor(0);
|
|
1733
|
+
// Phase D fix — snap to integer pixels so tilemap NEAREST sampling
|
|
1734
|
+
// doesn't catch transparent neighbor-tile pixels at subpixel offsets.
|
|
1735
|
+
// See spawnEntity.ts renderLayerInto for the matching Play-mode setting.
|
|
1736
|
+
editorCam.setRoundPixels(true);
|
|
1737
|
+
// Initial editor view: match the game's display size in Play mode —
|
|
1738
|
+
// i.e., fit the WHOLE world inside the canvas with NO margin. Play
|
|
1739
|
+
// mode uses Phaser FIT scaling against an iframe that's sized to the
|
|
1740
|
+
// game's AR, so the game renders at maximum size that fits the
|
|
1741
|
+
// iframe. In edit mode the canvas is now wider/taller than the game
|
|
1742
|
+
// (it fills the iframe regardless of AR), so we need to compute the
|
|
1743
|
+
// same fit zoom ourselves: `min(canvasW/worldW, canvasH/worldH)`.
|
|
1744
|
+
// The extra canvas area beyond the game becomes the editor void.
|
|
1745
|
+
//
|
|
1746
|
+
// History: 0.2.82 defaulted to zoom=1 which made the game look BIGGER
|
|
1747
|
+
// than in Play mode (a 1280-tall world at zoom 1 clipped vertically
|
|
1748
|
+
// in a 760-tall canvas) — user pushed back ("还不如你的53%"). The 53%
|
|
1749
|
+
// came from 0.2.81's fit*0.9 logic; same intent, but the 0.9 margin
|
|
1750
|
+
// made it visibly smaller than Play. Dropping the margin matches Play
|
|
1751
|
+
// exactly — game looks the same as it did before clicking Edit.
|
|
1752
|
+
const sceneFile = readActiveSceneFile(game);
|
|
1753
|
+
const snap = game[CANVAS_SCALE_SNAPSHOT_KEY];
|
|
1754
|
+
const worldW = sceneFile?.world.width ?? snap?.gameWidth ?? canvasW;
|
|
1755
|
+
const worldH = sceneFile?.world.height ?? snap?.gameHeight ?? canvasH;
|
|
1756
|
+
const editorZoom = Math.min(canvasW / worldW, canvasH / worldH);
|
|
1757
|
+
editorCam.setZoom(editorZoom);
|
|
1758
|
+
editorCam.scrollX = worldW / 2 - canvasW / (2 * editorZoom);
|
|
1759
|
+
editorCam.scrollY = worldH / 2 - canvasH / (2 * editorZoom);
|
|
1760
|
+
game[EDITOR_CAM_KEY] = editorCam;
|
|
1761
|
+
// Sync HUD scene's camera to render INSIDE the camera viewport rect
|
|
1762
|
+
// on the editor canvas (so HUD widgets visually track the camera
|
|
1763
|
+
// viewport rect during pan/zoom — matching what the player will see
|
|
1764
|
+
// at runtime).
|
|
1765
|
+
syncHudCameraToEditorCam(game);
|
|
1766
|
+
}
|
|
1767
|
+
/**
|
|
1768
|
+
* Tear down the editor camera + re-show the game's runtime camera. Inverse
|
|
1769
|
+
* of {@link installEditorCameras}. Called from `exitEdit` BEFORE resuming
|
|
1770
|
+
* scenes so the first resumed frame renders through cameras.main.
|
|
1771
|
+
*/
|
|
1772
|
+
function uninstallEditorCameras(game) {
|
|
1773
|
+
const worldScene = findWorldScene(game);
|
|
1774
|
+
const editorCam = game[EDITOR_CAM_KEY];
|
|
1775
|
+
if (worldScene && editorCam) {
|
|
1776
|
+
worldScene.cameras.remove(editorCam);
|
|
1777
|
+
}
|
|
1778
|
+
if (worldScene) {
|
|
1779
|
+
const wasVisible = game[GAME_CAM_HIDDEN_KEY];
|
|
1780
|
+
worldScene.cameras.main.setVisible(wasVisible !== false);
|
|
1781
|
+
}
|
|
1782
|
+
delete game[EDITOR_CAM_KEY];
|
|
1783
|
+
delete game[GAME_CAM_HIDDEN_KEY];
|
|
1784
|
+
// Restore HUD camera to identity (Play-mode behavior) so HUD widgets
|
|
1785
|
+
// return to canvas-edge anchoring once Phaser resizes back to game
|
|
1786
|
+
// intrinsic canvas dims.
|
|
1787
|
+
restoreHudCamera(game);
|
|
1788
|
+
}
|
|
1789
|
+
/**
|
|
1790
|
+
* Install a grey "void" fill in the world scene at very low depth so
|
|
1791
|
+
* the area outside world bounds reads as editor surface (not the
|
|
1792
|
+
* game's solid black canvas bg). Drawn as a single Graphics with four
|
|
1793
|
+
* rects extending ±VOID_FILL_EXTENT (1e6) around world bounds; camera
|
|
1794
|
+
* clips offscreen for free. Lives in the world scene (not overlay) at
|
|
1795
|
+
* depth `-1e9` so entities at default depth render ON TOP of it —
|
|
1796
|
+
* fixes the 2026-05-17 bug where dropped entities outside world bounds
|
|
1797
|
+
* were visually covered by the overlay-scene grey fill.
|
|
1798
|
+
*/
|
|
1799
|
+
function installVoidFill(game) {
|
|
1800
|
+
const worldScene = findWorldScene(game);
|
|
1801
|
+
if (!worldScene)
|
|
1802
|
+
return;
|
|
1803
|
+
// Idempotent: tear down stale fill before installing fresh.
|
|
1804
|
+
uninstallVoidFill(game);
|
|
1805
|
+
// Prefer scene-file dims (scene-as-data games declare world bounds
|
|
1806
|
+
// explicitly). Fall back to the snapshot's intrinsic game size for
|
|
1807
|
+
// pure scene-as-code games (no scene file in cache) — see game
|
|
1808
|
+
// 3167e9ee (Snake) which surfaced this 2026-05-17. Without the
|
|
1809
|
+
// fallback the void fill never installed and the editor showed the
|
|
1810
|
+
// game's solid bg color (black for Snake) instead of editor grey.
|
|
1811
|
+
const sceneFile = readActiveSceneFile(game);
|
|
1812
|
+
const snap = game[CANVAS_SCALE_SNAPSHOT_KEY];
|
|
1813
|
+
const wbX = 0;
|
|
1814
|
+
const wbY = 0;
|
|
1815
|
+
const wbR = sceneFile?.world.width ?? snap?.gameWidth ?? game.scale.width;
|
|
1816
|
+
const wbB = sceneFile?.world.height ?? snap?.gameHeight ?? game.scale.height;
|
|
1817
|
+
const B = VOID_FILL_EXTENT;
|
|
1818
|
+
const g = worldScene.add.graphics();
|
|
1819
|
+
g.setDepth(VOID_FILL_DEPTH);
|
|
1820
|
+
g.fillStyle(VOID_FILL_COLOR, VOID_FILL_ALPHA);
|
|
1821
|
+
g.fillRect(-B, -B, 2 * B, B + wbY); // top strip
|
|
1822
|
+
g.fillRect(-B, wbB, 2 * B, B - wbB); // bottom strip
|
|
1823
|
+
g.fillRect(-B, wbY, B + wbX, wbB - wbY); // left strip
|
|
1824
|
+
g.fillRect(wbR, wbY, B - wbR, wbB - wbY); // right strip
|
|
1825
|
+
// Hide from the game cam (which is invisible anyway, but be explicit
|
|
1826
|
+
// — if a future code path un-hides game cam during edit, the void
|
|
1827
|
+
// fill shouldn't show through there).
|
|
1828
|
+
g.setData('__unboxyVoidFill', true);
|
|
1829
|
+
game[VOID_FILL_KEY] = g;
|
|
1830
|
+
}
|
|
1831
|
+
function uninstallVoidFill(game) {
|
|
1832
|
+
const bag = game;
|
|
1833
|
+
const g = bag[VOID_FILL_KEY];
|
|
1834
|
+
if (g) {
|
|
1835
|
+
try {
|
|
1836
|
+
g.destroy();
|
|
1837
|
+
}
|
|
1838
|
+
catch { /* already gone */ }
|
|
1839
|
+
delete bag[VOID_FILL_KEY];
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1842
|
+
/**
|
|
1843
|
+
* Sync the HUD scene's main camera to render INSIDE the camera viewport
|
|
1844
|
+
* rect on the editor canvas, scaled by the editor cam's zoom. Without
|
|
1845
|
+
* this, HUD widgets stay anchored to the EXPANDED canvas corners — far
|
|
1846
|
+
* from where the player will actually see them at runtime — and don't
|
|
1847
|
+
* track when the user pans/zooms the editor view. With this, HUD
|
|
1848
|
+
* widgets visually stick to the blue camera-viewport rect, matching
|
|
1849
|
+
* what the player sees in Play mode.
|
|
1850
|
+
*
|
|
1851
|
+
* Pair with `resolveAnchor`'s snapshot-aware logic in HudRuntime — that
|
|
1852
|
+
* makes HUD widgets compute positions in GAME-intrinsic coords; this
|
|
1853
|
+
* function projects those coords onto the camera-viewport-rect area
|
|
1854
|
+
* of the editor canvas.
|
|
1855
|
+
*
|
|
1856
|
+
* Idempotent. Called from `installEditorCameras` + `applyEditorPanZoom`
|
|
1857
|
+
* + `resizeCanvasToIframe`'s rAF tick so HUD tracks all editor view
|
|
1858
|
+
* mutations.
|
|
1859
|
+
*/
|
|
1860
|
+
function syncHudCameraToEditorCam(game) {
|
|
1861
|
+
const hudScene = game.scene.getScene(UMICAT_HUD_SCENE_KEY);
|
|
1862
|
+
if (!hudScene)
|
|
1863
|
+
return;
|
|
1864
|
+
const hudCam = hudScene.cameras.main;
|
|
1865
|
+
if (!hudCam)
|
|
1866
|
+
return;
|
|
1867
|
+
const editorCam = getEditorCamera(game);
|
|
1868
|
+
const worldScene = findWorldScene(game);
|
|
1869
|
+
if (!editorCam || !worldScene)
|
|
1870
|
+
return;
|
|
1871
|
+
// Use FULL canvas viewport with a SCROLL offset (not a positioned
|
|
1872
|
+
// viewport). The original approach (0.2.89) set HUD cam viewport to
|
|
1873
|
+
// the camera-viewport rect's canvas position + size — visually
|
|
1874
|
+
// equivalent for HUD widgets positioned inside the rect, but it
|
|
1875
|
+
// caused two issues at zoom levels where the camera-viewport rect
|
|
1876
|
+
// extends past the canvas edge:
|
|
1877
|
+
// 1. Negative viewport coords (Phaser clips them weirdly)
|
|
1878
|
+
// 2. Viewport-positioned clipping cut off widgets as the rect
|
|
1879
|
+
// moved during cursor-anchored zoom
|
|
1880
|
+
// Scroll-based achieves the same widget placement without the
|
|
1881
|
+
// clip artifacts (full canvas viewport → no clipping).
|
|
1882
|
+
//
|
|
1883
|
+
// Math: widget at HUD-intrinsic (X, Y) renders at
|
|
1884
|
+
// canvas X = viewport.x + (X - scroll.x) * zoom = (X - scroll.x) * zoom
|
|
1885
|
+
// We want it to equal camRectCanvasX + X * zoom (where the camera-
|
|
1886
|
+
// viewport rect renders on the editor canvas), so:
|
|
1887
|
+
// scroll.x = -camRectCanvasX / zoom = editorCam.scrollX - gameCam.scrollX
|
|
1888
|
+
const gameCam = worldScene.cameras.main;
|
|
1889
|
+
hudCam.setViewport(0, 0, game.scale.width, game.scale.height);
|
|
1890
|
+
hudCam.setZoom(editorCam.zoom);
|
|
1891
|
+
hudCam.setScroll(editorCam.scrollX - gameCam.scrollX, editorCam.scrollY - gameCam.scrollY);
|
|
1892
|
+
}
|
|
1893
|
+
/**
|
|
1894
|
+
* Restore the HUD scene's main camera to its default identity (full
|
|
1895
|
+
* canvas viewport at zoom 1, scroll 0) — the Play-mode state. Called
|
|
1896
|
+
* from `uninstallEditorCameras` and `restoreCanvasAfterEditor`.
|
|
1897
|
+
*/
|
|
1898
|
+
function restoreHudCamera(game) {
|
|
1899
|
+
const hudScene = game.scene.getScene(UMICAT_HUD_SCENE_KEY);
|
|
1900
|
+
if (!hudScene)
|
|
1901
|
+
return;
|
|
1902
|
+
const hudCam = hudScene.cameras.main;
|
|
1903
|
+
if (!hudCam)
|
|
1904
|
+
return;
|
|
1905
|
+
hudCam.setViewport(0, 0, game.scale.width, game.scale.height);
|
|
1906
|
+
hudCam.setZoom(1);
|
|
1907
|
+
hudCam.setScroll(0, 0);
|
|
1908
|
+
}
|
|
1909
|
+
/**
|
|
1910
|
+
* Read-only access to the editor camera. Null when edit mode hasn't been
|
|
1911
|
+
* entered yet OR was just exited. Used by `applyEditorPanZoom`,
|
|
1912
|
+
* `postCameraState`, and the overlay scene's mirror in `create`.
|
|
1913
|
+
*/
|
|
1914
|
+
export function getEditorCamera(game) {
|
|
1915
|
+
return (game[EDITOR_CAM_KEY] ?? null);
|
|
1916
|
+
}
|
|
1917
|
+
/**
|
|
1918
|
+
* Toggle HUD scene rendering. HUD widgets are canvas-relative (anchor-
|
|
1919
|
+
* positioned to canvas edges), so in world edit mode — where the user is
|
|
1920
|
+
* panning/zooming the world view — the HUD would float visually wrong
|
|
1921
|
+
* (HUD widgets would stay glued to canvas corners regardless of editor
|
|
1922
|
+
* pan). Hide HUD in world edit; show it in HUD edit (the editing surface).
|
|
1923
|
+
*
|
|
1924
|
+
* Phaser scene's `setVisible(false)` keeps the scene running but skips
|
|
1925
|
+
* rendering. Cheap toggle.
|
|
1926
|
+
*/
|
|
1927
|
+
function setHudVisibility(game, visible) {
|
|
1928
|
+
const hudScene = game.scene.getScene(UMICAT_HUD_SCENE_KEY);
|
|
1929
|
+
if (!hudScene)
|
|
1930
|
+
return;
|
|
1931
|
+
hudScene.scene.setVisible(visible);
|
|
1932
|
+
}
|
|
1933
|
+
// --- Slice 11 Phase B.5: prefab live-edit --------------------------------
|
|
1934
|
+
/**
|
|
1935
|
+
* Handle an `umicat:editor:editPrefab` postMessage — slice 11 B.5 / editor P0.2.
|
|
1936
|
+
*
|
|
1937
|
+
* Pipeline:
|
|
1938
|
+
* 1. Locate the prefab in the cached manifest. Deep-merge the patch into
|
|
1939
|
+
* the record so subsequent `spawnPrefab` calls pick up the new values.
|
|
1940
|
+
* 2. Walk every live instance via `EntityRegistry.byPrefabId(prefabId)`
|
|
1941
|
+
* (populated by `spawnPrefab` — instances tagged with `entityPrefabId`).
|
|
1942
|
+
* 3. Re-apply visual + physics + property changes to each instance. Visual
|
|
1943
|
+
* reuses the same paths `applyEdit` uses for world entities so behavior
|
|
1944
|
+
* is consistent (Inspector edits on a world entity and on a prefab feel
|
|
1945
|
+
* identical to the user). Physics mutates the existing Arcade body
|
|
1946
|
+
* in place — no `physics.add.existing` recreate, no GameObject reuse
|
|
1947
|
+
* churn.
|
|
1948
|
+
*
|
|
1949
|
+
* No-op when the manifest isn't cached yet (edit fired during boot race) or
|
|
1950
|
+
* the prefab id isn't declared (host should validate first, but we soft-fail).
|
|
1951
|
+
*/
|
|
1952
|
+
function handleEditPrefab(game, prefabId, patch) {
|
|
1953
|
+
const manifest = readActiveManifest(game);
|
|
1954
|
+
if (!manifest || !Array.isArray(manifest.prefabs)) {
|
|
1955
|
+
console.warn(`[umicat/editor] editPrefab: no manifest cached, cannot apply patch for '${prefabId}'`);
|
|
1956
|
+
return;
|
|
1957
|
+
}
|
|
1958
|
+
const idx = manifest.prefabs.findIndex((p) => p.id === prefabId);
|
|
1959
|
+
if (idx < 0) {
|
|
1960
|
+
console.warn(`[umicat/editor] editPrefab: no prefab with id '${prefabId}' in manifest`);
|
|
1961
|
+
return;
|
|
1962
|
+
}
|
|
1963
|
+
// Merge patch into the cached record so subsequent spawns inherit it.
|
|
1964
|
+
manifest.prefabs[idx] = mergePrefabRecord(manifest.prefabs[idx], patch);
|
|
1965
|
+
// Walk live instances and re-apply each patched section. Lives in the
|
|
1966
|
+
// world-scene registry (prefabs only spawn into world scenes — HUD has
|
|
1967
|
+
// its own runtime).
|
|
1968
|
+
const registry = findWorldScene(game) && getEntityRegistry(findWorldScene(game));
|
|
1969
|
+
if (!registry)
|
|
1970
|
+
return;
|
|
1971
|
+
for (const go of registry.byPrefabId(prefabId)) {
|
|
1972
|
+
applyPrefabPatchToInstance(game, go, patch);
|
|
1973
|
+
}
|
|
1974
|
+
}
|
|
1975
|
+
function mergePrefabRecord(prev, patch) {
|
|
1976
|
+
// SDK 0.3.0: render fields live at the patch root (assetId / tint / width /
|
|
1977
|
+
// fillColor / params / etc.), same flat shape as the prefab record itself.
|
|
1978
|
+
// Copy each render-field key that's present in the patch into the merged
|
|
1979
|
+
// record. Non-render slots (physics / properties / role) stay in their
|
|
1980
|
+
// own slots.
|
|
1981
|
+
const merged = { ...prev };
|
|
1982
|
+
const RENDER_KEYS = [
|
|
1983
|
+
'assetId', 'frame', 'tint', 'alpha', 'flipX', 'flipY',
|
|
1984
|
+
'width', 'height', 'radius', 'fillColor', 'strokeColor', 'strokeWidth',
|
|
1985
|
+
'params',
|
|
1986
|
+
];
|
|
1987
|
+
for (const k of RENDER_KEYS) {
|
|
1988
|
+
const v = patch[k];
|
|
1989
|
+
if (v === undefined)
|
|
1990
|
+
continue;
|
|
1991
|
+
if (k === 'params' && isPlainObject(merged.params) && isPlainObject(v)) {
|
|
1992
|
+
merged.params = { ...merged.params, ...v };
|
|
1993
|
+
}
|
|
1994
|
+
else {
|
|
1995
|
+
merged[k] = v;
|
|
1996
|
+
}
|
|
1997
|
+
}
|
|
1998
|
+
if (patch.physics) {
|
|
1999
|
+
merged.physics = { ...(prev.physics ?? {}), ...patch.physics };
|
|
2000
|
+
}
|
|
2001
|
+
if (patch.properties) {
|
|
2002
|
+
merged.properties = { ...(prev.properties ?? {}), ...patch.properties };
|
|
2003
|
+
}
|
|
2004
|
+
if (patch.role !== undefined) {
|
|
2005
|
+
if (patch.role === null)
|
|
2006
|
+
delete merged.role;
|
|
2007
|
+
else
|
|
2008
|
+
merged.role = patch.role;
|
|
2009
|
+
}
|
|
2010
|
+
return merged;
|
|
2011
|
+
}
|
|
2012
|
+
/**
|
|
2013
|
+
* Re-apply a prefab patch to a single live instance.
|
|
2014
|
+
*
|
|
2015
|
+
* Visual: same paths `applyEdit` uses for world entities — `applyVisualPatch`
|
|
2016
|
+
* for sprite tint / alpha / size etc., `applyCodeRenderedParamsPatch` for
|
|
2017
|
+
* render-script param re-render.
|
|
2018
|
+
*
|
|
2019
|
+
* Physics: mutate the existing Arcade body in place (size / offset /
|
|
2020
|
+
* immovable / velocity / bounce). No recreation; no rendering change.
|
|
2021
|
+
*
|
|
2022
|
+
* Properties: merge into the GameObject's data-manager `entityProperties`
|
|
2023
|
+
* slot so behavior code reading `go.getData('entityProperties').hp` sees
|
|
2024
|
+
* the new value on next frame.
|
|
2025
|
+
*/
|
|
2026
|
+
function applyPrefabPatchToInstance(game, go, patch) {
|
|
2027
|
+
// SDK 0.3.0: render fields live at the patch root. Apply them through the
|
|
2028
|
+
// same paths `applyEdit` uses for world entities — `applyVisualPatch` for
|
|
2029
|
+
// sprite tint / alpha / size / etc., `applyCodeRenderedParamsPatch` for
|
|
2030
|
+
// render-script param re-render.
|
|
2031
|
+
applyVisualPatch(go, patch);
|
|
2032
|
+
applyCodeRenderedParamsPatch(game, go, patch.params);
|
|
2033
|
+
if (patch.physics) {
|
|
2034
|
+
applyPhysicsPatchToBody(go, patch.physics);
|
|
2035
|
+
}
|
|
2036
|
+
if (patch.properties) {
|
|
2037
|
+
const prev = go.getData('entityProperties') ?? {};
|
|
2038
|
+
go.setData('entityProperties', { ...prev, ...patch.properties });
|
|
2039
|
+
}
|
|
2040
|
+
if (patch.role !== undefined) {
|
|
2041
|
+
go.setData('entityRole', patch.role ?? undefined);
|
|
2042
|
+
}
|
|
2043
|
+
}
|
|
2044
|
+
/**
|
|
2045
|
+
* Mutate an existing Arcade body with the patched physics fields.
|
|
2046
|
+
*
|
|
2047
|
+
* Distinct from `spawnEntity.ts`'s `applyEntityPhysics`: that one *creates*
|
|
2048
|
+
* the body (calls `scene.physics.add.existing`) and computes defaults from
|
|
2049
|
+
* the visual extent. This one only touches fields the patch carries — every
|
|
2050
|
+
* other body property is left as-is. No body recreate, no origin shift
|
|
2051
|
+
* (code-rendered origin shift only matters at body creation; size/offset
|
|
2052
|
+
* patches respect the existing frame).
|
|
2053
|
+
*/
|
|
2054
|
+
function applyPhysicsPatchToBody(go, patch) {
|
|
2055
|
+
const body = go.body;
|
|
2056
|
+
if (!body)
|
|
2057
|
+
return;
|
|
2058
|
+
if (typeof patch.bodyW === 'number' || typeof patch.bodyH === 'number') {
|
|
2059
|
+
body.setSize(typeof patch.bodyW === 'number' ? patch.bodyW : body.width, typeof patch.bodyH === 'number' ? patch.bodyH : body.height);
|
|
2060
|
+
}
|
|
2061
|
+
if (typeof patch.offsetX === 'number' || typeof patch.offsetY === 'number') {
|
|
2062
|
+
body.setOffset(typeof patch.offsetX === 'number' ? patch.offsetX : body.offset.x, typeof patch.offsetY === 'number' ? patch.offsetY : body.offset.y);
|
|
2063
|
+
}
|
|
2064
|
+
if (typeof patch.immovable === 'boolean')
|
|
2065
|
+
body.setImmovable(patch.immovable);
|
|
2066
|
+
if (typeof patch.velocityX === 'number' || typeof patch.velocityY === 'number') {
|
|
2067
|
+
body.setVelocity(typeof patch.velocityX === 'number' ? patch.velocityX : body.velocity.x, typeof patch.velocityY === 'number' ? patch.velocityY : body.velocity.y);
|
|
2068
|
+
}
|
|
2069
|
+
if (typeof patch.collideWorldBounds === 'boolean') {
|
|
2070
|
+
body.setCollideWorldBounds(patch.collideWorldBounds);
|
|
2071
|
+
}
|
|
2072
|
+
if (typeof patch.bounceX === 'number' || typeof patch.bounceY === 'number') {
|
|
2073
|
+
body.setBounce(typeof patch.bounceX === 'number' ? patch.bounceX : body.bounce.x, typeof patch.bounceY === 'number' ? patch.bounceY : body.bounce.y);
|
|
2074
|
+
}
|
|
2075
|
+
}
|
|
2076
|
+
function isPlainObject(v) {
|
|
2077
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
2078
|
+
}
|
|
2079
|
+
// --- Slice 11 Phase B.5 / editor P0.3: rule live-edit --------------------
|
|
2080
|
+
/**
|
|
2081
|
+
* Handle `umicat:editor:patchRule { path, value }` — slice 11 B.5 / P0.3.
|
|
2082
|
+
*
|
|
2083
|
+
* Delegates to `Rules.ts`'s `patchRule(scene, path, value)` which:
|
|
2084
|
+
* - Writes the path into the cached rules tree (subsequent `getRule`
|
|
2085
|
+
* calls return the new value).
|
|
2086
|
+
* - Fires every subscriber whose path matches or is an ancestor of the
|
|
2087
|
+
* patched path — game code that called `onRuleChange(this, 'balance.lives', cb)`
|
|
2088
|
+
* sees the new value on the next frame.
|
|
2089
|
+
*
|
|
2090
|
+
* Game code that read rules via `getRule` ONCE in `create()` won't see
|
|
2091
|
+
* the change unless it subscribed; that's by design (most rules are
|
|
2092
|
+
* configuration, not live-tunable). See SDK-GUIDE.md "Rules" chapter for
|
|
2093
|
+
* the reactive subscription pattern.
|
|
2094
|
+
*
|
|
2095
|
+
* No-op when no world scene is loaded (edit fired during boot race).
|
|
2096
|
+
*/
|
|
2097
|
+
function handlePatchRule(game, path, value) {
|
|
2098
|
+
const scene = findWorldScene(game);
|
|
2099
|
+
if (!scene) {
|
|
2100
|
+
console.warn(`[umicat/editor] patchRule: no world scene to patch '${path}' on`);
|
|
2101
|
+
return;
|
|
2102
|
+
}
|
|
2103
|
+
patchRule(scene, path, value);
|
|
2104
|
+
}
|
|
2105
|
+
// --- Slice 6 Phase B — tilemap painter -----------------------------------
|
|
2106
|
+
function handleSetTilemapTool(game, msg) {
|
|
2107
|
+
setTilemapToolState(game, {
|
|
2108
|
+
tilemapEditingId: msg.tilemapEditingId,
|
|
2109
|
+
activeLayerId: msg.activeLayerId,
|
|
2110
|
+
tool: msg.tool,
|
|
2111
|
+
activeTile: msg.activeTile,
|
|
2112
|
+
activeTerrainId: msg.activeTerrainId ?? null,
|
|
2113
|
+
});
|
|
2114
|
+
}
|
|
2115
|
+
/**
|
|
2116
|
+
* Apply one or more tilemap edit ops to the live Phaser TilemapLayer(s).
|
|
2117
|
+
*
|
|
2118
|
+
* Called from two paths:
|
|
2119
|
+
* 1. Host pushes `umicat:editor:editTilemap` (agent writes, undo/redo
|
|
2120
|
+
* replay) — see EditorBridge.handleMessage dispatch.
|
|
2121
|
+
* 2. SDK's pointer-up handler (live painter stroke) — calls this directly
|
|
2122
|
+
* after composing the stroke's accumulated cells into one op, before
|
|
2123
|
+
* posting `umicat:editor:tilemapEdited` to the host.
|
|
2124
|
+
*
|
|
2125
|
+
* Mirror of handleEditPrefab's pattern: lookup container in registry → find
|
|
2126
|
+
* matching layer child by `tilemapLayerId` data tag → apply Phaser tilemap
|
|
2127
|
+
* mutation API. Soft-fails (warn + skip) on missing entity / missing layer
|
|
2128
|
+
* / out-of-bounds cell so a stale op doesn't crash the runtime.
|
|
2129
|
+
*/
|
|
2130
|
+
/**
|
|
2131
|
+
* Apply tilemap edit ops to live runtime. Public because the editor's
|
|
2132
|
+
* own drag-resize commit (in EditorOverlayScene) needs to apply the op
|
|
2133
|
+
* locally before posting `tilemapEdited` for host undo recording —
|
|
2134
|
+
* same pattern as brush/rect/fill commits (apply local, post for undo).
|
|
2135
|
+
* Skipping the local apply leaves SDK runtime + host draft out of sync
|
|
2136
|
+
* (host knows the new size, SDK keeps rendering old size → bounds snap-
|
|
2137
|
+
* back UX).
|
|
2138
|
+
*
|
|
2139
|
+
* Async because addLayer ops can reference a fresh tileset whose
|
|
2140
|
+
* texture hasn't loaded yet. Caller supplies optional `manifestAsset`
|
|
2141
|
+
* (same pattern as createEntity's drag-to-place) — handler upserts
|
|
2142
|
+
* cache + AWAITS texture load before invoking applyTilemapStructureOp.
|
|
2143
|
+
* Without the await, addLayer's `textures.exists()` check fails on
|
|
2144
|
+
* the in-flight load → skips layer creation → painter can't find
|
|
2145
|
+
* layer → click falls through to drag-the-entity.
|
|
2146
|
+
*/
|
|
2147
|
+
export async function handleEditTilemap(game, entityId, ops, manifestAsset) {
|
|
2148
|
+
const scene = findWorldScene(game);
|
|
2149
|
+
if (!scene) {
|
|
2150
|
+
console.warn(`[umicat/editor] editTilemap: no world scene for '${entityId}'`);
|
|
2151
|
+
return;
|
|
2152
|
+
}
|
|
2153
|
+
// Upsert + await asset load BEFORE any op runs. addLayer is the
|
|
2154
|
+
// primary consumer of this; other ops (paint/erase/resize) don't
|
|
2155
|
+
// need it but harmless to upsert idempotently.
|
|
2156
|
+
if (manifestAsset) {
|
|
2157
|
+
upsertCachedManifestAsset(scene, manifestAsset);
|
|
2158
|
+
await ensureAssetLoaded(scene, manifestAsset);
|
|
2159
|
+
}
|
|
2160
|
+
const registry = getEntityRegistry(scene);
|
|
2161
|
+
if (!registry)
|
|
2162
|
+
return;
|
|
2163
|
+
const container = registry.byId(entityId);
|
|
2164
|
+
if (!container || !(container instanceof Phaser.GameObjects.Container)) {
|
|
2165
|
+
console.warn(`[umicat/editor] editTilemap: entity '${entityId}' is not a tilemap container`);
|
|
2166
|
+
return;
|
|
2167
|
+
}
|
|
2168
|
+
for (const op of ops) {
|
|
2169
|
+
// Phase B.5 / B.6 layer-structure ops mutate the layer list / dims,
|
|
2170
|
+
// not individual cell data. They take the container + scene, not a
|
|
2171
|
+
// single TilemapLayer, because addLayer creates a NEW layer (no
|
|
2172
|
+
// existing target), removeLayer destroys one, and resize rebuilds
|
|
2173
|
+
// all layers atomically.
|
|
2174
|
+
if (op.kind === 'addLayer' ||
|
|
2175
|
+
op.kind === 'removeLayer' ||
|
|
2176
|
+
op.kind === 'setLayerVisibility' ||
|
|
2177
|
+
op.kind === 'setLayerName' ||
|
|
2178
|
+
op.kind === 'resize') {
|
|
2179
|
+
applyTilemapStructureOp(scene, container, op);
|
|
2180
|
+
continue;
|
|
2181
|
+
}
|
|
2182
|
+
const layer = findTilemapLayerById(container, op.layerId);
|
|
2183
|
+
if (!layer) {
|
|
2184
|
+
console.warn(`[umicat/editor] editTilemap: layer '${op.layerId}' not found on tilemap '${entityId}'`);
|
|
2185
|
+
continue;
|
|
2186
|
+
}
|
|
2187
|
+
// Resolve the layer's tileset asset once — autotilePaint needs it
|
|
2188
|
+
// for the terrain lookup, sub-tile-body sync needs it after every op.
|
|
2189
|
+
const layerTilesetId = layer.getData('tilemapTilesetId');
|
|
2190
|
+
let layerAsset = null;
|
|
2191
|
+
if (layerTilesetId) {
|
|
2192
|
+
const manifest = getManifest(scene);
|
|
2193
|
+
layerAsset = manifest?.assets.find((a) => a.id === layerTilesetId) ?? null;
|
|
2194
|
+
}
|
|
2195
|
+
applyTilemapOp(layer, op, layerAsset);
|
|
2196
|
+
// Phase C follow-up — sub-tile collision rects. After any cell
|
|
2197
|
+
// mutation, re-sync the layer's sub-tile static body group from the
|
|
2198
|
+
// current `layer.data`. Cheap when the tileset has no collisionRect
|
|
2199
|
+
// (single map scan + early return). Without this, painting a new tile
|
|
2200
|
+
// with a collisionRect leaves the sub-rect body missing until next
|
|
2201
|
+
// scene reload.
|
|
2202
|
+
if (layerAsset) {
|
|
2203
|
+
const tilesetPhaser = layer.tileset[0];
|
|
2204
|
+
if (tilesetPhaser) {
|
|
2205
|
+
applyTilesetTileMetadata(tilesetPhaser, layer, layerAsset);
|
|
2206
|
+
// Phase F — re-arm tile animations after each paint op. A
|
|
2207
|
+
// newly-painted root tile starts animating immediately.
|
|
2208
|
+
applyTilesetAnimations(layer, layerAsset);
|
|
2209
|
+
}
|
|
2210
|
+
}
|
|
2211
|
+
}
|
|
2212
|
+
}
|
|
2213
|
+
/**
|
|
2214
|
+
* Layer-structure ops (add / remove / visibility) — slice 6 Phase B.5.
|
|
2215
|
+
* Mutate the tilemap container's list of `TilemapLayer` children, not
|
|
2216
|
+
* individual cells. Mirrors how `createTilemap` builds layers initially
|
|
2217
|
+
* (in spawnEntity.ts) — same Container parent, same per-layer tagging,
|
|
2218
|
+
* same centered (-w/2, -h/2) positioning.
|
|
2219
|
+
*/
|
|
2220
|
+
function applyTilemapStructureOp(scene, container, op) {
|
|
2221
|
+
switch (op.kind) {
|
|
2222
|
+
case 'addLayer': {
|
|
2223
|
+
// Resolve tilemap entity dims (stashed by spawnEntity.tagGameObject
|
|
2224
|
+
// on the container). Without these we can't size the new layer.
|
|
2225
|
+
const tileSize = container.getData('tilemapTileSize');
|
|
2226
|
+
const size = container.getData('tilemapSize');
|
|
2227
|
+
if (!tileSize || !size) {
|
|
2228
|
+
console.warn('[umicat/editor] addLayer: container missing tilemap dims');
|
|
2229
|
+
return;
|
|
2230
|
+
}
|
|
2231
|
+
const tilesetId = op.layer.tilesetIds?.[0];
|
|
2232
|
+
if (!tilesetId) {
|
|
2233
|
+
console.warn(`[umicat/editor] addLayer '${op.layer.id}' has no tilesetIds`);
|
|
2234
|
+
return;
|
|
2235
|
+
}
|
|
2236
|
+
const manifest = getManifest(scene);
|
|
2237
|
+
const asset = manifest?.assets.find((a) => a.id === tilesetId);
|
|
2238
|
+
if (!asset) {
|
|
2239
|
+
console.warn(`[umicat/editor] addLayer: tileset '${tilesetId}' not in manifest`);
|
|
2240
|
+
return;
|
|
2241
|
+
}
|
|
2242
|
+
if (!scene.textures.exists(asset.textureKey)) {
|
|
2243
|
+
console.warn(`[umicat/editor] addLayer: tileset texture '${asset.textureKey}' not loaded; skipping`);
|
|
2244
|
+
return;
|
|
2245
|
+
}
|
|
2246
|
+
const cellW = asset.tileset?.cellSize.width
|
|
2247
|
+
?? asset.spriteSheetConfig?.frameWidth
|
|
2248
|
+
?? asset.frameWidth
|
|
2249
|
+
?? tileSize.width;
|
|
2250
|
+
const cellH = asset.tileset?.cellSize.height
|
|
2251
|
+
?? asset.spriteSheetConfig?.frameHeight
|
|
2252
|
+
?? asset.frameHeight
|
|
2253
|
+
?? tileSize.height;
|
|
2254
|
+
// Build an empty grid of -1 (Phaser's "no tile" sentinel) so the
|
|
2255
|
+
// new layer is immediately paintable. Same shape as Phase A's
|
|
2256
|
+
// empty-layer materialization path in renderLayerInto.
|
|
2257
|
+
const grid = [];
|
|
2258
|
+
for (let y = 0; y < size.height; y++) {
|
|
2259
|
+
grid.push(new Array(size.width).fill(-1));
|
|
2260
|
+
}
|
|
2261
|
+
let map;
|
|
2262
|
+
try {
|
|
2263
|
+
map = scene.make.tilemap({
|
|
2264
|
+
data: grid,
|
|
2265
|
+
tileWidth: cellW,
|
|
2266
|
+
tileHeight: cellH,
|
|
2267
|
+
});
|
|
2268
|
+
}
|
|
2269
|
+
catch (e) {
|
|
2270
|
+
console.warn(`[umicat/editor] addLayer: tilemap construction failed: ${String(e)}`);
|
|
2271
|
+
return;
|
|
2272
|
+
}
|
|
2273
|
+
const tileset = map.addTilesetImage(tilesetId, asset.textureKey, cellW, cellH, asset.tileset?.margin ?? 0, asset.tileset?.spacing ?? 0);
|
|
2274
|
+
if (!tileset) {
|
|
2275
|
+
console.warn(`[umicat/editor] addLayer: addTilesetImage null for '${tilesetId}'`);
|
|
2276
|
+
return;
|
|
2277
|
+
}
|
|
2278
|
+
const layerObj = map.createLayer(0, tileset, 0, 0);
|
|
2279
|
+
if (!layerObj) {
|
|
2280
|
+
console.warn(`[umicat/editor] addLayer: createLayer null for '${op.layer.id}'`);
|
|
2281
|
+
return;
|
|
2282
|
+
}
|
|
2283
|
+
if (cellW !== tileSize.width || cellH !== tileSize.height) {
|
|
2284
|
+
layerObj.setScale(tileSize.width / cellW, tileSize.height / cellH);
|
|
2285
|
+
}
|
|
2286
|
+
// Position in WORLD coords (NOT relative to container). See
|
|
2287
|
+
// createTilemap for why TilemapLayers can't be Container children.
|
|
2288
|
+
const pxW = size.width * tileSize.width;
|
|
2289
|
+
const pxH = size.height * tileSize.height;
|
|
2290
|
+
const left = -pxW / 2;
|
|
2291
|
+
const top = -pxH / 2;
|
|
2292
|
+
layerObj.x = container.x + left;
|
|
2293
|
+
layerObj.y = container.y + top;
|
|
2294
|
+
if (op.layer.visible === false)
|
|
2295
|
+
layerObj.setVisible(false);
|
|
2296
|
+
layerObj.setData('tilemapLayerId', op.layer.id);
|
|
2297
|
+
// Stash tileset asset id so live `assetUpdate` (Tile Metadata
|
|
2298
|
+
// Editor save) can find this layer + re-apply metadata.
|
|
2299
|
+
layerObj.setData('tilemapTilesetId', tilesetId);
|
|
2300
|
+
// Slice 6 Phase C — apply per-tile metadata + auto-collision AFTER
|
|
2301
|
+
// positioning so sub-tile static bodies land at correct world
|
|
2302
|
+
// coords. Calling before positioning would place bodies near world
|
|
2303
|
+
// origin (the original bug visualized as collision bodies in the
|
|
2304
|
+
// top-left of the canvas).
|
|
2305
|
+
applyTilesetTileMetadata(tileset, layerObj, asset);
|
|
2306
|
+
// Phase F — arm tile animations on this fresh layer.
|
|
2307
|
+
applyTilesetAnimations(layerObj, asset);
|
|
2308
|
+
// Parent-entity tag so the per-frame sync hook can find which
|
|
2309
|
+
// container drives this layer's position.
|
|
2310
|
+
const entityId = container.getData('entityId');
|
|
2311
|
+
if (entityId)
|
|
2312
|
+
layerObj.setData('tilemapEntityId', entityId);
|
|
2313
|
+
layerObj.setData('tilemapLocalOffsetX', left);
|
|
2314
|
+
layerObj.setData('tilemapLocalOffsetY', top);
|
|
2315
|
+
// Append to the container's stashed layer refs array.
|
|
2316
|
+
const layers = container.getData('tilemapLayers') ?? [];
|
|
2317
|
+
layers.push(layerObj);
|
|
2318
|
+
container.setData('tilemapLayers', layers);
|
|
2319
|
+
break;
|
|
2320
|
+
}
|
|
2321
|
+
case 'removeLayer': {
|
|
2322
|
+
const layer = findTilemapLayerById(container, op.layerId);
|
|
2323
|
+
if (!layer)
|
|
2324
|
+
return;
|
|
2325
|
+
// Drop from the container's stashed layers + destroy. Layer is in
|
|
2326
|
+
// scene root (not a container child), so container.remove() is wrong.
|
|
2327
|
+
const layers = container.getData('tilemapLayers') ?? [];
|
|
2328
|
+
const filtered = layers.filter((l) => l !== layer);
|
|
2329
|
+
container.setData('tilemapLayers', filtered);
|
|
2330
|
+
layer.destroy();
|
|
2331
|
+
break;
|
|
2332
|
+
}
|
|
2333
|
+
case 'setLayerVisibility': {
|
|
2334
|
+
const layer = findTilemapLayerById(container, op.layerId);
|
|
2335
|
+
if (!layer)
|
|
2336
|
+
return;
|
|
2337
|
+
layer.setVisible(op.visible);
|
|
2338
|
+
break;
|
|
2339
|
+
}
|
|
2340
|
+
case 'setLayerName': {
|
|
2341
|
+
// Editor-only metadata — name has no runtime effect (SDK lookups
|
|
2342
|
+
// are by id, never by name). Persisted to scene JSON via the host
|
|
2343
|
+
// draft. No runtime mutation needed; explicit case so the dispatcher
|
|
2344
|
+
// doesn't fall into the cell-op path.
|
|
2345
|
+
break;
|
|
2346
|
+
}
|
|
2347
|
+
case 'resize': {
|
|
2348
|
+
// Resize is destructive on shrink (cells outside new bounds are
|
|
2349
|
+
// lost). Host UI guards with a confirm dialog when shrinking
|
|
2350
|
+
// would discard painted cells — by the time the op reaches the
|
|
2351
|
+
// SDK, the user has accepted the loss. We just need to rebuild
|
|
2352
|
+
// every layer with the new dims, copying old cell data into the
|
|
2353
|
+
// intersection of old × new bounds.
|
|
2354
|
+
const tileSize = container.getData('tilemapTileSize');
|
|
2355
|
+
if (!tileSize) {
|
|
2356
|
+
console.warn('[umicat/editor] resize: container missing tilemapTileSize');
|
|
2357
|
+
return;
|
|
2358
|
+
}
|
|
2359
|
+
const newW = op.width;
|
|
2360
|
+
const newH = op.height;
|
|
2361
|
+
if (newW <= 0 || newH <= 0)
|
|
2362
|
+
return;
|
|
2363
|
+
// Phase B.6.1 — handle-drag resizes shift the tilemap center to
|
|
2364
|
+
// keep the opposite edge anchored. Apply BEFORE rebuilding layers
|
|
2365
|
+
// so the new layers are positioned around the new center.
|
|
2366
|
+
if (op.transformDelta) {
|
|
2367
|
+
container.x += op.transformDelta.x;
|
|
2368
|
+
container.y += op.transformDelta.y;
|
|
2369
|
+
// Keep the entity-transform stash in sync so other systems (drag,
|
|
2370
|
+
// selection rect, etc.) read the current pos.
|
|
2371
|
+
const tx = container.getData('entityTransform');
|
|
2372
|
+
if (tx) {
|
|
2373
|
+
tx.x += op.transformDelta.x;
|
|
2374
|
+
tx.y += op.transformDelta.y;
|
|
2375
|
+
}
|
|
2376
|
+
}
|
|
2377
|
+
const oldLayers = container.getData('tilemapLayers') ?? [];
|
|
2378
|
+
const snapshots = [];
|
|
2379
|
+
for (const layer of oldLayers) {
|
|
2380
|
+
const id = layer.getData('tilemapLayerId');
|
|
2381
|
+
if (!id)
|
|
2382
|
+
continue;
|
|
2383
|
+
const tileset = layer.tileset[0]; // single tileset per layer in v1
|
|
2384
|
+
if (!tileset)
|
|
2385
|
+
continue;
|
|
2386
|
+
const cells = [];
|
|
2387
|
+
// Phaser's TilemapLayer exposes `layer.layer.data[y][x].index`.
|
|
2388
|
+
const layerData = layer.layer.data;
|
|
2389
|
+
for (let y = 0; y < layerData.length; y++) {
|
|
2390
|
+
const row = [];
|
|
2391
|
+
const sourceRow = layerData[y];
|
|
2392
|
+
for (let x = 0; x < sourceRow.length; x++) {
|
|
2393
|
+
const tile = sourceRow[x];
|
|
2394
|
+
row.push(tile && tile.index >= 0 ? tile.index : -1);
|
|
2395
|
+
}
|
|
2396
|
+
cells.push(row);
|
|
2397
|
+
}
|
|
2398
|
+
snapshots.push({
|
|
2399
|
+
id,
|
|
2400
|
+
visible: layer.visible,
|
|
2401
|
+
tilesetId: tileset.name,
|
|
2402
|
+
textureKey: tileset.image?.key ?? tileset.name,
|
|
2403
|
+
cellSize: { width: tileset.tileWidth, height: tileset.tileHeight },
|
|
2404
|
+
margin: tileset.tileMargin,
|
|
2405
|
+
spacing: tileset.tileSpacing,
|
|
2406
|
+
cells,
|
|
2407
|
+
});
|
|
2408
|
+
layer.destroy();
|
|
2409
|
+
}
|
|
2410
|
+
container.setData('tilemapLayers', []);
|
|
2411
|
+
// Update size stash so subsequent ops (addLayer, etc.) see the new dims.
|
|
2412
|
+
container.setData('tilemapSize', { width: newW, height: newH });
|
|
2413
|
+
// Re-stamp the editor hit-test dims so clicks on the new (resized)
|
|
2414
|
+
// bounds register on the entity. Without this, the container's
|
|
2415
|
+
// hit-test rect stays at the original spawn dims — clicks in the
|
|
2416
|
+
// new "outside-original-bounds" area would miss the entity, click
|
|
2417
|
+
// fell through to empty canvas → selection clears → user thinks the
|
|
2418
|
+
// tilemap "disappeared". Mirrors spawnEntity's sizeForHitTest stash
|
|
2419
|
+
// which only runs at spawn time.
|
|
2420
|
+
const newPxW = newW * tileSize.width;
|
|
2421
|
+
const newPxH = newH * tileSize.height;
|
|
2422
|
+
container.setData('editorHitWidth', newPxW);
|
|
2423
|
+
container.setData('editorHitHeight', newPxH);
|
|
2424
|
+
// Recreate layers with the new dims, copying clipped/padded data.
|
|
2425
|
+
// Same algorithm as createTilemap's renderLayerInto, just sourcing
|
|
2426
|
+
// the cell grid from the snapshot.
|
|
2427
|
+
const pxW = newW * tileSize.width;
|
|
2428
|
+
const pxH = newH * tileSize.height;
|
|
2429
|
+
const left = -pxW / 2;
|
|
2430
|
+
const top = -pxH / 2;
|
|
2431
|
+
const newLayers = [];
|
|
2432
|
+
for (const snap of snapshots) {
|
|
2433
|
+
// Build new grid: copy intersect of (snap.cells, newW × newH).
|
|
2434
|
+
const grid = [];
|
|
2435
|
+
for (let y = 0; y < newH; y++) {
|
|
2436
|
+
const row = new Array(newW).fill(-1);
|
|
2437
|
+
const sourceRow = snap.cells[y];
|
|
2438
|
+
if (sourceRow) {
|
|
2439
|
+
for (let x = 0; x < Math.min(newW, sourceRow.length); x++) {
|
|
2440
|
+
row[x] = sourceRow[x];
|
|
2441
|
+
}
|
|
2442
|
+
}
|
|
2443
|
+
grid.push(row);
|
|
2444
|
+
}
|
|
2445
|
+
let map;
|
|
2446
|
+
try {
|
|
2447
|
+
map = scene.make.tilemap({
|
|
2448
|
+
data: grid,
|
|
2449
|
+
tileWidth: snap.cellSize.width,
|
|
2450
|
+
tileHeight: snap.cellSize.height,
|
|
2451
|
+
});
|
|
2452
|
+
}
|
|
2453
|
+
catch (e) {
|
|
2454
|
+
console.warn(`[umicat/editor] resize: tilemap rebuild failed for layer '${snap.id}': ${String(e)}`);
|
|
2455
|
+
continue;
|
|
2456
|
+
}
|
|
2457
|
+
const ts = map.addTilesetImage(snap.tilesetId, snap.textureKey, snap.cellSize.width, snap.cellSize.height, snap.margin, snap.spacing);
|
|
2458
|
+
if (!ts)
|
|
2459
|
+
continue;
|
|
2460
|
+
const layerObj = map.createLayer(0, ts, 0, 0);
|
|
2461
|
+
if (!layerObj)
|
|
2462
|
+
continue;
|
|
2463
|
+
if (snap.cellSize.width !== tileSize.width || snap.cellSize.height !== tileSize.height) {
|
|
2464
|
+
layerObj.setScale(tileSize.width / snap.cellSize.width, tileSize.height / snap.cellSize.height);
|
|
2465
|
+
}
|
|
2466
|
+
layerObj.x = container.x + left;
|
|
2467
|
+
layerObj.y = container.y + top;
|
|
2468
|
+
if (!snap.visible)
|
|
2469
|
+
layerObj.setVisible(false);
|
|
2470
|
+
layerObj.setData('tilemapLayerId', snap.id);
|
|
2471
|
+
const entityId = container.getData('entityId');
|
|
2472
|
+
if (entityId)
|
|
2473
|
+
layerObj.setData('tilemapEntityId', entityId);
|
|
2474
|
+
layerObj.setData('tilemapLocalOffsetX', left);
|
|
2475
|
+
layerObj.setData('tilemapLocalOffsetY', top);
|
|
2476
|
+
newLayers.push(layerObj);
|
|
2477
|
+
}
|
|
2478
|
+
container.setData('tilemapLayers', newLayers);
|
|
2479
|
+
// Redraw the background grid sketch inside the container. The
|
|
2480
|
+
// grid sketch is the first non-null Graphics child of the container
|
|
2481
|
+
// (added in createTilemap). We rebuild it in place to match new
|
|
2482
|
+
// dims. Without this, the visual bounds would still show the
|
|
2483
|
+
// pre-resize rectangle even though paintable cells are at the
|
|
2484
|
+
// new dims — confusing for the user.
|
|
2485
|
+
const cellsW = newW;
|
|
2486
|
+
const cellsH = newH;
|
|
2487
|
+
for (const child of container.list) {
|
|
2488
|
+
if (child instanceof Phaser.GameObjects.Graphics) {
|
|
2489
|
+
child.clear();
|
|
2490
|
+
child.fillStyle(0xffffff, 0.04);
|
|
2491
|
+
child.fillRect(left, top, pxW, pxH);
|
|
2492
|
+
child.lineStyle(2, 0xaaaaaa, 0.45);
|
|
2493
|
+
child.strokeRect(left, top, pxW, pxH);
|
|
2494
|
+
if (tileSize.width >= 8 && cellsW * cellsH < 4000) {
|
|
2495
|
+
child.lineStyle(1, 0xaaaaaa, 0.1);
|
|
2496
|
+
for (let i = 1; i < cellsW; i++) {
|
|
2497
|
+
const x = left + i * tileSize.width;
|
|
2498
|
+
child.beginPath();
|
|
2499
|
+
child.moveTo(x, top);
|
|
2500
|
+
child.lineTo(x, top + pxH);
|
|
2501
|
+
child.strokePath();
|
|
2502
|
+
}
|
|
2503
|
+
for (let i = 1; i < cellsH; i++) {
|
|
2504
|
+
const y = top + i * tileSize.height;
|
|
2505
|
+
child.beginPath();
|
|
2506
|
+
child.moveTo(left, y);
|
|
2507
|
+
child.lineTo(left + pxW, y);
|
|
2508
|
+
child.strokePath();
|
|
2509
|
+
}
|
|
2510
|
+
}
|
|
2511
|
+
break; // only the grid sketch — there are no other Graphics children
|
|
2512
|
+
}
|
|
2513
|
+
}
|
|
2514
|
+
break;
|
|
2515
|
+
}
|
|
2516
|
+
}
|
|
2517
|
+
}
|
|
2518
|
+
/**
|
|
2519
|
+
* Find the TilemapLayer tagged with `layerId` belonging to the given tilemap
|
|
2520
|
+
* container. Layers live in scene root (not as container children) because
|
|
2521
|
+
* Phaser's TilemapLayer doesn't inherit parent Container transforms — see
|
|
2522
|
+
* `createTilemap` for the rationale. The container's data manager stashes
|
|
2523
|
+
* a `tilemapLayers` array of refs so we can find them without a scene walk.
|
|
2524
|
+
*/
|
|
2525
|
+
export function findTilemapLayerById(container, layerId) {
|
|
2526
|
+
const layers = container.getData('tilemapLayers');
|
|
2527
|
+
if (!layers)
|
|
2528
|
+
return null;
|
|
2529
|
+
for (const layer of layers) {
|
|
2530
|
+
if (layer.getData('tilemapLayerId') === layerId)
|
|
2531
|
+
return layer;
|
|
2532
|
+
}
|
|
2533
|
+
return null;
|
|
2534
|
+
}
|
|
2535
|
+
/**
|
|
2536
|
+
* Apply a single op to a live TilemapLayer using Phaser's native mutation
|
|
2537
|
+
* APIs. All ops are in-place — no full re-render needed. Out-of-bounds
|
|
2538
|
+
* cells are tolerated by Phaser's `putTileAt` (no-op outside layer bounds).
|
|
2539
|
+
*
|
|
2540
|
+
* The optional `asset` arg is required for `autotilePaint` (the only op
|
|
2541
|
+
* that needs to resolve a terrain's ruleMap); other ops ignore it.
|
|
2542
|
+
*/
|
|
2543
|
+
export function applyTilemapOp(layer, op, asset) {
|
|
2544
|
+
switch (op.kind) {
|
|
2545
|
+
case 'paint':
|
|
2546
|
+
for (const c of op.cells)
|
|
2547
|
+
layer.putTileAt(c.index, c.x, c.y);
|
|
2548
|
+
break;
|
|
2549
|
+
case 'erase':
|
|
2550
|
+
for (const c of op.cells)
|
|
2551
|
+
layer.removeTileAt(c.x, c.y);
|
|
2552
|
+
break;
|
|
2553
|
+
case 'autotilePaint': {
|
|
2554
|
+
const terrain = findTerrain(asset, op.terrainId);
|
|
2555
|
+
if (!terrain) {
|
|
2556
|
+
console.warn(`[umicat/editor] autotilePaint: tileset has no terrain '${op.terrainId}' (asset=${asset?.id ?? '<unknown>'})`);
|
|
2557
|
+
return;
|
|
2558
|
+
}
|
|
2559
|
+
const mode = op.erase ? 'erase' : 'paint';
|
|
2560
|
+
const kind = getAutotileKind(asset);
|
|
2561
|
+
for (const c of op.cells)
|
|
2562
|
+
applyAutotile(layer, c.x, c.y, terrain, mode, kind);
|
|
2563
|
+
break;
|
|
2564
|
+
}
|
|
2565
|
+
case 'fillRect':
|
|
2566
|
+
if (typeof op.index === 'number') {
|
|
2567
|
+
layer.fill(op.index, op.x, op.y, op.w, op.h);
|
|
2568
|
+
}
|
|
2569
|
+
else {
|
|
2570
|
+
// No index → erase-rect. Phaser's `fill` with -1 sets every cell
|
|
2571
|
+
// in the region to "no tile", same effect as removeTileAt per cell.
|
|
2572
|
+
layer.fill(-1, op.x, op.y, op.w, op.h);
|
|
2573
|
+
}
|
|
2574
|
+
break;
|
|
2575
|
+
case 'bucketFill': {
|
|
2576
|
+
// Phaser doesn't ship a flood-fill primitive — walk the layer ourselves
|
|
2577
|
+
// from the seed cell, replacing matching cells 4-connected. Bounded by
|
|
2578
|
+
// layer dims so degenerate input (huge layer all one tile) doesn't
|
|
2579
|
+
// run unbounded.
|
|
2580
|
+
const seed = layer.getTileAt(op.x, op.y, true);
|
|
2581
|
+
const startIndex = seed ? seed.index : -1;
|
|
2582
|
+
if (startIndex === op.index)
|
|
2583
|
+
return; // already painted with target
|
|
2584
|
+
const w = layer.tilemap.width;
|
|
2585
|
+
const h = layer.tilemap.height;
|
|
2586
|
+
const stack = [[op.x, op.y]];
|
|
2587
|
+
let safety = 0;
|
|
2588
|
+
while (stack.length > 0 && safety++ < w * h) {
|
|
2589
|
+
const [cx, cy] = stack.pop();
|
|
2590
|
+
if (cx < 0 || cy < 0 || cx >= w || cy >= h)
|
|
2591
|
+
continue;
|
|
2592
|
+
const tile = layer.getTileAt(cx, cy, true);
|
|
2593
|
+
const idx = tile ? tile.index : -1;
|
|
2594
|
+
if (idx !== startIndex)
|
|
2595
|
+
continue;
|
|
2596
|
+
layer.putTileAt(op.index, cx, cy);
|
|
2597
|
+
stack.push([cx + 1, cy], [cx - 1, cy], [cx, cy + 1], [cx, cy - 1]);
|
|
2598
|
+
}
|
|
2599
|
+
break;
|
|
2600
|
+
}
|
|
2601
|
+
}
|
|
2602
|
+
}
|
|
2603
|
+
// --- postMessage helper ---------------------------------------------------
|
|
2604
|
+
function postToHost(msg) {
|
|
2605
|
+
if (typeof window === 'undefined' || !window.parent)
|
|
2606
|
+
return;
|
|
2607
|
+
window.parent.postMessage(msg, '*');
|
|
2608
|
+
}
|