@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,1896 @@
|
|
|
1
|
+
import Phaser from 'phaser';
|
|
2
|
+
import { getEntityRegistry } from '../scene/EntityRegistry.js';
|
|
3
|
+
import { findHudEntity, findHudRegistry, UMICAT_HUD_SCENE_KEY, } from '../scene/HudRuntime.js';
|
|
4
|
+
import { isPerFrameHitbox } from '../scene/types.js';
|
|
5
|
+
import { getManifest } from '../scene/SceneLoader.js';
|
|
6
|
+
import { getEditorState, startDrag, clearDrag, getDrag, getSelection, getEditorMode, getDebugOverlayState, getTilemapToolState, isTilemapPaintMode, beginTilemapStroke, beginAutotileStroke, appendTilemapStrokeCell, appendAutotileStrokeClick, endTilemapStroke, getTilemapStroke, beginTilemapRect, updateTilemapRect, endTilemapRect, getTilemapRect, getTilemapResize, beginTilemapResize, updateTilemapResize, endTilemapResize, } from './EditorState.js';
|
|
7
|
+
import { applyTilemapOp, findTilemapLayerById, handleEditTilemap } from './EditorBridge.js';
|
|
8
|
+
import { applyAutotile, findTerrain, getAutotileKind } from '../scene/autotile.js';
|
|
9
|
+
/**
|
|
10
|
+
* Editor overlay — slice 2.
|
|
11
|
+
*
|
|
12
|
+
* Runs ABOVE the world/HUD scenes. Its job:
|
|
13
|
+
*
|
|
14
|
+
* 1. Render selection rectangle around the currently selected entity
|
|
15
|
+
* 2. Render world bounds rectangle (visual cue for "edge of the game world")
|
|
16
|
+
* 3. Capture pointer events:
|
|
17
|
+
* - pointerdown on an entity → posts pickEntity to host
|
|
18
|
+
* - drag → mutates the entity's x/y in real time (visual only)
|
|
19
|
+
* - pointerup → posts dragEnd with before/after to host
|
|
20
|
+
*
|
|
21
|
+
* The overlay scene is launched by EditorBridge when entering Edit mode,
|
|
22
|
+
* stopped on exit. It holds no persistent state of its own — selection +
|
|
23
|
+
* drag info live in the shared EditorState attached to the Phaser game.
|
|
24
|
+
*/
|
|
25
|
+
export const EDITOR_OVERLAY_KEY = '__UmicatEditorOverlay';
|
|
26
|
+
const SELECTION_COLOR = 0x4662d8;
|
|
27
|
+
const SELECTION_ALPHA = 1;
|
|
28
|
+
const SELECTION_LINE_WIDTH = 2;
|
|
29
|
+
const WORLD_BOUNDS_COLOR = 0x888888;
|
|
30
|
+
const WORLD_BOUNDS_ALPHA = 0.7;
|
|
31
|
+
// (OUTSIDE_FILL_COLOR / OUTSIDE_FILL_ALPHA moved to EditorBridge as
|
|
32
|
+
// VOID_FILL_COLOR / VOID_FILL_ALPHA — the fill is now drawn in the
|
|
33
|
+
// world scene at low depth, not in the overlay, so entities render on
|
|
34
|
+
// top of it. 2026-05-17 fix.)
|
|
35
|
+
// P1 — camera viewport rect ("Camera screen" in Godot terms): shows where
|
|
36
|
+
// the game's runtime camera is currently looking. Blue, matches Godot 2D
|
|
37
|
+
// editor's Camera2D editor_draw_screen rect.
|
|
38
|
+
const CAMERA_VIEWPORT_COLOR = 0x4488ee;
|
|
39
|
+
const CAMERA_VIEWPORT_ALPHA = 0.9;
|
|
40
|
+
const CAMERA_VIEWPORT_LINE_WIDTH = 2;
|
|
41
|
+
// FB.10 polish — CSS cursor name per tilemap resize handle. Corner handles
|
|
42
|
+
// get the diagonal arrows (`nwse-resize` ↖↘ for NW/SE, `nesw-resize` ↗↙ for
|
|
43
|
+
// NE/SW). Edge handles get the axis arrows (ns / ew). Same conventions as
|
|
44
|
+
// Figma / Sketch / native HTML resize.
|
|
45
|
+
const RESIZE_CURSOR_FOR_HANDLE = {
|
|
46
|
+
nw: 'nwse-resize',
|
|
47
|
+
se: 'nwse-resize',
|
|
48
|
+
ne: 'nesw-resize',
|
|
49
|
+
sw: 'nesw-resize',
|
|
50
|
+
n: 'ns-resize',
|
|
51
|
+
s: 'ns-resize',
|
|
52
|
+
e: 'ew-resize',
|
|
53
|
+
w: 'ew-resize',
|
|
54
|
+
};
|
|
55
|
+
// Slice 8: "Show hitboxes" debug overlay colors. Green for hitboxes (matches
|
|
56
|
+
// Hitbox Editor canvas), cyan for depth-anchor crosshairs (distinct + visible
|
|
57
|
+
// on most sprite backgrounds).
|
|
58
|
+
const HITBOX_FILL_COLOR = 0x33dd55;
|
|
59
|
+
const HITBOX_FILL_ALPHA = 0.25;
|
|
60
|
+
const HITBOX_STROKE_COLOR = 0x22aa44;
|
|
61
|
+
const HITBOX_STROKE_ALPHA = 0.9;
|
|
62
|
+
const ANCHOR_COLOR = 0x00d0ff;
|
|
63
|
+
const ANCHOR_ALPHA = 1;
|
|
64
|
+
const ANCHOR_CROSS_LEN = 6;
|
|
65
|
+
export class EditorOverlayScene extends Phaser.Scene {
|
|
66
|
+
constructor() {
|
|
67
|
+
super({ key: EDITOR_OVERLAY_KEY });
|
|
68
|
+
// P1 infinite canvas — input state. Pan can be triggered by middle-button
|
|
69
|
+
// drag OR by space+left-drag. We accumulate a "pan active" flag so the
|
|
70
|
+
// existing selection / drag-to-move handlers don't fire while panning.
|
|
71
|
+
this.panActive = false;
|
|
72
|
+
this.spaceHeld = false;
|
|
73
|
+
this.panStartScreen = null;
|
|
74
|
+
this.panStartScroll = null;
|
|
75
|
+
this.handleShortcut = (e) => {
|
|
76
|
+
// Don't swallow keys when the user is typing in a real form control —
|
|
77
|
+
// shouldn't happen inside the iframe, but be defensive.
|
|
78
|
+
const target = e.target;
|
|
79
|
+
if (target) {
|
|
80
|
+
const tag = target.tagName;
|
|
81
|
+
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT')
|
|
82
|
+
return;
|
|
83
|
+
if (target.isContentEditable)
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const k = e.key;
|
|
87
|
+
if (e.metaKey || e.ctrlKey) {
|
|
88
|
+
const lower = k.toLowerCase();
|
|
89
|
+
if (lower === 'z') {
|
|
90
|
+
e.preventDefault();
|
|
91
|
+
this.postShortcut(e.shiftKey ? 'redo' : 'undo');
|
|
92
|
+
}
|
|
93
|
+
else if (lower === 'y') {
|
|
94
|
+
e.preventDefault();
|
|
95
|
+
this.postShortcut('redo');
|
|
96
|
+
}
|
|
97
|
+
else if (lower === 's') {
|
|
98
|
+
e.preventDefault();
|
|
99
|
+
this.postShortcut('save');
|
|
100
|
+
}
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (k === 'Backspace' || k === 'Delete') {
|
|
104
|
+
// Suppress default (Backspace = browser back-nav inside iframe).
|
|
105
|
+
e.preventDefault();
|
|
106
|
+
this.postShortcut('delete');
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
init(data) {
|
|
111
|
+
this.worldBounds = data.worldBounds;
|
|
112
|
+
this.hitTest = data.hitTest;
|
|
113
|
+
this.hitTestAll = data.hitTestAll;
|
|
114
|
+
this.postPick = data.postPick;
|
|
115
|
+
this.postDragEnd = data.postDragEnd;
|
|
116
|
+
this.postShortcut = data.postShortcut;
|
|
117
|
+
this.applyPanZoom = data.applyPanZoom;
|
|
118
|
+
}
|
|
119
|
+
create() {
|
|
120
|
+
this.graphics = this.add.graphics();
|
|
121
|
+
// Mirror the EDITOR camera (P1 infinite canvas, 2026-05-17). Falls back
|
|
122
|
+
// to the world scene's cameras.main on the very first frame when the
|
|
123
|
+
// editor cam hasn't been installed yet (enterEdit order may race) —
|
|
124
|
+
// `applyEditorPanZoom`'s subsequent mutations will keep the overlay
|
|
125
|
+
// cam in sync.
|
|
126
|
+
const sourceCam = this.findEditorCamera() ?? this.findWorldSceneCamera();
|
|
127
|
+
if (sourceCam) {
|
|
128
|
+
this.cameras.main.setScroll(sourceCam.scrollX, sourceCam.scrollY);
|
|
129
|
+
this.cameras.main.setZoom(sourceCam.zoom);
|
|
130
|
+
}
|
|
131
|
+
this.input.on('pointerdown', this.handlePointerDown, this);
|
|
132
|
+
this.input.on('pointermove', this.handlePointerMove, this);
|
|
133
|
+
this.input.on('pointerup', this.handlePointerUp, this);
|
|
134
|
+
// P1 infinite canvas — wheel for zoom (cursor-anchored), middle-button
|
|
135
|
+
// and space+drag for pan. Only active in world mode (HUD's identity
|
|
136
|
+
// camera doesn't pan/zoom). Wired via native DOM event on the canvas
|
|
137
|
+
// because Phaser's pointer events don't surface `wheel`.
|
|
138
|
+
this.installPanZoomInput();
|
|
139
|
+
// Forward editor shortcuts back to the host. The user clicks the canvas
|
|
140
|
+
// to select an entity, which moves focus into the iframe — after that,
|
|
141
|
+
// native Cmd+Z lands here, not on the host's window. We catch it before
|
|
142
|
+
// Phaser's keyboard plugin gets it (DOM phase is fine because Phaser
|
|
143
|
+
// listens via its own KeyboardManager), preventDefault'ing browser
|
|
144
|
+
// back-nav-on-Cmd+Left etc. is unnecessary; we only intercept the few
|
|
145
|
+
// editor keys we care about.
|
|
146
|
+
if (typeof document !== 'undefined') {
|
|
147
|
+
document.addEventListener('keydown', this.handleShortcut, true);
|
|
148
|
+
this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => {
|
|
149
|
+
document.removeEventListener('keydown', this.handleShortcut, true);
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Pointer coords mode-aware. World mode uses world coords (camera-relative);
|
|
155
|
+
* HUD mode transforms pointer through the HUD scene's camera to land in
|
|
156
|
+
* HUD-intrinsic coords (the coord space widget positions live in).
|
|
157
|
+
*
|
|
158
|
+
* Pre-0.2.90 this returned `pointer.x/y` (canvas pixels) for HUD mode
|
|
159
|
+
* — fine when HUD cam was an identity camera (viewport == canvas,
|
|
160
|
+
* zoom == 1). But 0.2.89 changed HUD cam to render INSIDE the camera-
|
|
161
|
+
* viewport rect on the editor canvas with editor-cam zoom, so canvas
|
|
162
|
+
* pixels no longer equal HUD-intrinsic coords. Hit-test against
|
|
163
|
+
* widgets (positioned in HUD-intrinsic) would only match a tiny
|
|
164
|
+
* patch near the canvas top-left, hence the user's "have to click
|
|
165
|
+
* many times" complaint. Using `hudCam.getWorldPoint(...)` applies
|
|
166
|
+
* the cam's inverse transform (subtract viewport offset, divide by
|
|
167
|
+
* zoom) and lands in widget coord space.
|
|
168
|
+
*/
|
|
169
|
+
pointerCoords(pointer) {
|
|
170
|
+
if (getEditorMode(this.game) === 'hud') {
|
|
171
|
+
const hudScene = this.game.scene.getScene(UMICAT_HUD_SCENE_KEY);
|
|
172
|
+
const hudCam = hudScene?.cameras.main;
|
|
173
|
+
if (hudCam) {
|
|
174
|
+
const p = hudCam.getWorldPoint(pointer.x, pointer.y);
|
|
175
|
+
return { x: p.x, y: p.y };
|
|
176
|
+
}
|
|
177
|
+
return { x: pointer.x, y: pointer.y };
|
|
178
|
+
}
|
|
179
|
+
return { x: pointer.worldX, y: pointer.worldY };
|
|
180
|
+
}
|
|
181
|
+
handlePointerDown(pointer) {
|
|
182
|
+
const state = getEditorState(this.game);
|
|
183
|
+
if (!state.active)
|
|
184
|
+
return;
|
|
185
|
+
// P1 infinite canvas — pan trigger: middle button OR space+left.
|
|
186
|
+
// Allowed in both world AND hud edit modes (2026-05-17 fix). The
|
|
187
|
+
// pan only mutates the EDITOR cam (world scene), so HUD widgets
|
|
188
|
+
// (rendered by HUD's separate identity camera) stay anchored to
|
|
189
|
+
// their canvas-pixel positions — pan doesn't visually move them.
|
|
190
|
+
// User gets to see HUD overlaid on different world-pan states.
|
|
191
|
+
const event = pointer.event;
|
|
192
|
+
const isMiddleButton = pointer.button === 1;
|
|
193
|
+
const isSpaceDrag = this.spaceHeld && pointer.button === 0;
|
|
194
|
+
if (isMiddleButton || isSpaceDrag) {
|
|
195
|
+
this.panActive = true;
|
|
196
|
+
this.panStartScreen = { x: pointer.x, y: pointer.y };
|
|
197
|
+
const editorCam = this.findActiveEditorCamera();
|
|
198
|
+
this.panStartScroll = editorCam
|
|
199
|
+
? { x: editorCam.scrollX, y: editorCam.scrollY }
|
|
200
|
+
: { x: 0, y: 0 };
|
|
201
|
+
event?.preventDefault?.();
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
const { x: px, y: py } = this.pointerCoords(pointer);
|
|
205
|
+
// FB.9a — Alt+click cycles through overlapping entities. When 2+
|
|
206
|
+
// entities visually overlap at the cursor, normal selection always
|
|
207
|
+
// picks topmost; Alt+click instead picks the entity just under
|
|
208
|
+
// (lower depth than) the current selection in the hit stack. Wraps
|
|
209
|
+
// around at the bottom. Standard Figma / Sketch / XD convention.
|
|
210
|
+
// Runs BEFORE paint-mode dispatch — Alt is a deliberate "show me
|
|
211
|
+
// something else under here" gesture; user can always click without
|
|
212
|
+
// Alt afterward to paint.
|
|
213
|
+
if (event?.altKey && !event?.metaKey && !event?.ctrlKey) {
|
|
214
|
+
const stack = this.hitTestAll(px, py);
|
|
215
|
+
if (stack.length > 0) {
|
|
216
|
+
const currentSelectedId = getSelection(this.game);
|
|
217
|
+
const currentIdx = stack.findIndex((go) => go.getData('entityId') === currentSelectedId);
|
|
218
|
+
// First Alt+click on this spot (current selection not in stack) →
|
|
219
|
+
// pick topmost. Subsequent Alt+clicks → cycle down through stack;
|
|
220
|
+
// after the bottommost, wrap back to topmost.
|
|
221
|
+
const nextIdx = currentIdx === -1 ? 0 : (currentIdx + 1) % stack.length;
|
|
222
|
+
const nextEntityId = stack[nextIdx].getData('entityId');
|
|
223
|
+
if (nextEntityId) {
|
|
224
|
+
this.postPick(nextEntityId, {
|
|
225
|
+
shift: !!event.shiftKey,
|
|
226
|
+
cmdOrCtrl: false,
|
|
227
|
+
alt: true,
|
|
228
|
+
});
|
|
229
|
+
event.preventDefault?.();
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
// Slice 6 Phase B.6.1: resize-handle hit-test takes priority over
|
|
235
|
+
// BOTH paint and entity selection. Handles are drawn on top of the
|
|
236
|
+
// tilemap bounds when paint mode is active. Click+drag → resize the
|
|
237
|
+
// tilemap; one-edge-anchored (opposite edge stays fixed).
|
|
238
|
+
if (isTilemapPaintMode(this.game)) {
|
|
239
|
+
const handle = this.hitTestTilemapResizeHandle(px, py);
|
|
240
|
+
if (handle) {
|
|
241
|
+
this.beginTilemapResizeDrag(handle);
|
|
242
|
+
event?.preventDefault?.();
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
// Slice 6 Phase B: tilemap paint mode hijacks pointerdown — clicks
|
|
247
|
+
// paint cells instead of selecting / dragging entities. Check before
|
|
248
|
+
// hitTest so a click on the tilemap doesn't reselect-and-drag-itself.
|
|
249
|
+
// Mode is true only when (a) the selected entity is a tilemap AND
|
|
250
|
+
// (b) the host has pushed a TilemapTool state with a non-null layer.
|
|
251
|
+
if (isTilemapPaintMode(this.game)) {
|
|
252
|
+
const handled = this.handleTilemapPaintDown(px, py);
|
|
253
|
+
if (handled) {
|
|
254
|
+
event?.preventDefault?.();
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
// Pointer fell outside the tilemap bounds — fall through to normal
|
|
258
|
+
// selection so the user can click out of paint mode by clicking
|
|
259
|
+
// another entity / empty canvas.
|
|
260
|
+
}
|
|
261
|
+
const hit = this.hitTest(px, py);
|
|
262
|
+
const modifiers = {
|
|
263
|
+
shift: !!event?.shiftKey,
|
|
264
|
+
cmdOrCtrl: !!(event?.metaKey || event?.ctrlKey),
|
|
265
|
+
alt: !!event?.altKey,
|
|
266
|
+
};
|
|
267
|
+
if (!hit) {
|
|
268
|
+
this.postPick(null, modifiers);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
const entityId = hit.getData('entityId');
|
|
272
|
+
if (!entityId) {
|
|
273
|
+
this.postPick(null, modifiers);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
// Surface the prefab id when the picked GameObject was spawned via
|
|
277
|
+
// `spawnPrefab` (set on the data manager at spawn — see Prefabs.ts).
|
|
278
|
+
// The host routes prefab-instance picks to the Prefab Inspector.
|
|
279
|
+
const prefabId = hit.getData('entityPrefabId');
|
|
280
|
+
this.postPick(entityId, modifiers, prefabId);
|
|
281
|
+
// Begin drag immediately on pointerdown (drag threshold can be added later).
|
|
282
|
+
// In HUD mode `startEntity` carries the entity's anchor offset (not the
|
|
283
|
+
// GameObject's canvas-pixel position) so dragEnd can report the new
|
|
284
|
+
// offset values directly.
|
|
285
|
+
if (getEditorMode(this.game) === 'hud') {
|
|
286
|
+
const ent = findHudEntity(this.game, entityId);
|
|
287
|
+
const startOffset = {
|
|
288
|
+
x: ent?.anchor.offsetX ?? 0,
|
|
289
|
+
y: ent?.anchor.offsetY ?? 0,
|
|
290
|
+
};
|
|
291
|
+
startDrag(this.game, entityId, { x: px, y: py }, startOffset);
|
|
292
|
+
}
|
|
293
|
+
else {
|
|
294
|
+
const target = hit;
|
|
295
|
+
startDrag(this.game, entityId, { x: px, y: py }, { x: target.x, y: target.y });
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
handlePointerMove(pointer) {
|
|
299
|
+
// FB.10 polish — update canvas cursor to indicate resize when hovering
|
|
300
|
+
// a tilemap resize handle. Done first so cursor reflects the immediate
|
|
301
|
+
// visual state regardless of which branch we hit below.
|
|
302
|
+
this.updateResizeCursor(pointer);
|
|
303
|
+
// P1 infinite canvas — pan in progress: translate screen delta into
|
|
304
|
+
// world delta via the current zoom, set scroll absolute (relative-mode
|
|
305
|
+
// would accumulate floating-point drift over many move events).
|
|
306
|
+
if (this.panActive && this.panStartScreen && this.panStartScroll) {
|
|
307
|
+
const editorCam = this.findActiveEditorCamera();
|
|
308
|
+
const zoom = editorCam?.zoom ?? 1;
|
|
309
|
+
const dxScreen = pointer.x - this.panStartScreen.x;
|
|
310
|
+
const dyScreen = pointer.y - this.panStartScreen.y;
|
|
311
|
+
this.applyPanZoom({
|
|
312
|
+
scrollX: this.panStartScroll.x - dxScreen / zoom,
|
|
313
|
+
scrollY: this.panStartScroll.y - dyScreen / zoom,
|
|
314
|
+
});
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
// Slice 6 Phase B.6.1: resize-handle drag in flight. Update preview
|
|
318
|
+
// dims based on cursor position; ghost rect renders in update().
|
|
319
|
+
if (getTilemapResize(this.game)) {
|
|
320
|
+
const { x: px, y: py } = this.pointerCoords(pointer);
|
|
321
|
+
this.updateTilemapResizeDrag(px, py);
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
// Slice 6 Phase B: continue an in-flight tilemap stroke (brush/eraser
|
|
325
|
+
// drag) OR rect drag. handleTilemapPaintMove already branches on
|
|
326
|
+
// which one is active internally — we just need to route to it
|
|
327
|
+
// whenever EITHER is in flight. Without checking getTilemapRect
|
|
328
|
+
// here, rect-drag pointermoves would fall through to entity-drag
|
|
329
|
+
// logic (which finds no drag since paint mode preempted startDrag),
|
|
330
|
+
// and the rect endpoint would never update → release always paints
|
|
331
|
+
// a 1×1 rect (single tile).
|
|
332
|
+
if (getTilemapStroke(this.game) || getTilemapRect(this.game)) {
|
|
333
|
+
const { x: px, y: py } = this.pointerCoords(pointer);
|
|
334
|
+
this.handleTilemapPaintMove(px, py);
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
const drag = getDrag(this.game);
|
|
338
|
+
if (!drag)
|
|
339
|
+
return;
|
|
340
|
+
const registry = this.findEntityRegistry();
|
|
341
|
+
if (!registry)
|
|
342
|
+
return;
|
|
343
|
+
const go = registry.byId(drag.entityId);
|
|
344
|
+
if (!go)
|
|
345
|
+
return;
|
|
346
|
+
const { x: px, y: py } = this.pointerCoords(pointer);
|
|
347
|
+
const dx = px - drag.startWorld.x;
|
|
348
|
+
const dy = py - drag.startWorld.y;
|
|
349
|
+
if (getEditorMode(this.game) === 'hud') {
|
|
350
|
+
// HUD `startEntity` carries the offset, not the canvas-pixel start
|
|
351
|
+
// pos. We track per-frame delta on the GO so each pointermove just
|
|
352
|
+
// applies the incremental nudge — `go.x = startGoPos.x + dx` doesn't
|
|
353
|
+
// work because startGoPos isn't stored. The visual position the user
|
|
354
|
+
// sees is the canvas pos derived from the live anchor base + delta.
|
|
355
|
+
const lastDx = go.__lastDragDx ?? 0;
|
|
356
|
+
const lastDy = go.__lastDragDy ?? 0;
|
|
357
|
+
go.x += dx - lastDx;
|
|
358
|
+
go.y += dy - lastDy;
|
|
359
|
+
go.__lastDragDx = dx;
|
|
360
|
+
go.__lastDragDy = dy;
|
|
361
|
+
}
|
|
362
|
+
else {
|
|
363
|
+
go.x = drag.startEntity.x + dx;
|
|
364
|
+
go.y = drag.startEntity.y + dy;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
handlePointerUp(pointer) {
|
|
368
|
+
if (this.panActive) {
|
|
369
|
+
this.panActive = false;
|
|
370
|
+
this.panStartScreen = null;
|
|
371
|
+
this.panStartScroll = null;
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
// Slice 6 Phase B.6.1: end a resize-handle drag — commit the final
|
|
375
|
+
// resize op (size + transformDelta to anchor opposite edge).
|
|
376
|
+
if (getTilemapResize(this.game)) {
|
|
377
|
+
this.commitTilemapResize();
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
// Slice 6 Phase B: end an in-flight tilemap stroke (brush/eraser) OR
|
|
381
|
+
// rect drag. Both compose a single TilemapEditOp + post `tilemapEdited`
|
|
382
|
+
// so undo reverts the whole stroke/rect in one step.
|
|
383
|
+
if (getTilemapStroke(this.game) || getTilemapRect(this.game)) {
|
|
384
|
+
this.handleTilemapPaintUp();
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
const drag = getDrag(this.game);
|
|
388
|
+
if (!drag)
|
|
389
|
+
return;
|
|
390
|
+
const { x: px, y: py } = this.pointerCoords(pointer);
|
|
391
|
+
const dx = px - drag.startWorld.x;
|
|
392
|
+
const dy = py - drag.startWorld.y;
|
|
393
|
+
const before = drag.startEntity;
|
|
394
|
+
const after = { x: drag.startEntity.x + dx, y: drag.startEntity.y + dy };
|
|
395
|
+
// Reset the per-drag delta accumulator on the GO if HUD mode left one.
|
|
396
|
+
if (getEditorMode(this.game) === 'hud') {
|
|
397
|
+
const reg = this.findEntityRegistry();
|
|
398
|
+
const go = reg?.byId(drag.entityId);
|
|
399
|
+
if (go) {
|
|
400
|
+
go.__lastDragDx = 0;
|
|
401
|
+
go.__lastDragDy = 0;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
clearDrag(this.game);
|
|
405
|
+
if (Math.abs(dx) < 0.5 && Math.abs(dy) < 0.5)
|
|
406
|
+
return;
|
|
407
|
+
this.postDragEnd(drag.entityId, before, after);
|
|
408
|
+
}
|
|
409
|
+
// --- Slice 6 Phase B — tilemap painter -----------------------------------
|
|
410
|
+
/**
|
|
411
|
+
* Pointer-down inside paint mode. Returns true when the pointer hit the
|
|
412
|
+
* active layer (stroke began); false when the pointer fell outside the
|
|
413
|
+
* tilemap's bounds (caller falls through to normal selection).
|
|
414
|
+
*
|
|
415
|
+
* Begins a stroke + applies the first cell. brush/eraser tools paint
|
|
416
|
+
* incrementally per pointermove; rect/fill/picker (Phase B.4) handle
|
|
417
|
+
* pointerdown specially — for now only brush + eraser are wired.
|
|
418
|
+
*/
|
|
419
|
+
handleTilemapPaintDown(worldX, worldY) {
|
|
420
|
+
const tool = getTilemapToolState(this.game);
|
|
421
|
+
if (!tool.tilemapEditingId || !tool.activeLayerId) {
|
|
422
|
+
// Paint mode is active per `isTilemapPaintMode` (which gated the caller)
|
|
423
|
+
// but the per-tool state is incomplete. This means the host pushed an
|
|
424
|
+
// intermediate state — fall through to drag is OK here.
|
|
425
|
+
return false;
|
|
426
|
+
}
|
|
427
|
+
const resolved = this.findActiveTilemapLayerWithContainer(tool.tilemapEditingId, tool.activeLayerId);
|
|
428
|
+
if (!resolved) {
|
|
429
|
+
console.warn(`[umicat/editor] tilemap paint: active layer '${tool.activeLayerId}' on entity '${tool.tilemapEditingId}' not found in scene — falling through to drag. ` +
|
|
430
|
+
`Likely cause: layer ref stale after a structure op (addLayer/removeLayer) failed to update the host's activeLayerId.`);
|
|
431
|
+
return false;
|
|
432
|
+
}
|
|
433
|
+
const { layer, container } = resolved;
|
|
434
|
+
// Layers live in scene root (not as Container children) and are
|
|
435
|
+
// positioned at WORLD coords via `installTilemapLayerSync`. So
|
|
436
|
+
// worldToTileXY operates on raw world coords directly — no coord
|
|
437
|
+
// shift needed.
|
|
438
|
+
const tile = layer.worldToTileXY(worldX, worldY, true);
|
|
439
|
+
if (tile.x < 0 || tile.y < 0 ||
|
|
440
|
+
tile.x >= layer.tilemap.width || tile.y >= layer.tilemap.height) {
|
|
441
|
+
// Click outside the active layer's bounds — fall-through to entity
|
|
442
|
+
// selection is the right behavior (user clicked off the tilemap).
|
|
443
|
+
return false;
|
|
444
|
+
}
|
|
445
|
+
// ---- From here on, click is INSIDE the tilemap bounds. ----
|
|
446
|
+
//
|
|
447
|
+
// Paint dispatch must either succeed (return true) OR no-op + warn
|
|
448
|
+
// (also return true so the click is consumed). NEVER fall through to
|
|
449
|
+
// drag — that's the recurring "selected terrain + clicked brush, but
|
|
450
|
+
// the tilemap drags instead of painting" bug. The user's click
|
|
451
|
+
// intent is unambiguously "paint" once we're past the bounds check.
|
|
452
|
+
// Drag-vs-paint policy:
|
|
453
|
+
//
|
|
454
|
+
// armed = (activeTerrainId != null) || (activeTile != null)
|
|
455
|
+
// || (tool === 'eraser') // eraser doesn't need a tile
|
|
456
|
+
// || (tool === 'picker') // picker reads, doesn't need a tile
|
|
457
|
+
//
|
|
458
|
+
// - armed=false → user has nothing loaded in the brush AND the tool
|
|
459
|
+
// isn't one that works without a tile. Fall through to entity
|
|
460
|
+
// selection/drag — that's how the user moves the tilemap.
|
|
461
|
+
// - armed=true → user expects to interact with the tilemap. If dispatch
|
|
462
|
+
// fails (terrain unresolved, etc.), CONSUME the click + warn. Never
|
|
463
|
+
// silently drag the tilemap — that was the recurring "selected
|
|
464
|
+
// terrain but brush doesn't paint, it drags" bug.
|
|
465
|
+
//
|
|
466
|
+
// Eraser is armed-by-tool because its job is "erase the cell I clicked"
|
|
467
|
+
// — no activeTile required. Same for picker (eyedropper reads the cell).
|
|
468
|
+
// Brush / rect / fill REQUIRE activeTile or activeTerrainId; without
|
|
469
|
+
// either, click is unambiguously a drag intent.
|
|
470
|
+
const armed = tool.activeTerrainId != null ||
|
|
471
|
+
tool.activeTile != null ||
|
|
472
|
+
tool.tool === 'eraser' ||
|
|
473
|
+
tool.tool === 'picker';
|
|
474
|
+
if (!armed) {
|
|
475
|
+
// Nothing to paint with — let the click flow to entity drag.
|
|
476
|
+
return false;
|
|
477
|
+
}
|
|
478
|
+
switch (tool.tool) {
|
|
479
|
+
case 'brush': {
|
|
480
|
+
if (tool.activeTerrainId != null) {
|
|
481
|
+
const ctx = this.resolveAutotileTerrainContext(layer, tool.activeTerrainId);
|
|
482
|
+
if (ctx) {
|
|
483
|
+
beginAutotileStroke(this.game, tool.tilemapEditingId, tool.activeLayerId, tool.activeTerrainId,
|
|
484
|
+
/* erase */ false);
|
|
485
|
+
this.applyAutotileStrokeCell(layer, tile.x, tile.y, ctx);
|
|
486
|
+
return true;
|
|
487
|
+
}
|
|
488
|
+
// Terrain context unresolved — warn already emitted. Consume
|
|
489
|
+
// (we're armed) so the tilemap doesn't drag.
|
|
490
|
+
return true;
|
|
491
|
+
}
|
|
492
|
+
// tool.activeTile is non-null per `armed` check above.
|
|
493
|
+
beginTilemapStroke(this.game, tool.tilemapEditingId, tool.activeLayerId);
|
|
494
|
+
this.applyStrokeCell(layer, tile.x, tile.y, tool);
|
|
495
|
+
return true;
|
|
496
|
+
}
|
|
497
|
+
case 'eraser': {
|
|
498
|
+
// Eraser is always "armed" — it doesn't need activeTile. But to
|
|
499
|
+
// keep drag-when-idle working, eraser must EITHER have a terrain
|
|
500
|
+
// (autotile erase) OR be the explicitly-chosen tool (user picked
|
|
501
|
+
// eraser from the palette → intent is clearly to erase).
|
|
502
|
+
// The `armed` gate above already required activeTile|terrain, so
|
|
503
|
+
// bare eraser with neither falls through to drag. The user picks
|
|
504
|
+
// eraser → tool is 'eraser' but armed=false → drag wins. That's
|
|
505
|
+
// fine: if you want to erase, pick eraser AND keep activeTile/
|
|
506
|
+
// terrain set so we know you mean "erase paint", not "move".
|
|
507
|
+
if (tool.activeTerrainId != null) {
|
|
508
|
+
const ctx = this.resolveAutotileTerrainContext(layer, tool.activeTerrainId);
|
|
509
|
+
if (ctx) {
|
|
510
|
+
beginAutotileStroke(this.game, tool.tilemapEditingId, tool.activeLayerId, tool.activeTerrainId,
|
|
511
|
+
/* erase */ true);
|
|
512
|
+
this.applyAutotileStrokeCell(layer, tile.x, tile.y, ctx);
|
|
513
|
+
return true;
|
|
514
|
+
}
|
|
515
|
+
return true;
|
|
516
|
+
}
|
|
517
|
+
beginTilemapStroke(this.game, tool.tilemapEditingId, tool.activeLayerId);
|
|
518
|
+
this.applyStrokeCell(layer, tile.x, tile.y, tool);
|
|
519
|
+
return true;
|
|
520
|
+
}
|
|
521
|
+
case 'rect': {
|
|
522
|
+
if (tool.activeTerrainId != null) {
|
|
523
|
+
const ctx = this.resolveAutotileTerrainContext(layer, tool.activeTerrainId);
|
|
524
|
+
if (ctx) {
|
|
525
|
+
beginTilemapRect(this.game, tool.tilemapEditingId, tool.activeLayerId, tile.x, tile.y, { terrainId: tool.activeTerrainId, erase: false });
|
|
526
|
+
return true;
|
|
527
|
+
}
|
|
528
|
+
return true;
|
|
529
|
+
}
|
|
530
|
+
beginTilemapRect(this.game, tool.tilemapEditingId, tool.activeLayerId, tile.x, tile.y);
|
|
531
|
+
return true;
|
|
532
|
+
}
|
|
533
|
+
case 'fill': {
|
|
534
|
+
if (tool.activeTerrainId != null) {
|
|
535
|
+
const ctx = this.resolveAutotileTerrainContext(layer, tool.activeTerrainId);
|
|
536
|
+
if (ctx) {
|
|
537
|
+
this.applyAutotileBucketFill(layer, tile.x, tile.y, ctx, tool);
|
|
538
|
+
return true;
|
|
539
|
+
}
|
|
540
|
+
return true;
|
|
541
|
+
}
|
|
542
|
+
this.applyBucketFill(layer, tile.x, tile.y, tool.activeTile, tool);
|
|
543
|
+
return true;
|
|
544
|
+
}
|
|
545
|
+
case 'picker': {
|
|
546
|
+
// Eyedropper — read cell + post to host.
|
|
547
|
+
const t = layer.getTileAt(tile.x, tile.y, true);
|
|
548
|
+
const tileIndex = t && t.index >= 0 ? t.index : null;
|
|
549
|
+
const msg = {
|
|
550
|
+
type: 'umicat:editor:tilemapTilePicked',
|
|
551
|
+
entityId: tool.tilemapEditingId,
|
|
552
|
+
layerId: tool.activeLayerId,
|
|
553
|
+
tileIndex,
|
|
554
|
+
};
|
|
555
|
+
if (typeof window !== 'undefined' && window.parent) {
|
|
556
|
+
window.parent.postMessage(msg, '*');
|
|
557
|
+
}
|
|
558
|
+
return true;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
return false;
|
|
562
|
+
}
|
|
563
|
+
handleTilemapPaintMove(worldX, worldY) {
|
|
564
|
+
// Rect-drag in flight — update preview rect end point; clamp into layer
|
|
565
|
+
// bounds so the preview never extends past the tilemap.
|
|
566
|
+
const rect = getTilemapRect(this.game);
|
|
567
|
+
if (rect) {
|
|
568
|
+
const resolved = this.findActiveTilemapLayerWithContainer(rect.entityId, rect.layerId);
|
|
569
|
+
if (!resolved)
|
|
570
|
+
return;
|
|
571
|
+
const { layer, container } = resolved;
|
|
572
|
+
const tile = layer.worldToTileXY(worldX, worldY, true);
|
|
573
|
+
const clampedX = Math.max(0, Math.min(layer.tilemap.width - 1, tile.x));
|
|
574
|
+
const clampedY = Math.max(0, Math.min(layer.tilemap.height - 1, tile.y));
|
|
575
|
+
updateTilemapRect(this.game, clampedX, clampedY);
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
// Brush/eraser stroke in flight — paint one more cell. Layers are now
|
|
579
|
+
// world-positioned (see createTilemap) so worldToTileXY takes raw
|
|
580
|
+
// world coords — no container-offset shift needed.
|
|
581
|
+
const stroke = getTilemapStroke(this.game);
|
|
582
|
+
if (!stroke)
|
|
583
|
+
return;
|
|
584
|
+
const tool = getTilemapToolState(this.game);
|
|
585
|
+
const layer = this.findActiveTilemapLayer(stroke.entityId, stroke.layerId);
|
|
586
|
+
if (!layer)
|
|
587
|
+
return;
|
|
588
|
+
const tile = layer.worldToTileXY(worldX, worldY, true);
|
|
589
|
+
if (tile.x < 0 || tile.y < 0 ||
|
|
590
|
+
tile.x >= layer.tilemap.width || tile.y >= layer.tilemap.height) {
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
// Slice 6 Phase D — drag-paint continues in the stroke's mode.
|
|
594
|
+
if (stroke.autotile) {
|
|
595
|
+
const ctx = this.resolveAutotileTerrainContext(layer, stroke.autotile.terrainId);
|
|
596
|
+
if (!ctx)
|
|
597
|
+
return;
|
|
598
|
+
this.applyAutotileStrokeCell(layer, tile.x, tile.y, ctx);
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
this.applyStrokeCell(layer, tile.x, tile.y, tool);
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* Compose stroke into one TilemapEditOp + post to host. Host records the
|
|
605
|
+
* undo command + persists on next flush. Stroke state is cleared.
|
|
606
|
+
*
|
|
607
|
+
* Brush stroke → `paint` op with all cells. Eraser stroke → `erase` op.
|
|
608
|
+
* (Phase B.4 adds rect / fill emit paths.)
|
|
609
|
+
*/
|
|
610
|
+
handleTilemapPaintUp() {
|
|
611
|
+
// Rect-drag takes priority — only one of (stroke, rect) is ever in
|
|
612
|
+
// flight, but check rect first so a partial brush stroke doesn't
|
|
613
|
+
// accidentally swallow a rect commit (defensive).
|
|
614
|
+
const rect = endTilemapRect(this.game);
|
|
615
|
+
if (rect) {
|
|
616
|
+
this.commitTilemapRect(rect);
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
const stroke = endTilemapStroke(this.game);
|
|
620
|
+
if (!stroke || stroke.cells.length === 0)
|
|
621
|
+
return;
|
|
622
|
+
// Slice 6 Phase D — autotile-mode stroke. Emit a single `autotilePaint`
|
|
623
|
+
// op carrying the user-clicked cells; the `previousCells` array (built
|
|
624
|
+
// from stroke.cells with first-wins prev semantics) captures every
|
|
625
|
+
// cascade-affected cell so undo replays the inverse op cleanly.
|
|
626
|
+
let op;
|
|
627
|
+
if (stroke.autotile) {
|
|
628
|
+
if (stroke.autotile.clickedCells.length === 0)
|
|
629
|
+
return;
|
|
630
|
+
// D.2.5.2 fix — include cascade-resolved cells so host save can
|
|
631
|
+
// persist the autotile output without recomputing.
|
|
632
|
+
const resolvedCells = stroke.cells.map((c) => ({
|
|
633
|
+
x: c.x,
|
|
634
|
+
y: c.y,
|
|
635
|
+
index: c.index,
|
|
636
|
+
}));
|
|
637
|
+
op = {
|
|
638
|
+
kind: 'autotilePaint',
|
|
639
|
+
layerId: stroke.layerId,
|
|
640
|
+
terrainId: stroke.autotile.terrainId,
|
|
641
|
+
cells: stroke.autotile.clickedCells.slice(),
|
|
642
|
+
erase: stroke.autotile.erase,
|
|
643
|
+
resolvedCells,
|
|
644
|
+
};
|
|
645
|
+
const previousCells = stroke.cells.map((c) => ({
|
|
646
|
+
x: c.x,
|
|
647
|
+
y: c.y,
|
|
648
|
+
index: c.prevIndex,
|
|
649
|
+
}));
|
|
650
|
+
const message = {
|
|
651
|
+
type: 'umicat:editor:tilemapEdited',
|
|
652
|
+
entityId: stroke.entityId,
|
|
653
|
+
op,
|
|
654
|
+
previousCells,
|
|
655
|
+
};
|
|
656
|
+
if (typeof window !== 'undefined' && window.parent) {
|
|
657
|
+
window.parent.postMessage(message, '*');
|
|
658
|
+
}
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
const isErase = stroke.cells.every((c) => c.index == null);
|
|
662
|
+
if (isErase) {
|
|
663
|
+
op = {
|
|
664
|
+
kind: 'erase',
|
|
665
|
+
layerId: stroke.layerId,
|
|
666
|
+
cells: stroke.cells.map((c) => ({ x: c.x, y: c.y })),
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
else {
|
|
670
|
+
op = {
|
|
671
|
+
kind: 'paint',
|
|
672
|
+
layerId: stroke.layerId,
|
|
673
|
+
cells: stroke.cells.map((c) => ({
|
|
674
|
+
x: c.x,
|
|
675
|
+
y: c.y,
|
|
676
|
+
// Brush stroke — every cell has index. Non-null asserted because
|
|
677
|
+
// mixed-mode strokes shouldn't happen (one tool active per stroke).
|
|
678
|
+
index: c.index,
|
|
679
|
+
})),
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
const previousCells = stroke.cells.map((c) => ({
|
|
683
|
+
x: c.x,
|
|
684
|
+
y: c.y,
|
|
685
|
+
index: c.prevIndex,
|
|
686
|
+
}));
|
|
687
|
+
const message = {
|
|
688
|
+
type: 'umicat:editor:tilemapEdited',
|
|
689
|
+
entityId: stroke.entityId,
|
|
690
|
+
op,
|
|
691
|
+
previousCells,
|
|
692
|
+
};
|
|
693
|
+
if (typeof window !== 'undefined' && window.parent) {
|
|
694
|
+
window.parent.postMessage(message, '*');
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
/**
|
|
698
|
+
* Compose a `fillRect` op from the completed rect drag + apply it live +
|
|
699
|
+
* post tilemapEdited. `previousCells` captures every cell the rect
|
|
700
|
+
* overwrites so undo replays them onto the layer.
|
|
701
|
+
*/
|
|
702
|
+
commitTilemapRect(rect) {
|
|
703
|
+
const tool = getTilemapToolState(this.game);
|
|
704
|
+
const layer = this.findActiveTilemapLayer(rect.entityId, rect.layerId);
|
|
705
|
+
if (!layer)
|
|
706
|
+
return;
|
|
707
|
+
const x0 = Math.min(rect.startX, rect.endX);
|
|
708
|
+
const y0 = Math.min(rect.startY, rect.endY);
|
|
709
|
+
const x1 = Math.max(rect.startX, rect.endX);
|
|
710
|
+
const y1 = Math.max(rect.startY, rect.endY);
|
|
711
|
+
const w = x1 - x0 + 1;
|
|
712
|
+
const h = y1 - y0 + 1;
|
|
713
|
+
// Slice 6 Phase D — autotile rect commit. Each in-rect cell is one
|
|
714
|
+
// "clicked cell" for the cascade; previousCells covers every cell
|
|
715
|
+
// the union of cascades touched (rect interior + 1-cell border).
|
|
716
|
+
if (rect.autotile) {
|
|
717
|
+
const ctx = this.resolveAutotileTerrainContext(layer, rect.autotile.terrainId);
|
|
718
|
+
if (!ctx)
|
|
719
|
+
return;
|
|
720
|
+
const mode = rect.autotile.erase ? 'erase' : 'paint';
|
|
721
|
+
const clickedCells = [];
|
|
722
|
+
const affectedMap = new Map();
|
|
723
|
+
// Snapshot prev indices BEFORE any cascade runs (first-wins per cell).
|
|
724
|
+
const layerW = layer.tilemap.width;
|
|
725
|
+
const layerH = layer.tilemap.height;
|
|
726
|
+
for (let y = y0 - 1; y <= y1 + 1; y++) {
|
|
727
|
+
for (let x = x0 - 1; x <= x1 + 1; x++) {
|
|
728
|
+
if (x < 0 || y < 0 || x >= layerW || y >= layerH)
|
|
729
|
+
continue;
|
|
730
|
+
const key = `${x},${y}`;
|
|
731
|
+
if (affectedMap.has(key))
|
|
732
|
+
continue;
|
|
733
|
+
const t = layer.getTileAt(x, y, true);
|
|
734
|
+
const prev = t && t.index >= 0 ? t.index : -1;
|
|
735
|
+
affectedMap.set(key, { x, y, prevIndex: prev });
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
const rectKind = getAutotileKind(ctx.asset);
|
|
739
|
+
for (let y = y0; y <= y1; y++) {
|
|
740
|
+
for (let x = x0; x <= x1; x++) {
|
|
741
|
+
clickedCells.push({ x, y });
|
|
742
|
+
applyAutotile(layer, x, y, ctx.terrain, mode, rectKind);
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
// D.2.5.2 fix — read post-cascade tile indices for save persistence.
|
|
746
|
+
const resolvedCells = Array.from(affectedMap.values()).map((c) => {
|
|
747
|
+
const t = layer.getTileAt(c.x, c.y, true);
|
|
748
|
+
const idx = t && t.index >= 0 ? t.index : null;
|
|
749
|
+
return { x: c.x, y: c.y, index: idx };
|
|
750
|
+
});
|
|
751
|
+
const op = {
|
|
752
|
+
kind: 'autotilePaint',
|
|
753
|
+
layerId: rect.layerId,
|
|
754
|
+
terrainId: rect.autotile.terrainId,
|
|
755
|
+
cells: clickedCells,
|
|
756
|
+
erase: rect.autotile.erase,
|
|
757
|
+
resolvedCells,
|
|
758
|
+
};
|
|
759
|
+
const previousCells = Array.from(affectedMap.values()).map((c) => ({
|
|
760
|
+
x: c.x,
|
|
761
|
+
y: c.y,
|
|
762
|
+
index: c.prevIndex < 0 ? null : c.prevIndex,
|
|
763
|
+
}));
|
|
764
|
+
const message = {
|
|
765
|
+
type: 'umicat:editor:tilemapEdited',
|
|
766
|
+
entityId: rect.entityId,
|
|
767
|
+
op,
|
|
768
|
+
previousCells,
|
|
769
|
+
};
|
|
770
|
+
if (typeof window !== 'undefined' && window.parent) {
|
|
771
|
+
window.parent.postMessage(message, '*');
|
|
772
|
+
}
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
if (tool.activeTile == null)
|
|
776
|
+
return;
|
|
777
|
+
const previousCells = [];
|
|
778
|
+
for (let y = y0; y <= y1; y++) {
|
|
779
|
+
for (let x = x0; x <= x1; x++) {
|
|
780
|
+
const t = layer.getTileAt(x, y, true);
|
|
781
|
+
const prev = t && t.index >= 0 ? t.index : null;
|
|
782
|
+
previousCells.push({ x, y, index: prev });
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
const op = {
|
|
786
|
+
kind: 'fillRect',
|
|
787
|
+
layerId: rect.layerId,
|
|
788
|
+
x: x0,
|
|
789
|
+
y: y0,
|
|
790
|
+
w,
|
|
791
|
+
h,
|
|
792
|
+
index: tool.activeTile,
|
|
793
|
+
};
|
|
794
|
+
applyTilemapOp(layer, op);
|
|
795
|
+
const message = {
|
|
796
|
+
type: 'umicat:editor:tilemapEdited',
|
|
797
|
+
entityId: rect.entityId,
|
|
798
|
+
op,
|
|
799
|
+
previousCells,
|
|
800
|
+
};
|
|
801
|
+
if (typeof window !== 'undefined' && window.parent) {
|
|
802
|
+
window.parent.postMessage(message, '*');
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
/**
|
|
806
|
+
* Bucket fill (4-connected flood). Captures every cell the flood
|
|
807
|
+
* touches into previousCells before applying, then posts one tilemapEdited
|
|
808
|
+
* so undo restores the whole flooded area.
|
|
809
|
+
*
|
|
810
|
+
* Reads the layer in one pass to compute the visited set + previous
|
|
811
|
+
* indices, then commits via Phaser's putTileAt. Single-pass because
|
|
812
|
+
* applyTilemapOp would re-walk + duplicate work.
|
|
813
|
+
*/
|
|
814
|
+
applyBucketFill(layer, seedX, seedY, targetIndex, tool) {
|
|
815
|
+
if (!tool.tilemapEditingId || !tool.activeLayerId)
|
|
816
|
+
return;
|
|
817
|
+
const w = layer.tilemap.width;
|
|
818
|
+
const h = layer.tilemap.height;
|
|
819
|
+
const seedTile = layer.getTileAt(seedX, seedY, true);
|
|
820
|
+
const seedIndex = seedTile && seedTile.index >= 0 ? seedTile.index : -1;
|
|
821
|
+
if (seedIndex === targetIndex)
|
|
822
|
+
return; // already painted
|
|
823
|
+
const visited = new Set();
|
|
824
|
+
const previousCells = [];
|
|
825
|
+
const stack = [[seedX, seedY]];
|
|
826
|
+
let safety = 0;
|
|
827
|
+
while (stack.length > 0 && safety++ < w * h) {
|
|
828
|
+
const [cx, cy] = stack.pop();
|
|
829
|
+
if (cx < 0 || cy < 0 || cx >= w || cy >= h)
|
|
830
|
+
continue;
|
|
831
|
+
const key = cy * w + cx;
|
|
832
|
+
if (visited.has(key))
|
|
833
|
+
continue;
|
|
834
|
+
const t = layer.getTileAt(cx, cy, true);
|
|
835
|
+
const idx = t && t.index >= 0 ? t.index : -1;
|
|
836
|
+
if (idx !== seedIndex)
|
|
837
|
+
continue;
|
|
838
|
+
visited.add(key);
|
|
839
|
+
previousCells.push({ x: cx, y: cy, index: idx < 0 ? null : idx });
|
|
840
|
+
layer.putTileAt(targetIndex, cx, cy);
|
|
841
|
+
stack.push([cx + 1, cy], [cx - 1, cy], [cx, cy + 1], [cx, cy - 1]);
|
|
842
|
+
}
|
|
843
|
+
if (previousCells.length === 0)
|
|
844
|
+
return;
|
|
845
|
+
const op = {
|
|
846
|
+
kind: 'bucketFill',
|
|
847
|
+
layerId: tool.activeLayerId,
|
|
848
|
+
x: seedX,
|
|
849
|
+
y: seedY,
|
|
850
|
+
index: targetIndex,
|
|
851
|
+
};
|
|
852
|
+
const message = {
|
|
853
|
+
type: 'umicat:editor:tilemapEdited',
|
|
854
|
+
entityId: tool.tilemapEditingId,
|
|
855
|
+
op,
|
|
856
|
+
previousCells,
|
|
857
|
+
};
|
|
858
|
+
if (typeof window !== 'undefined' && window.parent) {
|
|
859
|
+
window.parent.postMessage(message, '*');
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
/**
|
|
863
|
+
* Slice 6 Phase D — autotile bucket fill. 4-connected flood over cells
|
|
864
|
+
* with matching tile index (or all empties), then runs the wang cascade
|
|
865
|
+
* for each flooded cell. The resulting `autotilePaint` op carries every
|
|
866
|
+
* flooded cell as a clicked cell; `previousCells` covers the flood +
|
|
867
|
+
* its 1-cell border for cascade-correct undo.
|
|
868
|
+
*/
|
|
869
|
+
applyAutotileBucketFill(layer, seedX, seedY, ctx, tool) {
|
|
870
|
+
if (!tool.tilemapEditingId || !tool.activeLayerId || !tool.activeTerrainId)
|
|
871
|
+
return;
|
|
872
|
+
const w = layer.tilemap.width;
|
|
873
|
+
const h = layer.tilemap.height;
|
|
874
|
+
const seedTile = layer.getTileAt(seedX, seedY, true);
|
|
875
|
+
const seedIndex = seedTile && seedTile.index >= 0 ? seedTile.index : -1;
|
|
876
|
+
const visited = new Set();
|
|
877
|
+
const clicked = [];
|
|
878
|
+
const stack = [[seedX, seedY]];
|
|
879
|
+
let safety = 0;
|
|
880
|
+
while (stack.length > 0 && safety++ < w * h) {
|
|
881
|
+
const [cx, cy] = stack.pop();
|
|
882
|
+
if (cx < 0 || cy < 0 || cx >= w || cy >= h)
|
|
883
|
+
continue;
|
|
884
|
+
const key = cy * w + cx;
|
|
885
|
+
if (visited.has(key))
|
|
886
|
+
continue;
|
|
887
|
+
const t = layer.getTileAt(cx, cy, true);
|
|
888
|
+
const idx = t && t.index >= 0 ? t.index : -1;
|
|
889
|
+
if (idx !== seedIndex)
|
|
890
|
+
continue;
|
|
891
|
+
visited.add(key);
|
|
892
|
+
clicked.push({ x: cx, y: cy });
|
|
893
|
+
stack.push([cx + 1, cy], [cx - 1, cy], [cx, cy + 1], [cx, cy - 1]);
|
|
894
|
+
}
|
|
895
|
+
if (clicked.length === 0)
|
|
896
|
+
return;
|
|
897
|
+
// Snapshot prev indices for flooded cells + their 1-cell border BEFORE
|
|
898
|
+
// any cascade runs (cascade will mutate neighbors; first-wins matters).
|
|
899
|
+
const affectedMap = new Map();
|
|
900
|
+
for (const c of clicked) {
|
|
901
|
+
for (let dy = -1; dy <= 1; dy++) {
|
|
902
|
+
for (let dx = -1; dx <= 1; dx++) {
|
|
903
|
+
const x = c.x + dx;
|
|
904
|
+
const y = c.y + dy;
|
|
905
|
+
if (x < 0 || y < 0 || x >= w || y >= h)
|
|
906
|
+
continue;
|
|
907
|
+
const k = `${x},${y}`;
|
|
908
|
+
if (affectedMap.has(k))
|
|
909
|
+
continue;
|
|
910
|
+
const tt = layer.getTileAt(x, y, true);
|
|
911
|
+
const prev = tt && tt.index >= 0 ? tt.index : -1;
|
|
912
|
+
affectedMap.set(k, { x, y, prevIndex: prev });
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
const fillKind = getAutotileKind(ctx.asset);
|
|
917
|
+
for (const c of clicked) {
|
|
918
|
+
applyAutotile(layer, c.x, c.y, ctx.terrain, 'paint', fillKind);
|
|
919
|
+
}
|
|
920
|
+
// D.2.5.2 fix — capture cascade-resolved tile indices for save.
|
|
921
|
+
const resolvedCells = Array.from(affectedMap.values()).map((c) => {
|
|
922
|
+
const tt = layer.getTileAt(c.x, c.y, true);
|
|
923
|
+
const idx = tt && tt.index >= 0 ? tt.index : null;
|
|
924
|
+
return { x: c.x, y: c.y, index: idx };
|
|
925
|
+
});
|
|
926
|
+
const op = {
|
|
927
|
+
kind: 'autotilePaint',
|
|
928
|
+
layerId: tool.activeLayerId,
|
|
929
|
+
terrainId: tool.activeTerrainId,
|
|
930
|
+
cells: clicked,
|
|
931
|
+
erase: false,
|
|
932
|
+
resolvedCells,
|
|
933
|
+
};
|
|
934
|
+
const previousCells = Array.from(affectedMap.values()).map((c) => ({
|
|
935
|
+
x: c.x,
|
|
936
|
+
y: c.y,
|
|
937
|
+
index: c.prevIndex < 0 ? null : c.prevIndex,
|
|
938
|
+
}));
|
|
939
|
+
const message = {
|
|
940
|
+
type: 'umicat:editor:tilemapEdited',
|
|
941
|
+
entityId: tool.tilemapEditingId,
|
|
942
|
+
op,
|
|
943
|
+
previousCells,
|
|
944
|
+
};
|
|
945
|
+
if (typeof window !== 'undefined' && window.parent) {
|
|
946
|
+
window.parent.postMessage(message, '*');
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
/**
|
|
950
|
+
* Look up the active TilemapLayer in the world scene's entity registry,
|
|
951
|
+
* then walk the container's children for the layer with matching
|
|
952
|
+
* `tilemapLayerId` data tag.
|
|
953
|
+
*/
|
|
954
|
+
findActiveTilemapLayer(tilemapId, layerId) {
|
|
955
|
+
const resolved = this.findActiveTilemapLayerWithContainer(tilemapId, layerId);
|
|
956
|
+
return resolved?.layer ?? null;
|
|
957
|
+
}
|
|
958
|
+
/**
|
|
959
|
+
* Same as findActiveTilemapLayer but returns the container too. Callers
|
|
960
|
+
* that need to convert world coords to tile coords need the container's
|
|
961
|
+
* position to shift the input — `TilemapLayer.worldToTileXY` doesn't
|
|
962
|
+
* apply the container transform on its own (the layer's `x`/`y` are
|
|
963
|
+
* local to the container, not world coords).
|
|
964
|
+
*/
|
|
965
|
+
findActiveTilemapLayerWithContainer(tilemapId, layerId) {
|
|
966
|
+
const registry = this.findEntityRegistry();
|
|
967
|
+
if (!registry)
|
|
968
|
+
return null;
|
|
969
|
+
const container = registry.byId(tilemapId);
|
|
970
|
+
if (!container || !(container instanceof Phaser.GameObjects.Container)) {
|
|
971
|
+
return null;
|
|
972
|
+
}
|
|
973
|
+
const layer = findTilemapLayerById(container, layerId);
|
|
974
|
+
if (!layer)
|
|
975
|
+
return null;
|
|
976
|
+
return { layer, container };
|
|
977
|
+
}
|
|
978
|
+
/**
|
|
979
|
+
* Apply one stroke cell: read prev index (for undo), apply the new
|
|
980
|
+
* value via Phaser's tilemap API, push the cell into the stroke
|
|
981
|
+
* accumulator. Dedup happens in `appendTilemapStrokeCell` so dragging
|
|
982
|
+
* over the same cell multiple times only records once.
|
|
983
|
+
*/
|
|
984
|
+
applyStrokeCell(layer, x, y, tool) {
|
|
985
|
+
const prev = layer.getTileAt(x, y, true);
|
|
986
|
+
const prevIndex = prev ? prev.index : -1;
|
|
987
|
+
// -1 is Phaser's "no tile" sentinel; for our op shape we use null to
|
|
988
|
+
// mean "cell was empty" so the host's undo logic doesn't have to
|
|
989
|
+
// remember the magic number.
|
|
990
|
+
const prevIndexOrNull = prevIndex < 0 ? null : prevIndex;
|
|
991
|
+
let appliedOp;
|
|
992
|
+
if (tool.tool === 'eraser') {
|
|
993
|
+
appliedOp = { kind: 'erase', layerId: tool.activeLayerId, cells: [{ x, y }] };
|
|
994
|
+
appendTilemapStrokeCell(this.game, { x, y, index: null, prevIndex: prevIndexOrNull });
|
|
995
|
+
}
|
|
996
|
+
else {
|
|
997
|
+
const idx = tool.activeTile;
|
|
998
|
+
appliedOp = { kind: 'paint', layerId: tool.activeLayerId, cells: [{ x, y, index: idx }] };
|
|
999
|
+
appendTilemapStrokeCell(this.game, { x, y, index: idx, prevIndex: prevIndexOrNull });
|
|
1000
|
+
}
|
|
1001
|
+
applyTilemapOp(layer, appliedOp);
|
|
1002
|
+
}
|
|
1003
|
+
/**
|
|
1004
|
+
* Slice 6 Phase D — resolve `{ asset, terrain }` for the layer's
|
|
1005
|
+
* tileset + a requested terrain id. Returns null when:
|
|
1006
|
+
* - the layer has no `tilemapTilesetId` stamp (legacy / unattached layer)
|
|
1007
|
+
* - the manifest has no asset for that id
|
|
1008
|
+
* - the asset's tileset metadata lacks `autotile.terrains` with that id
|
|
1009
|
+
* Callers fall back to stamp-mode behavior on null.
|
|
1010
|
+
*/
|
|
1011
|
+
resolveAutotileTerrainContext(layer, terrainId) {
|
|
1012
|
+
const tilesetId = layer.getData('tilemapTilesetId');
|
|
1013
|
+
if (!tilesetId) {
|
|
1014
|
+
console.warn(`[umicat/editor] resolveAutotileTerrainContext: layer is missing 'tilemapTilesetId' data stamp. ` +
|
|
1015
|
+
`Layer was created without a tileset binding — likely cause: addLayer op fired before the asset was loaded into the iframe cache, or the layer's tilesetIds[] was empty at spawn.`);
|
|
1016
|
+
return null;
|
|
1017
|
+
}
|
|
1018
|
+
const manifest = getManifest(layer.scene);
|
|
1019
|
+
const asset = manifest?.assets.find((a) => a.id === tilesetId);
|
|
1020
|
+
if (!asset) {
|
|
1021
|
+
console.warn(`[umicat/editor] resolveAutotileTerrainContext: asset '${tilesetId}' (referenced by the active tilemap layer) is not in the iframe's manifest cache. ` +
|
|
1022
|
+
`Likely cause: the asset was added to the workspace manifest on disk but the host didn't push an 'umicat:editor:assetUpdate' message to sync the iframe cache.`);
|
|
1023
|
+
return null;
|
|
1024
|
+
}
|
|
1025
|
+
const terrain = findTerrain(asset, terrainId);
|
|
1026
|
+
if (!terrain) {
|
|
1027
|
+
const available = (asset.tileset?.autotile?.terrains ?? []).map((t) => t.id);
|
|
1028
|
+
console.warn(`[umicat/editor] resolveAutotileTerrainContext: asset '${tilesetId}' has no terrain with id '${terrainId}'. ` +
|
|
1029
|
+
`Available terrain ids: [${available.join(', ') || '(none — autotile not configured on this asset)'}]. ` +
|
|
1030
|
+
`Likely cause: TilemapInspectorPanel's terrain palette is reading a fresher AssetSummary than the iframe's manifest cache; an 'umicat:editor:assetUpdate' should have synced after the AutotileEditorModal save.`);
|
|
1031
|
+
return null;
|
|
1032
|
+
}
|
|
1033
|
+
return { asset, terrain };
|
|
1034
|
+
}
|
|
1035
|
+
/**
|
|
1036
|
+
* Slice 6 Phase D — autotile-mode stroke cell. Runs the Wang cascade
|
|
1037
|
+
* via `applyAutotile`, threads every affected cell (clicked + cascade
|
|
1038
|
+
* neighbors) into the stroke accumulator so the pointerup commit can
|
|
1039
|
+
* emit one `autotilePaint` op + a previousCells array that captures
|
|
1040
|
+
* the entire region the click changed.
|
|
1041
|
+
*
|
|
1042
|
+
* Per-frame metadata + sub-tile body sync is handled by the host-
|
|
1043
|
+
* driven `handleEditTilemap` path when undo replays inverse ops; the
|
|
1044
|
+
* live stroke only touches `layer.data` (cell indices) — fine because
|
|
1045
|
+
* cell-rect collision is re-armed at the next handleEditTilemap call.
|
|
1046
|
+
*/
|
|
1047
|
+
applyAutotileStrokeCell(layer, x, y, ctx) {
|
|
1048
|
+
const stroke = getTilemapStroke(this.game);
|
|
1049
|
+
if (!stroke || !stroke.autotile)
|
|
1050
|
+
return;
|
|
1051
|
+
const mode = stroke.autotile.erase ? 'erase' : 'paint';
|
|
1052
|
+
const kind = getAutotileKind(ctx.asset);
|
|
1053
|
+
const affected = applyAutotile(layer, x, y, ctx.terrain, mode, kind);
|
|
1054
|
+
appendAutotileStrokeClick(this.game, x, y, affected);
|
|
1055
|
+
}
|
|
1056
|
+
/**
|
|
1057
|
+
* Sync every tilemap entity's TilemapLayers to its container's
|
|
1058
|
+
* current world position. Runs every frame in edit mode (see update()).
|
|
1059
|
+
* Matches the play-mode hook in SceneLoader#installTilemapLayerSync —
|
|
1060
|
+
* same logic, different trigger source (overlay vs world scene event).
|
|
1061
|
+
* Cheap: handful of number assignments per tilemap.
|
|
1062
|
+
*/
|
|
1063
|
+
syncTilemapLayersFromContainers() {
|
|
1064
|
+
const registry = this.findEntityRegistry();
|
|
1065
|
+
if (!registry)
|
|
1066
|
+
return;
|
|
1067
|
+
for (const go of registry.all()) {
|
|
1068
|
+
if (go.getData('entityKind') !== 'tilemap')
|
|
1069
|
+
continue;
|
|
1070
|
+
const container = go;
|
|
1071
|
+
const layers = go.getData('tilemapLayers');
|
|
1072
|
+
if (!layers)
|
|
1073
|
+
continue;
|
|
1074
|
+
const containerDepth = container.depth ?? 0;
|
|
1075
|
+
for (const layer of layers) {
|
|
1076
|
+
const offX = layer.getData('tilemapLocalOffsetX') ?? 0;
|
|
1077
|
+
const offY = layer.getData('tilemapLocalOffsetY') ?? 0;
|
|
1078
|
+
const targetX = container.x + offX;
|
|
1079
|
+
const targetY = container.y + offY;
|
|
1080
|
+
if (layer.x !== targetX)
|
|
1081
|
+
layer.x = targetX;
|
|
1082
|
+
if (layer.y !== targetY)
|
|
1083
|
+
layer.y = targetY;
|
|
1084
|
+
// FB.10 — sync depth too; matches the play-mode SceneLoader hook.
|
|
1085
|
+
if (layer.depth !== containerDepth)
|
|
1086
|
+
layer.setDepth(containerDepth);
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
// --- Slice 6 Phase B.6.1 — resize-handle drag ----------------------------
|
|
1091
|
+
/**
|
|
1092
|
+
* Read the selected tilemap entity's bounds + return the 8 handle
|
|
1093
|
+
* positions in world coords. Returns null when the selected entity
|
|
1094
|
+
* is not a tilemap (no handles to draw / hit-test).
|
|
1095
|
+
*/
|
|
1096
|
+
tilemapHandlePositions() {
|
|
1097
|
+
const selectedId = getSelection(this.game);
|
|
1098
|
+
if (!selectedId)
|
|
1099
|
+
return null;
|
|
1100
|
+
const registry = this.findEntityRegistry();
|
|
1101
|
+
if (!registry)
|
|
1102
|
+
return null;
|
|
1103
|
+
const go = registry.byId(selectedId);
|
|
1104
|
+
if (!go || go.getData('entityKind') !== 'tilemap')
|
|
1105
|
+
return null;
|
|
1106
|
+
const container = go;
|
|
1107
|
+
const tileSize = go.getData('tilemapTileSize');
|
|
1108
|
+
const size = go.getData('tilemapSize');
|
|
1109
|
+
if (!tileSize || !size)
|
|
1110
|
+
return null;
|
|
1111
|
+
const pxW = size.width * tileSize.width;
|
|
1112
|
+
const pxH = size.height * tileSize.height;
|
|
1113
|
+
const cx = container.x;
|
|
1114
|
+
const cy = container.y;
|
|
1115
|
+
const left = cx - pxW / 2;
|
|
1116
|
+
const right = cx + pxW / 2;
|
|
1117
|
+
const top = cy - pxH / 2;
|
|
1118
|
+
const bottom = cy + pxH / 2;
|
|
1119
|
+
return {
|
|
1120
|
+
entityId: selectedId,
|
|
1121
|
+
center: { x: cx, y: cy },
|
|
1122
|
+
pxW,
|
|
1123
|
+
pxH,
|
|
1124
|
+
cellsW: size.width,
|
|
1125
|
+
cellsH: size.height,
|
|
1126
|
+
tileSize,
|
|
1127
|
+
handles: [
|
|
1128
|
+
{ id: 'nw', x: left, y: top },
|
|
1129
|
+
{ id: 'n', x: cx, y: top },
|
|
1130
|
+
{ id: 'ne', x: right, y: top },
|
|
1131
|
+
{ id: 'e', x: right, y: cy },
|
|
1132
|
+
{ id: 'se', x: right, y: bottom },
|
|
1133
|
+
{ id: 's', x: cx, y: bottom },
|
|
1134
|
+
{ id: 'sw', x: left, y: bottom },
|
|
1135
|
+
{ id: 'w', x: left, y: cy },
|
|
1136
|
+
],
|
|
1137
|
+
};
|
|
1138
|
+
}
|
|
1139
|
+
/**
|
|
1140
|
+
* Hit-test the resize handles. Corners (NW/NE/SW/SE) use a dedicated
|
|
1141
|
+
* ~14px square click target. Edges (N/S/E/W) are grabbable along the
|
|
1142
|
+
* ENTIRE edge line (Figma / Sketch convention) — clicking anywhere on
|
|
1143
|
+
* the blue bounds line resizes that edge. Corner zones take priority
|
|
1144
|
+
* over edges so dragging right at a corner gives diagonal resize, not
|
|
1145
|
+
* single-axis.
|
|
1146
|
+
*
|
|
1147
|
+
* All click targets are zoom-invariant via `1/zoom` scaling so they
|
|
1148
|
+
* stay constant size on screen regardless of how zoomed in/out.
|
|
1149
|
+
*/
|
|
1150
|
+
hitTestTilemapResizeHandle(worldX, worldY) {
|
|
1151
|
+
const info = this.tilemapHandlePositions();
|
|
1152
|
+
if (!info)
|
|
1153
|
+
return null;
|
|
1154
|
+
const cam = this.findActiveEditorCamera();
|
|
1155
|
+
const zoom = cam?.zoom ?? 1;
|
|
1156
|
+
const halfHandleWorld = 7 / zoom; // 14px square = 7px half-side
|
|
1157
|
+
const edgeTolerance = 6 / zoom; // 12px-thick zone perpendicular to edge
|
|
1158
|
+
// 1. Corner handles take priority — small dedicated squares.
|
|
1159
|
+
for (const id of ['nw', 'ne', 'sw', 'se']) {
|
|
1160
|
+
const h = info.handles.find((h) => h.id === id);
|
|
1161
|
+
if (!h)
|
|
1162
|
+
continue;
|
|
1163
|
+
if (Math.abs(worldX - h.x) <= halfHandleWorld &&
|
|
1164
|
+
Math.abs(worldY - h.y) <= halfHandleWorld) {
|
|
1165
|
+
return id;
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
// 2. Edge zones — entire edge line is the click target, EXCLUDING
|
|
1169
|
+
// the corner zones (so corners always win when overlap).
|
|
1170
|
+
const left = info.center.x - info.pxW / 2;
|
|
1171
|
+
const right = info.center.x + info.pxW / 2;
|
|
1172
|
+
const top = info.center.y - info.pxH / 2;
|
|
1173
|
+
const bottom = info.center.y + info.pxH / 2;
|
|
1174
|
+
const xWithinHorizontalEdge = worldX >= left + halfHandleWorld && worldX <= right - halfHandleWorld;
|
|
1175
|
+
const yWithinVerticalEdge = worldY >= top + halfHandleWorld && worldY <= bottom - halfHandleWorld;
|
|
1176
|
+
if (Math.abs(worldY - top) <= edgeTolerance && xWithinHorizontalEdge)
|
|
1177
|
+
return 'n';
|
|
1178
|
+
if (Math.abs(worldY - bottom) <= edgeTolerance && xWithinHorizontalEdge)
|
|
1179
|
+
return 's';
|
|
1180
|
+
if (Math.abs(worldX - left) <= edgeTolerance && yWithinVerticalEdge)
|
|
1181
|
+
return 'w';
|
|
1182
|
+
if (Math.abs(worldX - right) <= edgeTolerance && yWithinVerticalEdge)
|
|
1183
|
+
return 'e';
|
|
1184
|
+
return null;
|
|
1185
|
+
}
|
|
1186
|
+
/**
|
|
1187
|
+
* Set canvas cursor to a resize-arrow when hovering a handle, or restore
|
|
1188
|
+
* default. Direction matches the handle's axis (Figma / Sketch / browser
|
|
1189
|
+
* native convention):
|
|
1190
|
+
* NW/SE corners → `nwse-resize` (↖↘)
|
|
1191
|
+
* NE/SW corners → `nesw-resize` (↗↙)
|
|
1192
|
+
* N/S edges → `ns-resize` (↕)
|
|
1193
|
+
* E/W edges → `ew-resize` (↔)
|
|
1194
|
+
*
|
|
1195
|
+
* Active drag (getTilemapResize non-null) pins the cursor to the dragged
|
|
1196
|
+
* handle so it doesn't flicker back to default when the cursor briefly
|
|
1197
|
+
* leaves the handle's 14px target box during fast drag.
|
|
1198
|
+
*/
|
|
1199
|
+
updateResizeCursor(pointer) {
|
|
1200
|
+
const canvas = this.game.canvas;
|
|
1201
|
+
if (!canvas)
|
|
1202
|
+
return;
|
|
1203
|
+
let handle = null;
|
|
1204
|
+
const inDrag = getTilemapResize(this.game);
|
|
1205
|
+
if (inDrag) {
|
|
1206
|
+
handle = inDrag.handle;
|
|
1207
|
+
}
|
|
1208
|
+
else if (isTilemapPaintMode(this.game)) {
|
|
1209
|
+
const { x, y } = this.pointerCoords(pointer);
|
|
1210
|
+
handle = this.hitTestTilemapResizeHandle(x, y);
|
|
1211
|
+
}
|
|
1212
|
+
if (!handle) {
|
|
1213
|
+
// Only reset when WE set it earlier; don't clobber pan-grab or other
|
|
1214
|
+
// cursor states future features might add.
|
|
1215
|
+
if (canvas.style.cursor.endsWith('resize'))
|
|
1216
|
+
canvas.style.cursor = '';
|
|
1217
|
+
return;
|
|
1218
|
+
}
|
|
1219
|
+
canvas.style.cursor = RESIZE_CURSOR_FOR_HANDLE[handle];
|
|
1220
|
+
}
|
|
1221
|
+
/**
|
|
1222
|
+
* Begin a resize-handle drag. Snapshots the starting size + center so
|
|
1223
|
+
* pointermove can compute deltas relative to drag-start.
|
|
1224
|
+
*/
|
|
1225
|
+
beginTilemapResizeDrag(handle) {
|
|
1226
|
+
const info = this.tilemapHandlePositions();
|
|
1227
|
+
if (!info)
|
|
1228
|
+
return;
|
|
1229
|
+
beginTilemapResize(this.game, {
|
|
1230
|
+
entityId: info.entityId,
|
|
1231
|
+
handle,
|
|
1232
|
+
startSize: { width: info.cellsW, height: info.cellsH },
|
|
1233
|
+
startCenter: { x: info.center.x, y: info.center.y },
|
|
1234
|
+
tileSize: info.tileSize,
|
|
1235
|
+
previewWidth: info.cellsW,
|
|
1236
|
+
previewHeight: info.cellsH,
|
|
1237
|
+
previewCenter: { x: info.center.x, y: info.center.y },
|
|
1238
|
+
});
|
|
1239
|
+
}
|
|
1240
|
+
/**
|
|
1241
|
+
* Update preview dims based on cursor position. Snaps to integer cells.
|
|
1242
|
+
* The handle dictates which edges move — opposite edges stay fixed.
|
|
1243
|
+
*
|
|
1244
|
+
* Algorithm: from the cursor position + handle, compute the new L/R/T/B
|
|
1245
|
+
* edges; opposite edges are pinned to start values. New size = R-L, B-T
|
|
1246
|
+
* in pixels → divide by tileSize for cell counts. New center = midpoint
|
|
1247
|
+
* of new L/R/T/B.
|
|
1248
|
+
*/
|
|
1249
|
+
updateTilemapResizeDrag(worldX, worldY) {
|
|
1250
|
+
const resize = getTilemapResize(this.game);
|
|
1251
|
+
if (!resize)
|
|
1252
|
+
return;
|
|
1253
|
+
const { handle, startSize, startCenter, tileSize } = resize;
|
|
1254
|
+
const startPxW = startSize.width * tileSize.width;
|
|
1255
|
+
const startPxH = startSize.height * tileSize.height;
|
|
1256
|
+
const startLeft = startCenter.x - startPxW / 2;
|
|
1257
|
+
const startRight = startCenter.x + startPxW / 2;
|
|
1258
|
+
const startTop = startCenter.y - startPxH / 2;
|
|
1259
|
+
const startBottom = startCenter.y + startPxH / 2;
|
|
1260
|
+
let newLeft = startLeft;
|
|
1261
|
+
let newRight = startRight;
|
|
1262
|
+
let newTop = startTop;
|
|
1263
|
+
let newBottom = startBottom;
|
|
1264
|
+
// Which edges does this handle move?
|
|
1265
|
+
const movesLeft = handle === 'nw' || handle === 'w' || handle === 'sw';
|
|
1266
|
+
const movesRight = handle === 'ne' || handle === 'e' || handle === 'se';
|
|
1267
|
+
const movesTop = handle === 'nw' || handle === 'n' || handle === 'ne';
|
|
1268
|
+
const movesBottom = handle === 'sw' || handle === 's' || handle === 'se';
|
|
1269
|
+
if (movesLeft)
|
|
1270
|
+
newLeft = worldX;
|
|
1271
|
+
if (movesRight)
|
|
1272
|
+
newRight = worldX;
|
|
1273
|
+
if (movesTop)
|
|
1274
|
+
newTop = worldY;
|
|
1275
|
+
if (movesBottom)
|
|
1276
|
+
newBottom = worldY;
|
|
1277
|
+
// Snap to whole cells. Round each moving edge to nearest cell boundary
|
|
1278
|
+
// measured from the anchor (opposite) edge.
|
|
1279
|
+
if (movesLeft) {
|
|
1280
|
+
const cellsFromRight = Math.max(1, Math.round((newRight - newLeft) / tileSize.width));
|
|
1281
|
+
newLeft = newRight - cellsFromRight * tileSize.width;
|
|
1282
|
+
}
|
|
1283
|
+
if (movesRight) {
|
|
1284
|
+
const cellsFromLeft = Math.max(1, Math.round((newRight - newLeft) / tileSize.width));
|
|
1285
|
+
newRight = newLeft + cellsFromLeft * tileSize.width;
|
|
1286
|
+
}
|
|
1287
|
+
if (movesTop) {
|
|
1288
|
+
const cellsFromBottom = Math.max(1, Math.round((newBottom - newTop) / tileSize.height));
|
|
1289
|
+
newTop = newBottom - cellsFromBottom * tileSize.height;
|
|
1290
|
+
}
|
|
1291
|
+
if (movesBottom) {
|
|
1292
|
+
const cellsFromTop = Math.max(1, Math.round((newBottom - newTop) / tileSize.height));
|
|
1293
|
+
newBottom = newTop + cellsFromTop * tileSize.height;
|
|
1294
|
+
}
|
|
1295
|
+
const previewWidth = Math.round((newRight - newLeft) / tileSize.width);
|
|
1296
|
+
const previewHeight = Math.round((newBottom - newTop) / tileSize.height);
|
|
1297
|
+
if (previewWidth < 1 || previewHeight < 1)
|
|
1298
|
+
return;
|
|
1299
|
+
const previewCenter = {
|
|
1300
|
+
x: (newLeft + newRight) / 2,
|
|
1301
|
+
y: (newTop + newBottom) / 2,
|
|
1302
|
+
};
|
|
1303
|
+
updateTilemapResize(this.game, {
|
|
1304
|
+
previewWidth,
|
|
1305
|
+
previewHeight,
|
|
1306
|
+
previewCenter,
|
|
1307
|
+
});
|
|
1308
|
+
}
|
|
1309
|
+
/**
|
|
1310
|
+
* Commit the resize drag — post a `resize` op via `editTilemap` channel,
|
|
1311
|
+
* carrying the new dims + transformDelta to keep the opposite edge
|
|
1312
|
+
* anchored. Posted directly to host (skipping the local SDK apply path)
|
|
1313
|
+
* because the host's draft + iframe runtime BOTH need the op — host
|
|
1314
|
+
* records the command for undo + applies the same op back via the
|
|
1315
|
+
* normal editTilemap dispatch (which mutates the live runtime).
|
|
1316
|
+
*/
|
|
1317
|
+
commitTilemapResize() {
|
|
1318
|
+
const resize = endTilemapResize(this.game);
|
|
1319
|
+
if (!resize)
|
|
1320
|
+
return;
|
|
1321
|
+
const { entityId, startSize, startCenter, previewWidth, previewHeight, previewCenter } = resize;
|
|
1322
|
+
if (previewWidth === startSize.width && previewHeight === startSize.height)
|
|
1323
|
+
return;
|
|
1324
|
+
const transformDelta = {
|
|
1325
|
+
x: previewCenter.x - startCenter.x,
|
|
1326
|
+
y: previewCenter.y - startCenter.y,
|
|
1327
|
+
};
|
|
1328
|
+
const op = {
|
|
1329
|
+
kind: 'resize',
|
|
1330
|
+
width: previewWidth,
|
|
1331
|
+
height: previewHeight,
|
|
1332
|
+
...(transformDelta.x !== 0 || transformDelta.y !== 0 ? { transformDelta } : {}),
|
|
1333
|
+
};
|
|
1334
|
+
// Apply LOCALLY first — destroys + rebuilds the live TilemapLayers
|
|
1335
|
+
// at the new dims, shifts container.x/y by transformDelta to keep
|
|
1336
|
+
// the opposite edge anchored. Without this local apply, the SDK
|
|
1337
|
+
// runtime would stay at the old size (bounds rect reads stale
|
|
1338
|
+
// `tilemapSize` stash) → user sees the bounds "snap back" after
|
|
1339
|
+
// release, even though the host's draft + persisted scene file
|
|
1340
|
+
// ARE at the new size.
|
|
1341
|
+
// Same pattern as brush/rect/fill: apply local, post for undo.
|
|
1342
|
+
handleEditTilemap(this.game, entityId, [op]);
|
|
1343
|
+
const message = {
|
|
1344
|
+
type: 'umicat:editor:tilemapEdited',
|
|
1345
|
+
entityId,
|
|
1346
|
+
op,
|
|
1347
|
+
previousCells: [],
|
|
1348
|
+
};
|
|
1349
|
+
if (typeof window !== 'undefined' && window.parent) {
|
|
1350
|
+
window.parent.postMessage(message, '*');
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
update() {
|
|
1354
|
+
this.graphics.clear();
|
|
1355
|
+
// Slice 6 Phase B (fix): sync tilemap layer world positions to their
|
|
1356
|
+
// entity transform. Runs from overlay scene (always active in edit
|
|
1357
|
+
// mode) — the equivalent hook on world scene's UPDATE event doesn't
|
|
1358
|
+
// fire because the world scene is paused in edit mode (see
|
|
1359
|
+
// SceneLoader#installTilemapLayerSync's comment). Without this sync,
|
|
1360
|
+
// dragging a tilemap entity moves the bounds but not the painted
|
|
1361
|
+
// layers (or the resize-rebuilt layers don't track post-resize
|
|
1362
|
+
// container.x/y changes).
|
|
1363
|
+
this.syncTilemapLayersFromContainers();
|
|
1364
|
+
if (this.worldBounds) {
|
|
1365
|
+
// World bounds outline (the visible game frame). Outside-fill grey
|
|
1366
|
+
// strips moved to a separate Graphics in the WORLD scene at very
|
|
1367
|
+
// low depth (see `installVoidFill` in EditorBridge) — drawing
|
|
1368
|
+
// them HERE in overlay scene meant they rendered ABOVE entities,
|
|
1369
|
+
// so an entity dropped outside world bounds was visually covered
|
|
1370
|
+
// by grey (only the selection rect was visible). Putting the
|
|
1371
|
+
// fill below entities lets users see their out-of-bounds drops.
|
|
1372
|
+
this.graphics.lineStyle(2, WORLD_BOUNDS_COLOR, WORLD_BOUNDS_ALPHA);
|
|
1373
|
+
this.graphics.strokeRect(this.worldBounds.x, this.worldBounds.y, this.worldBounds.width, this.worldBounds.height);
|
|
1374
|
+
}
|
|
1375
|
+
// P1 infinite canvas — camera viewport rect ("Camera screen"): shows
|
|
1376
|
+
// where the GAME'S runtime camera (cameras.main, untouched during edit
|
|
1377
|
+
// mode) is currently positioned + how much it sees. Lets the user
|
|
1378
|
+
// place entities inside vs outside the camera's view at game start.
|
|
1379
|
+
// For games where world == camera viewport (Space Invaders, Tetris)
|
|
1380
|
+
// this rect coincides with the world bounds rect. For Mario / RPG /
|
|
1381
|
+
// farming etc. where world > camera, this is a small blue rect inside
|
|
1382
|
+
// the larger world rect.
|
|
1383
|
+
const worldSceneCam = this.findWorldSceneCamera();
|
|
1384
|
+
if (worldSceneCam) {
|
|
1385
|
+
const camX = worldSceneCam.scrollX;
|
|
1386
|
+
const camY = worldSceneCam.scrollY;
|
|
1387
|
+
// Use the GAME'S INTRINSIC size (snapshot from enterEdit), not the
|
|
1388
|
+
// current canvas dims. During edit the canvas is expanded to fill
|
|
1389
|
+
// the iframe, and game.scale.width is the expanded value — which
|
|
1390
|
+
// would draw the "Camera screen" rect at expanded canvas size,
|
|
1391
|
+
// not at the game's runtime camera viewport size. The runtime cam
|
|
1392
|
+
// ALWAYS renders the game's declared resolution (e.g. 720×1280),
|
|
1393
|
+
// regardless of what the editor does to the canvas.
|
|
1394
|
+
const snap = this.game['__unboxyEditorScaleSnapshot'];
|
|
1395
|
+
const intrinsicW = snap?.gameWidth ?? this.game.scale.width;
|
|
1396
|
+
const intrinsicH = snap?.gameHeight ?? this.game.scale.height;
|
|
1397
|
+
const camW = intrinsicW / worldSceneCam.zoom;
|
|
1398
|
+
const camH = intrinsicH / worldSceneCam.zoom;
|
|
1399
|
+
this.graphics.lineStyle(CAMERA_VIEWPORT_LINE_WIDTH, CAMERA_VIEWPORT_COLOR, CAMERA_VIEWPORT_ALPHA);
|
|
1400
|
+
this.graphics.strokeRect(camX, camY, camW, camH);
|
|
1401
|
+
}
|
|
1402
|
+
const selectedId = getSelection(this.game);
|
|
1403
|
+
if (selectedId) {
|
|
1404
|
+
const registry = this.findEntityRegistry();
|
|
1405
|
+
const go = registry?.byId(selectedId);
|
|
1406
|
+
if (go) {
|
|
1407
|
+
const bounds = computeBounds(go);
|
|
1408
|
+
if (bounds) {
|
|
1409
|
+
// HUD-mode selection-rect transform fix (2026-05-17): HUD
|
|
1410
|
+
// widgets render through the HUD cam, which has its viewport
|
|
1411
|
+
// POSITIONED at the camera-viewport rect on the editor canvas
|
|
1412
|
+
// (offset by `(gameCam.scrollX - editorCam.scrollX) *
|
|
1413
|
+
// editorCam.zoom`). But this overlay scene's selection rect
|
|
1414
|
+
// draws through the editor cam directly. The mismatch leaves
|
|
1415
|
+
// a `gameCam.scrollX * editorCam.zoom` drift that scales
|
|
1416
|
+
// with zoom — visible as the selection rect getting more
|
|
1417
|
+
// and more off the widget as the user zooms in. Math:
|
|
1418
|
+
// bg canvas pos = camRectCanvasX + (bounds.x) * zoom
|
|
1419
|
+
// = (gameCam.scrollX - editorCam.scrollX) * zoom
|
|
1420
|
+
// + bounds.x * zoom
|
|
1421
|
+
// selRect canvas pos = (bounds.x - editorCam.scrollX) * zoom
|
|
1422
|
+
// diff = gameCam.scrollX * zoom
|
|
1423
|
+
// Fix: add gameCam.scrollX/Y to the rect's world coords so
|
|
1424
|
+
// the overlay-cam transform produces the same canvas pos as
|
|
1425
|
+
// the HUD-cam transform. World-mode entities are unaffected
|
|
1426
|
+
// because they render through the editor cam directly, no
|
|
1427
|
+
// camRectCanvasX offset.
|
|
1428
|
+
let drawX = bounds.x;
|
|
1429
|
+
let drawY = bounds.y;
|
|
1430
|
+
if (getEditorMode(this.game) === 'hud') {
|
|
1431
|
+
const ws = this.findWorldSceneInstance();
|
|
1432
|
+
const gameCam = ws?.cameras.main;
|
|
1433
|
+
if (gameCam) {
|
|
1434
|
+
drawX += gameCam.scrollX;
|
|
1435
|
+
drawY += gameCam.scrollY;
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
this.graphics.lineStyle(SELECTION_LINE_WIDTH, SELECTION_COLOR, SELECTION_ALPHA);
|
|
1439
|
+
this.graphics.strokeRect(drawX, drawY, bounds.width, bounds.height);
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
// Slice 8: "Show hitboxes" debug overlay. Only renders in world mode;
|
|
1444
|
+
// HUD widgets don't carry world hitboxes.
|
|
1445
|
+
if (getEditorMode(this.game) !== 'hud' && getDebugOverlayState(this.game).showHitboxes) {
|
|
1446
|
+
this.drawHitboxDebug();
|
|
1447
|
+
}
|
|
1448
|
+
// Slice 6 Phase B.4: rect-drag preview. Drawn over the live tilemap so
|
|
1449
|
+
// the user sees the rect being composed before committing on pointerup.
|
|
1450
|
+
// Layers are now world-positioned (see createTilemap), so
|
|
1451
|
+
// `tileToWorldXY` returns world coords directly — no shift needed.
|
|
1452
|
+
const rect = getTilemapRect(this.game);
|
|
1453
|
+
if (rect) {
|
|
1454
|
+
const layer = this.findActiveTilemapLayer(rect.entityId, rect.layerId);
|
|
1455
|
+
if (layer) {
|
|
1456
|
+
const x0 = Math.min(rect.startX, rect.endX);
|
|
1457
|
+
const y0 = Math.min(rect.startY, rect.endY);
|
|
1458
|
+
const x1 = Math.max(rect.startX, rect.endX);
|
|
1459
|
+
const y1 = Math.max(rect.startY, rect.endY);
|
|
1460
|
+
const tl = layer.tileToWorldXY(x0, y0);
|
|
1461
|
+
const br = layer.tileToWorldXY(x1 + 1, y1 + 1);
|
|
1462
|
+
const w = br.x - tl.x;
|
|
1463
|
+
const h = br.y - tl.y;
|
|
1464
|
+
this.graphics.fillStyle(0xffaa00, 0.18);
|
|
1465
|
+
this.graphics.fillRect(tl.x, tl.y, w, h);
|
|
1466
|
+
this.graphics.lineStyle(2, 0xffaa00, 0.85);
|
|
1467
|
+
this.graphics.strokeRect(tl.x, tl.y, w, h);
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
// Slice 6 Phase B.6.1: tilemap resize handles + drag preview.
|
|
1471
|
+
// Handles render whenever paint mode is active on a tilemap entity
|
|
1472
|
+
// (the same condition that makes the tile palette appear in the
|
|
1473
|
+
// host Inspector). Ghost rect preview shows the new bounds during
|
|
1474
|
+
// an active resize drag.
|
|
1475
|
+
this.drawTilemapResizeOverlay();
|
|
1476
|
+
}
|
|
1477
|
+
/**
|
|
1478
|
+
* Render the 8 resize handles (when paint mode is active) and the
|
|
1479
|
+
* orange ghost-rect preview (when a resize drag is in progress).
|
|
1480
|
+
* Handles are zoom-invariant ~12px squares; preview rect snaps to
|
|
1481
|
+
* cell boundaries via the resize-drag's running preview state.
|
|
1482
|
+
*/
|
|
1483
|
+
drawTilemapResizeOverlay() {
|
|
1484
|
+
if (!isTilemapPaintMode(this.game))
|
|
1485
|
+
return;
|
|
1486
|
+
const info = this.tilemapHandlePositions();
|
|
1487
|
+
if (!info)
|
|
1488
|
+
return;
|
|
1489
|
+
const cam = this.findActiveEditorCamera();
|
|
1490
|
+
const zoom = cam?.zoom ?? 1;
|
|
1491
|
+
const handleSize = 14 / zoom; // 14px on screen regardless of zoom
|
|
1492
|
+
const half = handleSize / 2;
|
|
1493
|
+
// Tilemap bounds outline (cyan, dashed-ish via solid moderate alpha).
|
|
1494
|
+
// Helps the user see what they're about to resize.
|
|
1495
|
+
this.graphics.lineStyle(1, 0x4488ee, 0.5);
|
|
1496
|
+
this.graphics.strokeRect(info.center.x - info.pxW / 2, info.center.y - info.pxH / 2, info.pxW, info.pxH);
|
|
1497
|
+
// 8 handles — small blue squares with white outline for contrast
|
|
1498
|
+
// against any underlying tile colors.
|
|
1499
|
+
for (const h of info.handles) {
|
|
1500
|
+
this.graphics.fillStyle(0xffffff, 1);
|
|
1501
|
+
this.graphics.fillRect(h.x - half, h.y - half, handleSize, handleSize);
|
|
1502
|
+
this.graphics.fillStyle(0x4488ee, 1);
|
|
1503
|
+
this.graphics.fillRect(h.x - half + 1.5 / zoom, h.y - half + 1.5 / zoom, handleSize - 3 / zoom, handleSize - 3 / zoom);
|
|
1504
|
+
}
|
|
1505
|
+
// Active resize drag — orange ghost rect at preview bounds.
|
|
1506
|
+
const resize = getTilemapResize(this.game);
|
|
1507
|
+
if (resize) {
|
|
1508
|
+
const pxW = resize.previewWidth * resize.tileSize.width;
|
|
1509
|
+
const pxH = resize.previewHeight * resize.tileSize.height;
|
|
1510
|
+
const left = resize.previewCenter.x - pxW / 2;
|
|
1511
|
+
const top = resize.previewCenter.y - pxH / 2;
|
|
1512
|
+
this.graphics.fillStyle(0xffaa00, 0.12);
|
|
1513
|
+
this.graphics.fillRect(left, top, pxW, pxH);
|
|
1514
|
+
this.graphics.lineStyle(2, 0xffaa00, 0.85);
|
|
1515
|
+
this.graphics.strokeRect(left, top, pxW, pxH);
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
/**
|
|
1519
|
+
* Walks the world entity registry and draws each sprite's `hitbox` rect +
|
|
1520
|
+
* `depthAnchor` crosshair (when present on the entity's Asset). Sprites
|
|
1521
|
+
* without metadata get nothing drawn — chat-only Workflow A respect.
|
|
1522
|
+
*
|
|
1523
|
+
* Per-frame mode: reads the sprite's CURRENT frame index and looks up the
|
|
1524
|
+
* override (falls back to default) so the rendered rect tracks what the
|
|
1525
|
+
* SDK is actually applying to the body at runtime.
|
|
1526
|
+
*/
|
|
1527
|
+
drawHitboxDebug() {
|
|
1528
|
+
const registry = this.findEntityRegistry();
|
|
1529
|
+
if (!registry)
|
|
1530
|
+
return;
|
|
1531
|
+
const worldScene = this.findWorldSceneInstance();
|
|
1532
|
+
const manifest = worldScene ? getManifest(worldScene) : undefined;
|
|
1533
|
+
if (!manifest)
|
|
1534
|
+
return;
|
|
1535
|
+
const assetsById = new Map();
|
|
1536
|
+
for (const asset of manifest.assets ?? []) {
|
|
1537
|
+
assetsById.set(asset.id, asset);
|
|
1538
|
+
}
|
|
1539
|
+
for (const go of registry.all()) {
|
|
1540
|
+
// (1) Tilemap collision — painted-cell rects + per-tile collisionRects.
|
|
1541
|
+
// Walks every painted tile on every layer of every tilemap entity;
|
|
1542
|
+
// draws the cell-rect for tiles with `solid: true` AND each sub-tile
|
|
1543
|
+
// rect for tiles with `collisionRects`. This is the visualization of
|
|
1544
|
+
// what `setCollisionByProperty({ solid: true })` + `syncSubTileBodies`
|
|
1545
|
+
// armed at scene-load. Without this pass the debug overlay only shows
|
|
1546
|
+
// sprite hitboxes — tile collision is invisible even when configured,
|
|
1547
|
+
// so "Show hitboxes" looked broken for tilemap-only games.
|
|
1548
|
+
if (go.getData('entityKind') === 'tilemap') {
|
|
1549
|
+
const container = go;
|
|
1550
|
+
const layers = container.getData('tilemapLayers') ?? [];
|
|
1551
|
+
for (const layer of layers) {
|
|
1552
|
+
const tilesetId = layer.getData('tilemapTilesetId');
|
|
1553
|
+
if (!tilesetId)
|
|
1554
|
+
continue;
|
|
1555
|
+
const asset = assetsById.get(tilesetId);
|
|
1556
|
+
const tileMeta = asset?.tileset?.tiles ?? {};
|
|
1557
|
+
// Layer position in world coords (layers live in scene root with
|
|
1558
|
+
// their own x/y per `installTilemapLayerSync`).
|
|
1559
|
+
const layerX = layer.x ?? 0;
|
|
1560
|
+
const layerY = layer.y ?? 0;
|
|
1561
|
+
layer.forEachTile((tile) => {
|
|
1562
|
+
if (tile.index < 0)
|
|
1563
|
+
return; // unpainted cell
|
|
1564
|
+
const meta = tileMeta[tile.index];
|
|
1565
|
+
const cellX = layerX + tile.x * tile.width;
|
|
1566
|
+
const cellY = layerY + tile.y * tile.height;
|
|
1567
|
+
const subRects = meta?.collisionRects;
|
|
1568
|
+
if (subRects && subRects.length > 0) {
|
|
1569
|
+
// Sub-tile rects override cell-rect collision (per SDK 0.2.116).
|
|
1570
|
+
for (const r of subRects) {
|
|
1571
|
+
this.graphics.fillStyle(HITBOX_FILL_COLOR, HITBOX_FILL_ALPHA);
|
|
1572
|
+
this.graphics.fillRect(cellX + r.x, cellY + r.y, r.w, r.h);
|
|
1573
|
+
this.graphics.lineStyle(1, HITBOX_STROKE_COLOR, HITBOX_STROKE_ALPHA);
|
|
1574
|
+
this.graphics.strokeRect(cellX + r.x, cellY + r.y, r.w, r.h);
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
else if (meta?.solid === true) {
|
|
1578
|
+
// Full-cell collision (no sub-rects authored).
|
|
1579
|
+
this.graphics.fillStyle(HITBOX_FILL_COLOR, HITBOX_FILL_ALPHA);
|
|
1580
|
+
this.graphics.fillRect(cellX, cellY, tile.width, tile.height);
|
|
1581
|
+
this.graphics.lineStyle(1, HITBOX_STROKE_COLOR, HITBOX_STROKE_ALPHA);
|
|
1582
|
+
this.graphics.strokeRect(cellX, cellY, tile.width, tile.height);
|
|
1583
|
+
}
|
|
1584
|
+
});
|
|
1585
|
+
}
|
|
1586
|
+
continue; // tilemap GO — nothing else to draw
|
|
1587
|
+
}
|
|
1588
|
+
// (2) Sprite hitbox + depth anchor (slice 8). Reads asset metadata
|
|
1589
|
+
// and renders the rect in source-pixel coords mapped to the sprite's
|
|
1590
|
+
// actual rendered position (compensating for origin shift).
|
|
1591
|
+
const assetId = go.getData('entityAssetId');
|
|
1592
|
+
if (!assetId)
|
|
1593
|
+
continue; // primitives / code-rendered / group — skip
|
|
1594
|
+
const asset = assetsById.get(assetId);
|
|
1595
|
+
if (!asset)
|
|
1596
|
+
continue;
|
|
1597
|
+
const sprite = go;
|
|
1598
|
+
const frameW = sprite.frame?.width ?? sprite.width ?? 0;
|
|
1599
|
+
const frameH = sprite.frame?.height ?? sprite.height ?? 0;
|
|
1600
|
+
if (frameW === 0 || frameH === 0)
|
|
1601
|
+
continue;
|
|
1602
|
+
const originX = sprite.originX ?? 0.5;
|
|
1603
|
+
const originY = sprite.originY ?? 0.5;
|
|
1604
|
+
const frameLeft = sprite.x - frameW * originX;
|
|
1605
|
+
const frameTop = sprite.y - frameH * originY;
|
|
1606
|
+
if (asset.hitbox) {
|
|
1607
|
+
const shape = this.resolveCurrentHitboxShape(asset, sprite);
|
|
1608
|
+
if (shape) {
|
|
1609
|
+
this.graphics.fillStyle(HITBOX_FILL_COLOR, HITBOX_FILL_ALPHA);
|
|
1610
|
+
this.graphics.fillRect(frameLeft + shape.x, frameTop + shape.y, shape.w, shape.h);
|
|
1611
|
+
this.graphics.lineStyle(1, HITBOX_STROKE_COLOR, HITBOX_STROKE_ALPHA);
|
|
1612
|
+
this.graphics.strokeRect(frameLeft + shape.x, frameTop + shape.y, shape.w, shape.h);
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
if (asset.depthAnchor) {
|
|
1616
|
+
const ax = frameLeft + asset.depthAnchor.x;
|
|
1617
|
+
const ay = frameTop + asset.depthAnchor.y;
|
|
1618
|
+
this.graphics.lineStyle(1, ANCHOR_COLOR, ANCHOR_ALPHA);
|
|
1619
|
+
this.graphics.lineBetween(ax - ANCHOR_CROSS_LEN, ay, ax + ANCHOR_CROSS_LEN, ay);
|
|
1620
|
+
this.graphics.lineBetween(ax, ay - ANCHOR_CROSS_LEN, ax, ay + ANCHOR_CROSS_LEN);
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1624
|
+
/**
|
|
1625
|
+
* For per-frame hitboxes, pick the shape the SDK would currently be
|
|
1626
|
+
* applying — the override for the playing frame, falling back to default.
|
|
1627
|
+
* For single-shape hitboxes, just return the rect.
|
|
1628
|
+
*/
|
|
1629
|
+
resolveCurrentHitboxShape(asset, sprite) {
|
|
1630
|
+
const h = asset.hitbox;
|
|
1631
|
+
if (!h)
|
|
1632
|
+
return undefined;
|
|
1633
|
+
if (isPerFrameHitbox(h)) {
|
|
1634
|
+
const idx = sprite.anims?.currentFrame?.index;
|
|
1635
|
+
if (typeof idx === 'number' && h.frames[idx])
|
|
1636
|
+
return h.frames[idx];
|
|
1637
|
+
return h.default;
|
|
1638
|
+
}
|
|
1639
|
+
return h;
|
|
1640
|
+
}
|
|
1641
|
+
findWorldSceneInstance() {
|
|
1642
|
+
for (const scene of this.game.scene.getScenes(false)) {
|
|
1643
|
+
const key = scene.scene.key;
|
|
1644
|
+
if (key === EDITOR_OVERLAY_KEY)
|
|
1645
|
+
continue;
|
|
1646
|
+
if (key === UMICAT_HUD_SCENE_KEY)
|
|
1647
|
+
continue;
|
|
1648
|
+
if (key === 'BootScene' || key === 'Boot')
|
|
1649
|
+
continue;
|
|
1650
|
+
// First non-overlay non-HUD non-Boot scene is the world scene.
|
|
1651
|
+
return scene;
|
|
1652
|
+
}
|
|
1653
|
+
return undefined;
|
|
1654
|
+
}
|
|
1655
|
+
/**
|
|
1656
|
+
* Find the world scene's main camera so the overlay can mirror it.
|
|
1657
|
+
* In edit mode, the world scene is paused but its camera state is what
|
|
1658
|
+
* the overlay's pointer math should use.
|
|
1659
|
+
*/
|
|
1660
|
+
findWorldSceneCamera() {
|
|
1661
|
+
for (const scene of this.game.scene.getScenes(false)) {
|
|
1662
|
+
if (scene.scene.key === 'GameScene') {
|
|
1663
|
+
return scene.cameras.main;
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1666
|
+
return null;
|
|
1667
|
+
}
|
|
1668
|
+
/**
|
|
1669
|
+
* P1 infinite canvas — read the editor camera installed by
|
|
1670
|
+
* `installEditorCameras` in EditorBridge. Returns null on the boot race
|
|
1671
|
+
* when the overlay scene's `create` runs before the bridge attaches it
|
|
1672
|
+
* (resolved by the next `applyEditorPanZoom` mirror).
|
|
1673
|
+
*/
|
|
1674
|
+
findEditorCamera() {
|
|
1675
|
+
return this.game['__unboxyEditorCam'] ?? null;
|
|
1676
|
+
}
|
|
1677
|
+
/**
|
|
1678
|
+
* The camera the editor is currently viewing through. Prefers the
|
|
1679
|
+
* editor cam; falls back to the game's cameras.main if the editor cam
|
|
1680
|
+
* hasn't been installed yet (boot race). All pan/zoom math + selection
|
|
1681
|
+
* rects should use this, NOT cameras.main directly.
|
|
1682
|
+
*/
|
|
1683
|
+
findActiveEditorCamera() {
|
|
1684
|
+
return this.findEditorCamera() ?? this.findWorldSceneCamera();
|
|
1685
|
+
}
|
|
1686
|
+
/**
|
|
1687
|
+
* P1 infinite canvas — install native DOM listeners for wheel + space
|
|
1688
|
+
* tracking. Phaser's pointer events don't surface `wheel`, and we want
|
|
1689
|
+
* Space to be a global modifier (held while moving cursor), not tied to
|
|
1690
|
+
* a specific GameObject. Listeners are torn down on scene shutdown.
|
|
1691
|
+
*
|
|
1692
|
+
* World mode only: HUD has identity camera + anchor-positioned widgets,
|
|
1693
|
+
* panning/zooming it would rip widgets off their anchors.
|
|
1694
|
+
*/
|
|
1695
|
+
installPanZoomInput() {
|
|
1696
|
+
if (typeof document === 'undefined')
|
|
1697
|
+
return;
|
|
1698
|
+
const canvas = this.game.canvas;
|
|
1699
|
+
if (!canvas)
|
|
1700
|
+
return;
|
|
1701
|
+
// Wheel handler — FIGMA-STYLE (matches Mac trackpad muscle memory):
|
|
1702
|
+
// - ctrlKey OR metaKey → zoom. ctrlKey covers Mac trackpad pinch
|
|
1703
|
+
// (Chrome/Safari synthesize ctrlKey=true on
|
|
1704
|
+
// pinch) AND Windows/Linux Ctrl+wheel.
|
|
1705
|
+
// metaKey covers Mac Cmd+wheel (the actual
|
|
1706
|
+
// Mac mouse convention — Figma uses this).
|
|
1707
|
+
// - Otherwise → pan (Mac trackpad two-finger swipe sends
|
|
1708
|
+
// deltaX + deltaY here; mouse wheel without
|
|
1709
|
+
// modifier also pans vertically).
|
|
1710
|
+
//
|
|
1711
|
+
// Briefly switched to engine-style (plain wheel = zoom) in 0.2.84
|
|
1712
|
+
// but reverted in 0.2.85 — Mac trackpad users lost two-finger pan
|
|
1713
|
+
// and the gain wasn't worth it. 0.2.86 added metaKey to fix Mac
|
|
1714
|
+
// mouse Cmd+wheel zoom.
|
|
1715
|
+
this.wheelHandler = (e) => {
|
|
1716
|
+
if (!getEditorState(this.game).active)
|
|
1717
|
+
return;
|
|
1718
|
+
// Allowed in both world AND hud edit modes (2026-05-17). Wheel
|
|
1719
|
+
// mutates the editor cam; HUD widgets use a separate identity
|
|
1720
|
+
// camera so they stay anchored regardless.
|
|
1721
|
+
e.preventDefault(); // suppress browser page scroll
|
|
1722
|
+
const editorCam = this.findActiveEditorCamera();
|
|
1723
|
+
if (!editorCam)
|
|
1724
|
+
return;
|
|
1725
|
+
if (e.ctrlKey || e.metaKey) {
|
|
1726
|
+
// ZOOM — cursor-anchored. World coord under the cursor stays put
|
|
1727
|
+
// after zoom (standard map-app feel).
|
|
1728
|
+
const rect = canvas.getBoundingClientRect();
|
|
1729
|
+
const cssX = e.clientX - rect.left;
|
|
1730
|
+
const cssY = e.clientY - rect.top;
|
|
1731
|
+
const sx = this.game.scale.width / rect.width;
|
|
1732
|
+
const sy = this.game.scale.height / rect.height;
|
|
1733
|
+
const logicalX = cssX * sx;
|
|
1734
|
+
const logicalY = cssY * sy;
|
|
1735
|
+
const worldX = editorCam.scrollX + logicalX / editorCam.zoom;
|
|
1736
|
+
const worldY = editorCam.scrollY + logicalY / editorCam.zoom;
|
|
1737
|
+
// Scale the per-event zoom factor by deltaY magnitude. Mac
|
|
1738
|
+
// trackpad pinch emits ~20-30 wheel events for even a brief
|
|
1739
|
+
// gesture, so a fixed 5% per event compounded into "slight
|
|
1740
|
+
// pinch → 50% to 200% in a blink". deltaY-proportional matches
|
|
1741
|
+
// Figma/Photoshop: slow pinch = fine zoom, fast pinch = quick.
|
|
1742
|
+
// Sensitivity (0.005) tuned so deltaY≈10 (typical Mac pinch
|
|
1743
|
+
// event) ≈ 5% zoom step — same speed as before for moderate
|
|
1744
|
+
// gestures, but much finer for slow ones. Clamp deltaY so a
|
|
1745
|
+
// freak large event (e.g. fling) can't zoom 100x in one tick.
|
|
1746
|
+
const ZOOM_SENSITIVITY = 0.005;
|
|
1747
|
+
const clampedDelta = Math.max(-100, Math.min(100, e.deltaY));
|
|
1748
|
+
const factor = Math.exp(-clampedDelta * ZOOM_SENSITIVITY);
|
|
1749
|
+
// Clamp HERE so the cursor-anchor math uses the same zoom value
|
|
1750
|
+
// applyPanZoom will actually commit. Otherwise at the cap, the
|
|
1751
|
+
// zoom snaps back but the scroll was computed for the (smaller)
|
|
1752
|
+
// unclamped zoom — world drifts on every wheel event while the
|
|
1753
|
+
// zoom indicator stays pinned at 25%. ZOOM_MIN/MAX must mirror
|
|
1754
|
+
// EditorBridge.clampZoom — kept local to avoid a cross-file
|
|
1755
|
+
// import for two constants.
|
|
1756
|
+
const ZOOM_MIN = 0.25;
|
|
1757
|
+
const ZOOM_MAX = 4;
|
|
1758
|
+
const newZoom = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, editorCam.zoom * factor));
|
|
1759
|
+
// No-op when at the cap — nothing to update, prevents accumulated
|
|
1760
|
+
// floating-point drift from many same-zoom events. Approximate
|
|
1761
|
+
// equality because `editorCam.zoom` can have sub-ulp drift from
|
|
1762
|
+
// prior Phaser internal ops (setZoom → cam.dirty resets → etc.),
|
|
1763
|
+
// and strict `===` would miss it.
|
|
1764
|
+
if (Math.abs(newZoom - editorCam.zoom) < 1e-9)
|
|
1765
|
+
return;
|
|
1766
|
+
const newScrollX = worldX - logicalX / newZoom;
|
|
1767
|
+
const newScrollY = worldY - logicalY / newZoom;
|
|
1768
|
+
this.applyPanZoom({ scrollX: newScrollX, scrollY: newScrollY, zoom: newZoom });
|
|
1769
|
+
}
|
|
1770
|
+
else {
|
|
1771
|
+
// PAN — translate scroll delta by the current zoom so 1px of
|
|
1772
|
+
// trackpad swipe ≈ 1px of canvas pan regardless of zoom.
|
|
1773
|
+
// Dead zone: Mac trackpad pinch gestures emit a stray sub-pixel
|
|
1774
|
+
// pan wheel event alongside each ctrlKey=true zoom event
|
|
1775
|
+
// (gesture jitter, finger drift). Without filtering, those
|
|
1776
|
+
// accumulate into visible scroll drift during pinch-at-cap —
|
|
1777
|
+
// the residual horizontal drift the user saw after the
|
|
1778
|
+
// zoom-branch cap fix. Real two-finger pan swipes emit deltas
|
|
1779
|
+
// well over 1px per event, so a 1px floor doesn't impact them.
|
|
1780
|
+
if (Math.abs(e.deltaX) < 1 && Math.abs(e.deltaY) < 1)
|
|
1781
|
+
return;
|
|
1782
|
+
const rect = canvas.getBoundingClientRect();
|
|
1783
|
+
const scaleSx = rect.width > 0 ? this.game.scale.width / rect.width : 1;
|
|
1784
|
+
const scaleSy = rect.height > 0 ? this.game.scale.height / rect.height : 1;
|
|
1785
|
+
const deltaX = (e.deltaX * scaleSx) / editorCam.zoom;
|
|
1786
|
+
const deltaY = (e.deltaY * scaleSy) / editorCam.zoom;
|
|
1787
|
+
this.applyPanZoom({
|
|
1788
|
+
scrollX: editorCam.scrollX + deltaX,
|
|
1789
|
+
scrollY: editorCam.scrollY + deltaY,
|
|
1790
|
+
});
|
|
1791
|
+
}
|
|
1792
|
+
};
|
|
1793
|
+
canvas.addEventListener('wheel', this.wheelHandler, { passive: false });
|
|
1794
|
+
// Space-held tracking so left-drag becomes pan instead of selection.
|
|
1795
|
+
const onKeyDown = (e) => {
|
|
1796
|
+
if (e.code === 'Space' && !this.isTypingTarget(e.target)) {
|
|
1797
|
+
this.spaceHeld = true;
|
|
1798
|
+
// Prevent page scroll while space is held in canvas-focused state.
|
|
1799
|
+
e.preventDefault();
|
|
1800
|
+
}
|
|
1801
|
+
};
|
|
1802
|
+
const onKeyUp = (e) => {
|
|
1803
|
+
if (e.code === 'Space')
|
|
1804
|
+
this.spaceHeld = false;
|
|
1805
|
+
};
|
|
1806
|
+
document.addEventListener('keydown', onKeyDown, true);
|
|
1807
|
+
document.addEventListener('keyup', onKeyUp, true);
|
|
1808
|
+
this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => {
|
|
1809
|
+
if (this.wheelHandler)
|
|
1810
|
+
canvas.removeEventListener('wheel', this.wheelHandler);
|
|
1811
|
+
document.removeEventListener('keydown', onKeyDown, true);
|
|
1812
|
+
document.removeEventListener('keyup', onKeyUp, true);
|
|
1813
|
+
this.spaceHeld = false;
|
|
1814
|
+
this.panActive = false;
|
|
1815
|
+
});
|
|
1816
|
+
}
|
|
1817
|
+
/**
|
|
1818
|
+
* Be polite to text input — don't intercept Space when the user is
|
|
1819
|
+
* typing into a form control inside the iframe (shouldn't normally
|
|
1820
|
+
* happen, but be defensive).
|
|
1821
|
+
*/
|
|
1822
|
+
isTypingTarget(target) {
|
|
1823
|
+
const el = target;
|
|
1824
|
+
if (!el)
|
|
1825
|
+
return false;
|
|
1826
|
+
const tag = el.tagName;
|
|
1827
|
+
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT')
|
|
1828
|
+
return true;
|
|
1829
|
+
return el.isContentEditable === true;
|
|
1830
|
+
}
|
|
1831
|
+
/**
|
|
1832
|
+
* Mode-aware registry lookup. World mode skips the HUD scene; HUD mode
|
|
1833
|
+
* picks the HUD scene specifically.
|
|
1834
|
+
*/
|
|
1835
|
+
findEntityRegistry() {
|
|
1836
|
+
if (getEditorMode(this.game) === 'hud')
|
|
1837
|
+
return findHudRegistry(this.game);
|
|
1838
|
+
for (const scene of this.game.scene.getScenes(false)) {
|
|
1839
|
+
const key = scene.scene.key;
|
|
1840
|
+
if (key === UMICAT_HUD_SCENE_KEY)
|
|
1841
|
+
continue;
|
|
1842
|
+
const reg = getEntityRegistry(scene);
|
|
1843
|
+
if (reg)
|
|
1844
|
+
return reg;
|
|
1845
|
+
}
|
|
1846
|
+
return undefined;
|
|
1847
|
+
}
|
|
1848
|
+
}
|
|
1849
|
+
function computeBounds(go) {
|
|
1850
|
+
const positioned = go;
|
|
1851
|
+
// Live tracker query (0.2.97) — code-rendered entities expose a
|
|
1852
|
+
// getter that yields the latest draw extent. See
|
|
1853
|
+
// EditorBridge.readLiveTrackedBounds for the rationale.
|
|
1854
|
+
const getter = go.getData('__unboxyGraphicsBoundsGetter');
|
|
1855
|
+
if (typeof getter === 'function') {
|
|
1856
|
+
const live = getter();
|
|
1857
|
+
if (live) {
|
|
1858
|
+
return {
|
|
1859
|
+
x: positioned.x + live.x,
|
|
1860
|
+
y: positioned.y + live.y,
|
|
1861
|
+
width: live.width,
|
|
1862
|
+
height: live.height,
|
|
1863
|
+
};
|
|
1864
|
+
}
|
|
1865
|
+
}
|
|
1866
|
+
// Code-rendered entities stash hit dimensions on data because Phaser
|
|
1867
|
+
// Graphics has no intrinsic bounds. Use those when present.
|
|
1868
|
+
const hitW = go.getData('editorHitWidth');
|
|
1869
|
+
const hitH = go.getData('editorHitHeight');
|
|
1870
|
+
if (typeof hitW === 'number' && typeof hitH === 'number') {
|
|
1871
|
+
// editorHitOffsetX/Y (0.2.96) for off-center procedural draws.
|
|
1872
|
+
const offX = go.getData('editorHitOffsetX');
|
|
1873
|
+
const offY = go.getData('editorHitOffsetY');
|
|
1874
|
+
if (typeof offX === 'number' && typeof offY === 'number') {
|
|
1875
|
+
return {
|
|
1876
|
+
x: positioned.x + offX,
|
|
1877
|
+
y: positioned.y + offY,
|
|
1878
|
+
width: hitW,
|
|
1879
|
+
height: hitH,
|
|
1880
|
+
};
|
|
1881
|
+
}
|
|
1882
|
+
return {
|
|
1883
|
+
x: positioned.x - hitW / 2,
|
|
1884
|
+
y: positioned.y - hitH / 2,
|
|
1885
|
+
width: hitW,
|
|
1886
|
+
height: hitH,
|
|
1887
|
+
};
|
|
1888
|
+
}
|
|
1889
|
+
const withBounds = go;
|
|
1890
|
+
if (typeof withBounds.getBounds !== 'function')
|
|
1891
|
+
return null;
|
|
1892
|
+
const r = withBounds.getBounds();
|
|
1893
|
+
if (r.width === 0 && r.height === 0)
|
|
1894
|
+
return null;
|
|
1895
|
+
return { x: r.x, y: r.y, width: r.width, height: r.height };
|
|
1896
|
+
}
|