@umicat/phaser-sdk 1.0.2 → 1.0.4

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.
@@ -43,6 +43,16 @@ export function setupEditorBridge(game) {
43
43
  return;
44
44
  handleMessage(game, data);
45
45
  });
46
+ // Boot-time edit flag (2026-06-11). When the host remounts the iframe
47
+ // while edit mode is on (post-save / post-agent-turn rebuild), it appends
48
+ // `umicatEdit=1` to the iframe URL. Enter edit IMMEDIATELY at game
49
+ // construction so scenes pause before their first update. Without this
50
+ // the game visibly plays for a second or two after every rebuild: the
51
+ // host's `enter` postMessage can't arrive any earlier because main.ts
52
+ // awaits font fetches before createUmicatGame installs this listener.
53
+ if (typeof window !== 'undefined' && /[?&]umicatEdit=1/.test(window.location.search)) {
54
+ enterEdit(game);
55
+ }
46
56
  }
47
57
  function handleMessage(game, msg) {
48
58
  switch (msg.type) {
@@ -134,6 +144,15 @@ function enterEdit(game) {
134
144
  restoreCanvasAfterEditor(game);
135
145
  setEditorActive(game, true);
136
146
  pauseActiveNonEditor(game);
147
+ // Continuous pause enforcement (2026-06-11) — the bounded 3s re-attempt
148
+ // loop below misses scenes that finish booting later than 3s after
149
+ // enter (slow CDN fetch of scene assets, etc.), and even within the
150
+ // window a scene can run for up to 100ms before the next tick pauses
151
+ // it. A PRE_STEP hook pauses any newly-active non-editor scene BEFORE
152
+ // its first update, for as long as edit mode is on. pauseActiveNonEditor
153
+ // skips already-paused scenes, so the per-frame cost is a short loop
154
+ // over the scene list.
155
+ installPauseEnforcement(game);
137
156
  // P1 infinite canvas — expand the Phaser canvas to fill its container
138
157
  // (host's iframe). Without this, the editor surface stays locked to
139
158
  // the game's intrinsic aspect ratio (720×1280 portrait, etc.) with
@@ -199,6 +218,33 @@ function enterEdit(game) {
199
218
  };
200
219
  setTimeout(reattempt, 100);
201
220
  }
221
+ const PAUSE_ENFORCER_FLAG = '__umicatEditorPauseEnforcer';
222
+ /**
223
+ * While edit mode is active, pause any non-editor scene before each game
224
+ * step. Catches scenes that boot AFTER enterEdit (iframe rebuild path) no
225
+ * matter how late — the game never visibly plays under the editor.
226
+ * Self-disarms via the active check; uninstalled on exitEdit.
227
+ */
228
+ function installPauseEnforcement(game) {
229
+ const bag = game;
230
+ if (bag[PAUSE_ENFORCER_FLAG])
231
+ return;
232
+ const handler = () => {
233
+ if (!getEditorState(game).active)
234
+ return;
235
+ pauseActiveNonEditor(game);
236
+ };
237
+ game.events.on(Phaser.Core.Events.PRE_STEP, handler);
238
+ bag[PAUSE_ENFORCER_FLAG] = handler;
239
+ }
240
+ function uninstallPauseEnforcement(game) {
241
+ const bag = game;
242
+ const handler = bag[PAUSE_ENFORCER_FLAG];
243
+ if (!handler)
244
+ return;
245
+ game.events.off(Phaser.Core.Events.PRE_STEP, handler);
246
+ delete bag[PAUSE_ENFORCER_FLAG];
247
+ }
202
248
  function pauseActiveNonEditor(game) {
203
249
  // P1 infinite canvas (2026-05-17) — use `setActive(false)` instead of
204
250
  // `pause()` so scenes STOP updating (game logic frozen) but KEEP
@@ -250,6 +296,9 @@ function exitEdit(game) {
250
296
  if (!getEditorState(game).active)
251
297
  return;
252
298
  setEditorActive(game, false);
299
+ // Active is false now so the enforcer self-disables, but remove the
300
+ // hook too — no point paying the per-frame check during play.
301
+ uninstallPauseEnforcement(game);
253
302
  setSelection(game, null);
254
303
  // Allow next enter to re-post the snapshot (scene file may have changed).
255
304
  delete game[SNAPSHOT_POSTED_FLAG];
@@ -89,6 +89,8 @@ export declare class EditorOverlayScene extends Phaser.Scene {
89
89
  private panStartScreen;
90
90
  private panStartScroll;
91
91
  private wheelHandler?;
92
+ private snapCandidates;
93
+ private snapGuides;
92
94
  constructor();
93
95
  init(data: EditorOverlayInitData): void;
94
96
  create(): void;
@@ -268,6 +270,16 @@ export declare class EditorOverlayScene extends Phaser.Scene {
268
270
  * normal editTilemap dispatch (which mutates the live runtime).
269
271
  */
270
272
  private commitTilemapResize;
273
+ /** Candidate alignment lines: world-bounds edges/centers + every OTHER entity's. */
274
+ private buildSnapCandidates;
275
+ /**
276
+ * Snap a candidate position against the cached lines. Returns the
277
+ * adjusted position (or null when nothing is within threshold) and
278
+ * records the matched guide lines for update() to draw.
279
+ */
280
+ private applySnap;
281
+ /** Pink alignment guide lines across the visible viewport while snapped. */
282
+ private drawSnapGuides;
271
283
  /**
272
284
  * Selected rect entity's bounds + 8 handle positions in world coords.
273
285
  * Returns null when the selection isn't a rect, in HUD mode, or when the
@@ -72,6 +72,12 @@ export class EditorOverlayScene extends Phaser.Scene {
72
72
  this.spaceHeld = false;
73
73
  this.panStartScreen = null;
74
74
  this.panStartScroll = null;
75
+ // Snap-to-align (2026-06-11) — Figma-style magnetic alignment while
76
+ // dragging an entity. Candidates (world-bounds edges/centers + every
77
+ // other entity's edges/centers) are cached once at drag start; guides
78
+ // are the alignment lines currently snapped to, drawn in update().
79
+ this.snapCandidates = null;
80
+ this.snapGuides = { xs: [], ys: [] };
75
81
  this.handleShortcut = (e) => {
76
82
  // Don't swallow keys when the user is typing in a real form control —
77
83
  // shouldn't happen inside the iframe, but be defensive.
@@ -303,6 +309,9 @@ export class EditorOverlayScene extends Phaser.Scene {
303
309
  else {
304
310
  const target = hit;
305
311
  startDrag(this.game, entityId, { x: px, y: py }, { x: target.x, y: target.y });
312
+ // Cache snap candidates once per drag — other entities don't move
313
+ // while we drag this one, so a per-move registry walk would be waste.
314
+ this.buildSnapCandidates(entityId);
306
315
  }
307
316
  }
308
317
  handlePointerMove(pointer) {
@@ -377,8 +386,23 @@ export class EditorOverlayScene extends Phaser.Scene {
377
386
  go.__lastDragDy = dy;
378
387
  }
379
388
  else {
380
- go.x = drag.startEntity.x + dx;
381
- go.y = drag.startEntity.y + dy;
389
+ let nx = drag.startEntity.x + dx;
390
+ let ny = drag.startEntity.y + dy;
391
+ // Snap-to-align — Cmd/Ctrl held disables it (Figma convention:
392
+ // precision placement override).
393
+ const ev = pointer.event;
394
+ if (ev?.metaKey || ev?.ctrlKey) {
395
+ this.snapGuides = { xs: [], ys: [] };
396
+ }
397
+ else {
398
+ const snapped = this.applySnap(go, nx, ny);
399
+ if (snapped) {
400
+ nx = snapped.x;
401
+ ny = snapped.y;
402
+ }
403
+ }
404
+ go.x = nx;
405
+ go.y = ny;
382
406
  }
383
407
  }
384
408
  handlePointerUp(pointer) {
@@ -410,11 +434,22 @@ export class EditorOverlayScene extends Phaser.Scene {
410
434
  const drag = getDrag(this.game);
411
435
  if (!drag)
412
436
  return;
437
+ // Snap state is per-drag.
438
+ this.snapCandidates = null;
439
+ this.snapGuides = { xs: [], ys: [] };
413
440
  const { x: px, y: py } = this.pointerCoords(pointer);
414
441
  const dx = px - drag.startWorld.x;
415
442
  const dy = py - drag.startWorld.y;
416
443
  const before = drag.startEntity;
417
- const after = { x: drag.startEntity.x + dx, y: drag.startEntity.y + dy };
444
+ let after = { x: drag.startEntity.x + dx, y: drag.startEntity.y + dy };
445
+ // World mode: report the GameObject's ACTUAL final position, not the
446
+ // raw pointer delta — snapping may have adjusted it during the drag,
447
+ // and the raw-delta value would make the entity "un-snap" on save.
448
+ if (getEditorMode(this.game) !== 'hud') {
449
+ const go = this.findEntityRegistry()?.byId(drag.entityId);
450
+ if (go)
451
+ after = { x: go.x, y: go.y };
452
+ }
418
453
  // Reset the per-drag delta accumulator on the GO if HUD mode left one.
419
454
  if (getEditorMode(this.game) === 'hud') {
420
455
  const reg = this.findEntityRegistry();
@@ -1389,6 +1424,101 @@ export class EditorOverlayScene extends Phaser.Scene {
1389
1424
  window.parent.postMessage(message, '*');
1390
1425
  }
1391
1426
  }
1427
+ // --- Snap-to-align (2026-06-11) -------------------------------------------
1428
+ //
1429
+ // Figma-style magnetic alignment while dragging. The dragged entity's
1430
+ // left/center/right (and top/center/bottom) lines are tested against the
1431
+ // cached candidate lines; when one comes within ~8 screen px the position
1432
+ // snaps to it and a pink guide line renders across the viewport. X and Y
1433
+ // snap independently. Cmd/Ctrl held during the drag bypasses snapping.
1434
+ /** Candidate alignment lines: world-bounds edges/centers + every OTHER entity's. */
1435
+ buildSnapCandidates(draggedId) {
1436
+ if (getEditorMode(this.game) === 'hud') {
1437
+ this.snapCandidates = null;
1438
+ return;
1439
+ }
1440
+ const xs = [];
1441
+ const ys = [];
1442
+ if (this.worldBounds) {
1443
+ const wb = this.worldBounds;
1444
+ xs.push(wb.x, wb.x + wb.width / 2, wb.x + wb.width);
1445
+ ys.push(wb.y, wb.y + wb.height / 2, wb.y + wb.height);
1446
+ }
1447
+ const registry = this.findEntityRegistry();
1448
+ if (registry) {
1449
+ for (const go of registry.all()) {
1450
+ if (go.getData('entityId') === draggedId)
1451
+ continue;
1452
+ const b = computeBounds(go);
1453
+ if (!b || (b.width === 0 && b.height === 0))
1454
+ continue;
1455
+ xs.push(b.x, b.x + b.width / 2, b.x + b.width);
1456
+ ys.push(b.y, b.y + b.height / 2, b.y + b.height);
1457
+ }
1458
+ }
1459
+ this.snapCandidates = { xs, ys };
1460
+ this.snapGuides = { xs: [], ys: [] };
1461
+ }
1462
+ /**
1463
+ * Snap a candidate position against the cached lines. Returns the
1464
+ * adjusted position (or null when nothing is within threshold) and
1465
+ * records the matched guide lines for update() to draw.
1466
+ */
1467
+ applySnap(go, rawX, rawY) {
1468
+ this.snapGuides = { xs: [], ys: [] };
1469
+ const cands = this.snapCandidates;
1470
+ if (!cands)
1471
+ return null;
1472
+ const b = computeBounds(go);
1473
+ if (!b)
1474
+ return null;
1475
+ const pos = go;
1476
+ // Bounds offset relative to the GO's transform position is constant
1477
+ // during the drag — derive once from the current frame's bounds.
1478
+ const offL = b.x - pos.x;
1479
+ const offT = b.y - pos.y;
1480
+ const cam = this.findActiveEditorCamera();
1481
+ const zoom = cam?.zoom ?? 1;
1482
+ const threshold = 8 / zoom; // 8 screen px regardless of zoom
1483
+ const snapAxis = (raw, off, size, lines) => {
1484
+ const edges = [raw + off, raw + off + size / 2, raw + off + size];
1485
+ let best = null;
1486
+ for (const line of lines) {
1487
+ for (const e of edges) {
1488
+ const d = Math.abs(e - line);
1489
+ if (d <= threshold && (!best || d < best.d)) {
1490
+ best = { pos: raw + (line - e), guide: line, d };
1491
+ }
1492
+ }
1493
+ }
1494
+ return best ? { pos: best.pos, guide: best.guide } : null;
1495
+ };
1496
+ const sx = snapAxis(rawX, offL, b.width, cands.xs);
1497
+ const sy = snapAxis(rawY, offT, b.height, cands.ys);
1498
+ if (sx)
1499
+ this.snapGuides.xs.push(sx.guide);
1500
+ if (sy)
1501
+ this.snapGuides.ys.push(sy.guide);
1502
+ if (!sx && !sy)
1503
+ return null;
1504
+ return { x: sx?.pos ?? rawX, y: sy?.pos ?? rawY };
1505
+ }
1506
+ /** Pink alignment guide lines across the visible viewport while snapped. */
1507
+ drawSnapGuides() {
1508
+ if (this.snapGuides.xs.length === 0 && this.snapGuides.ys.length === 0)
1509
+ return;
1510
+ const cam = this.findActiveEditorCamera();
1511
+ if (!cam)
1512
+ return;
1513
+ const view = cam.worldView;
1514
+ this.graphics.lineStyle(1 / cam.zoom, 0xff2d78, 0.9);
1515
+ for (const x of this.snapGuides.xs) {
1516
+ this.graphics.lineBetween(x, view.y, x, view.y + view.height);
1517
+ }
1518
+ for (const y of this.snapGuides.ys) {
1519
+ this.graphics.lineBetween(view.x, y, view.x + view.width, y);
1520
+ }
1521
+ }
1392
1522
  // --- Rect-entity resize-handle drag (2026-06-10) --------------------------
1393
1523
  //
1394
1524
  // Same 8-handle affordance as the tilemap resize above, generalized to
@@ -1707,6 +1837,8 @@ export class EditorOverlayScene extends Phaser.Scene {
1707
1837
  // Rect-entity resize handles — render whenever a rect entity is
1708
1838
  // selected (2026-06-10).
1709
1839
  this.drawEntityResizeOverlay();
1840
+ // Snap-to-align guide lines — render while a drag is snapped (2026-06-11).
1841
+ this.drawSnapGuides();
1710
1842
  }
1711
1843
  /**
1712
1844
  * Render the 8 resize handles (when paint mode is active) and the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@umicat/phaser-sdk",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "description": "Umicat Phaser 3 SDK — game infrastructure for the Umicat platform",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",