@umicat/phaser-sdk 1.0.0 → 1.0.2

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 CHANGED
@@ -626,10 +626,11 @@ class GameScene extends Phaser.Scene {
626
626
  // ...wire physics + input on `this.player`...
627
627
  }
628
628
  update() {
629
- // Safe: loadWorldScene suspends update() during its await, so any
630
- // class field assigned after `await loadWorldScene(...)` is defined
631
- // by the first time update() ticks. No `if (!this.player) return;`
632
- // guard needed.
629
+ // Safe: an async create() suspends update() until it resolves. The SDK
630
+ // gates this in createUmicatGame (since 1.0.1) for EVERY registered
631
+ // scene, so any class field assigned after ANY `await` in create()
632
+ // loadWorldScene, saves.get(), umicatReady, a fetch — is defined by the
633
+ // first time update() ticks. No `if (!this.player) return;` guard needed.
633
634
  this.player.setVelocityX(0);
634
635
  }
635
636
  }
@@ -6,11 +6,70 @@ import { setupEditorModeListener } from '../scene/EditorMode.js';
6
6
  import { ORIENTATION_DIMENSIONS } from '../orientation.js';
7
7
  import { setRenderScriptRegistry, } from '../scene/renderScripts.js';
8
8
  import { UmicatHudScene } from '../scene/HudRuntime.js';
9
+ /**
10
+ * Make a Promise-returning `create()` safe against Phaser's update loop.
11
+ *
12
+ * Phaser does NOT await an async `create()`. The moment `create()` suspends
13
+ * at its first `await`, Phaser flips the scene to RUNNING and starts calling
14
+ * `update()` every frame — so any object the game builds *after* that await
15
+ * (a HUD text, the player sprite) is `undefined` for those frames. An
16
+ * `update()` that reads it throws on every frame and the game appears frozen
17
+ * on the loading screen. This is a very easy trap to fall into: the docs
18
+ * pattern `const hi = await saves.get('highScore')` at the top of `create()`
19
+ * is enough to trigger it.
20
+ *
21
+ * We give async `create()` the same guarantee `preload()` already has —
22
+ * "update() does not run until setup is finished" — by wrapping each scene so
23
+ * `update()` is a no-op until the `create()` promise settles. Synchronous
24
+ * `create()` is untouched (it never returns a thenable, so the gate is never
25
+ * armed). Re-runs cleanly across `scene.restart()` since the flag lives on
26
+ * the scene instance and is re-armed each create().
27
+ */
28
+ function guardAsyncCreate(SceneClass) {
29
+ const proto = SceneClass.prototype;
30
+ // Patch the prototype at most once, even under HMR / repeated factory calls.
31
+ if (proto.__umicatGuarded)
32
+ return;
33
+ const origCreate = proto.create;
34
+ if (typeof origCreate !== 'function')
35
+ return; // no create() → nothing to gate
36
+ proto.__umicatGuarded = true;
37
+ const PENDING = '__umicatCreatePending';
38
+ proto.create = function (...args) {
39
+ const ret = origCreate.apply(this, args);
40
+ if (ret && typeof ret.then === 'function') {
41
+ this[PENDING] = true;
42
+ Promise.resolve(ret)
43
+ .catch((e) => {
44
+ // Surface the failure instead of hanging update() forever.
45
+ // eslint-disable-next-line no-console
46
+ console.error('[umicat] async create() rejected:', e);
47
+ })
48
+ .finally(() => {
49
+ this[PENDING] = false;
50
+ });
51
+ }
52
+ return ret;
53
+ };
54
+ const origUpdate = proto.update;
55
+ if (typeof origUpdate === 'function') {
56
+ proto.update = function (...args) {
57
+ if (this[PENDING])
58
+ return undefined; // setup still running — skip this frame
59
+ return origUpdate.apply(this, args);
60
+ };
61
+ }
62
+ }
9
63
  /**
10
64
  * Create an Umicat-enhanced Phaser game instance.
11
65
  * Includes built-in integrations: screenshot capture, preserveDrawingBuffer, etc.
12
66
  */
13
67
  export function createUmicatGame(options) {
68
+ // Harden every game scene against the async-create()/update() race before
69
+ // Phaser instantiates them (see guardAsyncCreate). The auto-registered
70
+ // UmicatHudScene is ours and uses a sync create(), so it's left alone.
71
+ for (const SceneClass of options.scenes)
72
+ guardAsyncCreate(SceneClass);
14
73
  const { width, height } = 'orientation' in options && options.orientation
15
74
  ? ORIENTATION_DIMENSIONS[options.orientation]
16
75
  : { width: options.width, height: options.height };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,10 @@
1
+ import Phaser from 'phaser';
2
+ // phaser3-rex-plugins (e.g. the NinePatch plugin) were written for Phaser 3's
3
+ // UMD build, which set a global `window.Phaser`. Their module bodies reference a
4
+ // bare `Phaser` at eval time (e.g. `Phaser.Utils.Objects.GetValue`). Phaser 4 is
5
+ // pure ESM and does NOT expose a global, so those plugins throw
6
+ // "Phaser is not defined". Expose Phaser on globalThis here.
7
+ //
8
+ // IMPORTANT: this module must be imported BEFORE any `phaser3-rex-plugins/*`
9
+ // import so the global exists before the plugin's top-level code evaluates.
10
+ globalThis.Phaser = Phaser;
@@ -2,6 +2,7 @@ import Phaser from 'phaser';
2
2
  import { AssetRecord } from '../scene/types.js';
3
3
  import { TilemapEditOp } from '../protocol.js';
4
4
  export declare function setupEditorBridge(game: Phaser.Game): void;
5
+ export declare function postSelectionRect(game: Phaser.Game): void;
5
6
  /**
6
7
  * FB.9a — return ALL entities at the given world coords, sorted by depth
7
8
  * DESC (topmost first). Used by EditorOverlayScene's Alt+click handler
@@ -407,7 +407,7 @@ function postRulesSnapshot(game) {
407
407
  // inside one RAF tick — Inspector spam (per-keystroke transform edits)
408
408
  // would otherwise spam the postMessage channel.
409
409
  const RECT_RAF_FLAG = '__unboxyEditorSelectionRectRafScheduled';
410
- function postSelectionRect(game) {
410
+ export function postSelectionRect(game) {
411
411
  const bag = game;
412
412
  if (bag[RECT_RAF_FLAG])
413
413
  return;
@@ -225,6 +225,11 @@ export declare class EditorOverlayScene extends Phaser.Scene {
225
225
  * stay constant size on screen regardless of how zoomed in/out.
226
226
  */
227
227
  private hitTestTilemapResizeHandle;
228
+ /**
229
+ * Shared handle hit-test body — used by both the tilemap and the
230
+ * rect-entity resize affordances (same 8-handle geometry).
231
+ */
232
+ private hitTestResizeHandlesAt;
228
233
  /**
229
234
  * Set canvas cursor to a resize-arrow when hovering a handle, or restore
230
235
  * default. Direction matches the handle's axis (Figma / Sketch / browser
@@ -263,6 +268,36 @@ export declare class EditorOverlayScene extends Phaser.Scene {
263
268
  * normal editTilemap dispatch (which mutates the live runtime).
264
269
  */
265
270
  private commitTilemapResize;
271
+ /**
272
+ * Selected rect entity's bounds + 8 handle positions in world coords.
273
+ * Returns null when the selection isn't a rect, in HUD mode, or when the
274
+ * rect is rotated (axis-aligned handle math would shear a rotated rect —
275
+ * v1 simply hides the handles there).
276
+ */
277
+ private entityResizeHandlePositions;
278
+ private hitTestEntityResizeHandle;
279
+ private beginEntityResizeDrag;
280
+ /**
281
+ * Update the in-flight rect resize from the cursor position. Edges the
282
+ * handle owns follow the cursor (snapped to whole pixels); opposite edges
283
+ * stay anchored. Applies the result to the live GameObject immediately.
284
+ */
285
+ private updateEntityResizeDrag;
286
+ /** Mutate the live GameObject to the preview size + center (display px). */
287
+ private applyEntityResizePreview;
288
+ /**
289
+ * Commit the rect resize — the GO is already at its final size (applied
290
+ * live per pointermove); post `entityResized` with BASE dims (entity-
291
+ * record units) so the host records the modify-entity command. Undo
292
+ * replays the `before` patch through the normal applyEdit path.
293
+ */
294
+ private commitEntityResize;
295
+ /**
296
+ * Render the 8 resize handles around the selected rect entity. No bounds
297
+ * outline (the blue selection rect already draws it) and no ghost rect
298
+ * (the live GO resizes in place during the drag).
299
+ */
300
+ private drawEntityResizeOverlay;
266
301
  update(): void;
267
302
  /**
268
303
  * Render the 8 resize handles (when paint mode is active) and the
@@ -3,8 +3,8 @@ import { getEntityRegistry } from '../scene/EntityRegistry.js';
3
3
  import { findHudEntity, findHudRegistry, UMICAT_HUD_SCENE_KEY, } from '../scene/HudRuntime.js';
4
4
  import { isPerFrameHitbox } from '../scene/types.js';
5
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';
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, getEntityResize, beginEntityResize, updateEntityResize, endEntityResize, } from './EditorState.js';
7
+ import { applyTilemapOp, findTilemapLayerById, handleEditTilemap, postSelectionRect } from './EditorBridge.js';
8
8
  import { applyAutotile, findTerrain, getAutotileKind } from '../scene/autotile.js';
9
9
  /**
10
10
  * Editor overlay — slice 2.
@@ -258,6 +258,16 @@ export class EditorOverlayScene extends Phaser.Scene {
258
258
  // selection so the user can click out of paint mode by clicking
259
259
  // another entity / empty canvas.
260
260
  }
261
+ // Rect-entity resize handles (2026-06-10) — selected rect entities get
262
+ // the same 8-handle resize affordance as tilemaps. Checked BEFORE the
263
+ // entity hit-test so grabbing an edge/corner resizes instead of
264
+ // re-selecting + starting a move-drag.
265
+ const entityHandle = this.hitTestEntityResizeHandle(px, py);
266
+ if (entityHandle) {
267
+ this.beginEntityResizeDrag(entityHandle);
268
+ event?.preventDefault?.();
269
+ return;
270
+ }
261
271
  const hit = this.hitTest(px, py);
262
272
  const modifiers = {
263
273
  shift: !!event?.shiftKey,
@@ -321,6 +331,13 @@ export class EditorOverlayScene extends Phaser.Scene {
321
331
  this.updateTilemapResizeDrag(px, py);
322
332
  return;
323
333
  }
334
+ // Rect-entity resize drag in flight — live-applies setSize + center to
335
+ // the GameObject per move (no ghost; Rectangle.setSize is cheap).
336
+ if (getEntityResize(this.game)) {
337
+ const { x: px, y: py } = this.pointerCoords(pointer);
338
+ this.updateEntityResizeDrag(px, py);
339
+ return;
340
+ }
324
341
  // Slice 6 Phase B: continue an in-flight tilemap stroke (brush/eraser
325
342
  // drag) OR rect drag. handleTilemapPaintMove already branches on
326
343
  // which one is active internally — we just need to route to it
@@ -377,6 +394,12 @@ export class EditorOverlayScene extends Phaser.Scene {
377
394
  this.commitTilemapResize();
378
395
  return;
379
396
  }
397
+ // Rect-entity resize drag — GO is already at the final size (live-applied
398
+ // per move); post `entityResized` so the host records one undo command.
399
+ if (getEntityResize(this.game)) {
400
+ this.commitEntityResize();
401
+ return;
402
+ }
380
403
  // Slice 6 Phase B: end an in-flight tilemap stroke (brush/eraser) OR
381
404
  // rect drag. Both compose a single TilemapEditOp + post `tilemapEdited`
382
405
  // so undo reverts the whole stroke/rect in one step.
@@ -1151,6 +1174,13 @@ export class EditorOverlayScene extends Phaser.Scene {
1151
1174
  const info = this.tilemapHandlePositions();
1152
1175
  if (!info)
1153
1176
  return null;
1177
+ return this.hitTestResizeHandlesAt(worldX, worldY, info);
1178
+ }
1179
+ /**
1180
+ * Shared handle hit-test body — used by both the tilemap and the
1181
+ * rect-entity resize affordances (same 8-handle geometry).
1182
+ */
1183
+ hitTestResizeHandlesAt(worldX, worldY, info) {
1154
1184
  const cam = this.findActiveEditorCamera();
1155
1185
  const zoom = cam?.zoom ?? 1;
1156
1186
  const halfHandleWorld = 7 / zoom; // 14px square = 7px half-side
@@ -1202,13 +1232,22 @@ export class EditorOverlayScene extends Phaser.Scene {
1202
1232
  return;
1203
1233
  let handle = null;
1204
1234
  const inDrag = getTilemapResize(this.game);
1235
+ const inEntityDrag = getEntityResize(this.game);
1205
1236
  if (inDrag) {
1206
1237
  handle = inDrag.handle;
1207
1238
  }
1239
+ else if (inEntityDrag) {
1240
+ handle = inEntityDrag.handle;
1241
+ }
1208
1242
  else if (isTilemapPaintMode(this.game)) {
1209
1243
  const { x, y } = this.pointerCoords(pointer);
1210
1244
  handle = this.hitTestTilemapResizeHandle(x, y);
1211
1245
  }
1246
+ else {
1247
+ // Rect-entity handles — hover feedback whenever a rect is selected.
1248
+ const { x, y } = this.pointerCoords(pointer);
1249
+ handle = this.hitTestEntityResizeHandle(x, y);
1250
+ }
1212
1251
  if (!handle) {
1213
1252
  // Only reset when WE set it earlier; don't clobber pan-grab or other
1214
1253
  // cursor states future features might add.
@@ -1350,6 +1389,198 @@ export class EditorOverlayScene extends Phaser.Scene {
1350
1389
  window.parent.postMessage(message, '*');
1351
1390
  }
1352
1391
  }
1392
+ // --- Rect-entity resize-handle drag (2026-06-10) --------------------------
1393
+ //
1394
+ // Same 8-handle affordance as the tilemap resize above, generalized to
1395
+ // rect entities: handles render whenever a rect entity is selected (no
1396
+ // paint-mode gate — rects have no paint mode), sizes are pixel-based
1397
+ // (no cell snapping), and the live GameObject is mutated during the drag
1398
+ // because Rectangle.setSize is cheap (tilemap resize rebuilds layers, so
1399
+ // it previews with a ghost rect instead). Commit posts `entityResized`
1400
+ // so the host records ONE modify-entity command — undo/redo + Cmd+S
1401
+ // persistence ride the existing flat-patch path (width/height at the
1402
+ // patch root, center shift in transform.x/y).
1403
+ /**
1404
+ * Selected rect entity's bounds + 8 handle positions in world coords.
1405
+ * Returns null when the selection isn't a rect, in HUD mode, or when the
1406
+ * rect is rotated (axis-aligned handle math would shear a rotated rect —
1407
+ * v1 simply hides the handles there).
1408
+ */
1409
+ entityResizeHandlePositions() {
1410
+ if (getEditorMode(this.game) === 'hud')
1411
+ return null;
1412
+ const selectedId = getSelection(this.game);
1413
+ if (!selectedId)
1414
+ return null;
1415
+ const registry = this.findEntityRegistry();
1416
+ if (!registry)
1417
+ return null;
1418
+ const go = registry.byId(selectedId);
1419
+ if (!go || go.getData('entityKind') !== 'rect')
1420
+ return null;
1421
+ const rect = go;
1422
+ if (rect.rotation)
1423
+ return null;
1424
+ const pxW = rect.displayWidth ?? 0;
1425
+ const pxH = rect.displayHeight ?? 0;
1426
+ if (pxW <= 0 || pxH <= 0)
1427
+ return null;
1428
+ const cx = rect.x;
1429
+ const cy = rect.y;
1430
+ const left = cx - pxW / 2;
1431
+ const right = cx + pxW / 2;
1432
+ const top = cy - pxH / 2;
1433
+ const bottom = cy + pxH / 2;
1434
+ return {
1435
+ entityId: selectedId,
1436
+ center: { x: cx, y: cy },
1437
+ pxW,
1438
+ pxH,
1439
+ handles: [
1440
+ { id: 'nw', x: left, y: top },
1441
+ { id: 'n', x: cx, y: top },
1442
+ { id: 'ne', x: right, y: top },
1443
+ { id: 'e', x: right, y: cy },
1444
+ { id: 'se', x: right, y: bottom },
1445
+ { id: 's', x: cx, y: bottom },
1446
+ { id: 'sw', x: left, y: bottom },
1447
+ { id: 'w', x: left, y: cy },
1448
+ ],
1449
+ };
1450
+ }
1451
+ hitTestEntityResizeHandle(worldX, worldY) {
1452
+ const info = this.entityResizeHandlePositions();
1453
+ if (!info)
1454
+ return null;
1455
+ return this.hitTestResizeHandlesAt(worldX, worldY, info);
1456
+ }
1457
+ beginEntityResizeDrag(handle) {
1458
+ const info = this.entityResizeHandlePositions();
1459
+ if (!info)
1460
+ return;
1461
+ beginEntityResize(this.game, {
1462
+ entityId: info.entityId,
1463
+ handle,
1464
+ startSize: { width: info.pxW, height: info.pxH },
1465
+ startCenter: { x: info.center.x, y: info.center.y },
1466
+ previewWidth: info.pxW,
1467
+ previewHeight: info.pxH,
1468
+ previewCenter: { x: info.center.x, y: info.center.y },
1469
+ });
1470
+ }
1471
+ /**
1472
+ * Update the in-flight rect resize from the cursor position. Edges the
1473
+ * handle owns follow the cursor (snapped to whole pixels); opposite edges
1474
+ * stay anchored. Applies the result to the live GameObject immediately.
1475
+ */
1476
+ updateEntityResizeDrag(worldX, worldY) {
1477
+ const resize = getEntityResize(this.game);
1478
+ if (!resize)
1479
+ return;
1480
+ const MIN_SIZE = 2; // px — keeps the rect grabbable
1481
+ const { handle, startSize, startCenter } = resize;
1482
+ let newLeft = startCenter.x - startSize.width / 2;
1483
+ let newRight = startCenter.x + startSize.width / 2;
1484
+ let newTop = startCenter.y - startSize.height / 2;
1485
+ let newBottom = startCenter.y + startSize.height / 2;
1486
+ const movesLeft = handle === 'nw' || handle === 'w' || handle === 'sw';
1487
+ const movesRight = handle === 'ne' || handle === 'e' || handle === 'se';
1488
+ const movesTop = handle === 'nw' || handle === 'n' || handle === 'ne';
1489
+ const movesBottom = handle === 'sw' || handle === 's' || handle === 'se';
1490
+ if (movesLeft)
1491
+ newLeft = Math.min(Math.round(worldX), newRight - MIN_SIZE);
1492
+ if (movesRight)
1493
+ newRight = Math.max(Math.round(worldX), newLeft + MIN_SIZE);
1494
+ if (movesTop)
1495
+ newTop = Math.min(Math.round(worldY), newBottom - MIN_SIZE);
1496
+ if (movesBottom)
1497
+ newBottom = Math.max(Math.round(worldY), newTop + MIN_SIZE);
1498
+ const previewWidth = newRight - newLeft;
1499
+ const previewHeight = newBottom - newTop;
1500
+ const previewCenter = {
1501
+ x: (newLeft + newRight) / 2,
1502
+ y: (newTop + newBottom) / 2,
1503
+ };
1504
+ updateEntityResize(this.game, { previewWidth, previewHeight, previewCenter });
1505
+ this.applyEntityResizePreview(resize.entityId, previewWidth, previewHeight, previewCenter);
1506
+ }
1507
+ /** Mutate the live GameObject to the preview size + center (display px). */
1508
+ applyEntityResizePreview(entityId, displayW, displayH, center) {
1509
+ const registry = this.findEntityRegistry();
1510
+ const go = registry?.byId(entityId);
1511
+ if (!go)
1512
+ return;
1513
+ // setSize takes BASE dims; divide the display size by the transform
1514
+ // scale (palette rects spawn at scale 1, but be correct regardless).
1515
+ const sx = go.scaleX || 1;
1516
+ const sy = go.scaleY || 1;
1517
+ go.setSize?.(displayW / sx, displayH / sy);
1518
+ go.x = center.x;
1519
+ go.y = center.y;
1520
+ }
1521
+ /**
1522
+ * Commit the rect resize — the GO is already at its final size (applied
1523
+ * live per pointermove); post `entityResized` with BASE dims (entity-
1524
+ * record units) so the host records the modify-entity command. Undo
1525
+ * replays the `before` patch through the normal applyEdit path.
1526
+ */
1527
+ commitEntityResize() {
1528
+ const resize = endEntityResize(this.game);
1529
+ if (!resize)
1530
+ return;
1531
+ const { entityId, startSize, startCenter, previewWidth, previewHeight, previewCenter } = resize;
1532
+ if (previewWidth === startSize.width &&
1533
+ previewHeight === startSize.height &&
1534
+ previewCenter.x === startCenter.x &&
1535
+ previewCenter.y === startCenter.y) {
1536
+ return;
1537
+ }
1538
+ const registry = this.findEntityRegistry();
1539
+ const go = registry?.byId(entityId);
1540
+ const sx = go?.scaleX || 1;
1541
+ const sy = go?.scaleY || 1;
1542
+ const message = {
1543
+ type: 'umicat:editor:entityResized',
1544
+ entityId,
1545
+ before: {
1546
+ x: startCenter.x,
1547
+ y: startCenter.y,
1548
+ width: startSize.width / sx,
1549
+ height: startSize.height / sy,
1550
+ },
1551
+ after: {
1552
+ x: previewCenter.x,
1553
+ y: previewCenter.y,
1554
+ width: previewWidth / sx,
1555
+ height: previewHeight / sy,
1556
+ },
1557
+ };
1558
+ if (typeof window !== 'undefined' && window.parent) {
1559
+ window.parent.postMessage(message, '*');
1560
+ }
1561
+ // The selection rect changed size — refresh the host's anchor rect.
1562
+ postSelectionRect(this.game);
1563
+ }
1564
+ /**
1565
+ * Render the 8 resize handles around the selected rect entity. No bounds
1566
+ * outline (the blue selection rect already draws it) and no ghost rect
1567
+ * (the live GO resizes in place during the drag).
1568
+ */
1569
+ drawEntityResizeOverlay() {
1570
+ const info = this.entityResizeHandlePositions();
1571
+ if (!info)
1572
+ return;
1573
+ const cam = this.findActiveEditorCamera();
1574
+ const zoom = cam?.zoom ?? 1;
1575
+ const handleSize = 14 / zoom; // 14px on screen regardless of zoom
1576
+ const half = handleSize / 2;
1577
+ for (const h of info.handles) {
1578
+ this.graphics.fillStyle(0xffffff, 1);
1579
+ this.graphics.fillRect(h.x - half, h.y - half, handleSize, handleSize);
1580
+ this.graphics.fillStyle(SELECTION_COLOR, 1);
1581
+ this.graphics.fillRect(h.x - half + 1.5 / zoom, h.y - half + 1.5 / zoom, handleSize - 3 / zoom, handleSize - 3 / zoom);
1582
+ }
1583
+ }
1353
1584
  update() {
1354
1585
  this.graphics.clear();
1355
1586
  // Slice 6 Phase B (fix): sync tilemap layer world positions to their
@@ -1473,6 +1704,9 @@ export class EditorOverlayScene extends Phaser.Scene {
1473
1704
  // host Inspector). Ghost rect preview shows the new bounds during
1474
1705
  // an active resize drag.
1475
1706
  this.drawTilemapResizeOverlay();
1707
+ // Rect-entity resize handles — render whenever a rect entity is
1708
+ // selected (2026-06-10).
1709
+ this.drawEntityResizeOverlay();
1476
1710
  }
1477
1711
  /**
1478
1712
  * Render the 8 resize handles (when paint mode is active) and the
@@ -177,6 +177,32 @@ interface EditorStateShape {
177
177
  y: number;
178
178
  };
179
179
  } | null;
180
+ /**
181
+ * Rect-entity resize-handle drag in flight (2026-06-10). Same handle
182
+ * mechanics as tilemapResize but pixel-based (no cell snapping) and the
183
+ * live GameObject is mutated during the drag — Rectangle.setSize is cheap,
184
+ * unlike tilemap layer rebuilds, so the user sees the real rect resize
185
+ * rather than a ghost preview. All sizes are DISPLAY px (scale applied);
186
+ * the commit converts back to base dims before posting `entityResized`.
187
+ */
188
+ entityResize: {
189
+ entityId: string;
190
+ handle: TilemapResizeHandle;
191
+ startSize: {
192
+ width: number;
193
+ height: number;
194
+ };
195
+ startCenter: {
196
+ x: number;
197
+ y: number;
198
+ };
199
+ previewWidth: number;
200
+ previewHeight: number;
201
+ previewCenter: {
202
+ x: number;
203
+ y: number;
204
+ };
205
+ } | null;
180
206
  }
181
207
  /**
182
208
  * Tilemap resize handle identity. 4 corners + 4 edge midpoints. Each
@@ -248,4 +274,8 @@ export declare function getTilemapResize(game: AnyObject): EditorStateShape['til
248
274
  export declare function beginTilemapResize(game: AnyObject, resize: NonNullable<EditorStateShape['tilemapResize']>): void;
249
275
  export declare function updateTilemapResize(game: AnyObject, patch: Partial<Pick<NonNullable<EditorStateShape['tilemapResize']>, 'previewWidth' | 'previewHeight' | 'previewCenter'>>): void;
250
276
  export declare function endTilemapResize(game: AnyObject): EditorStateShape['tilemapResize'];
277
+ export declare function getEntityResize(game: AnyObject): EditorStateShape['entityResize'];
278
+ export declare function beginEntityResize(game: AnyObject, resize: NonNullable<EditorStateShape['entityResize']>): void;
279
+ export declare function updateEntityResize(game: AnyObject, patch: Partial<Pick<NonNullable<EditorStateShape['entityResize']>, 'previewWidth' | 'previewHeight' | 'previewCenter'>>): void;
280
+ export declare function endEntityResize(game: AnyObject): EditorStateShape['entityResize'];
251
281
  export {};
@@ -31,6 +31,7 @@ export function getEditorState(game) {
31
31
  tilemapStroke: null,
32
32
  tilemapRect: null,
33
33
  tilemapResize: null,
34
+ entityResize: null,
34
35
  };
35
36
  b[KEY] = fresh;
36
37
  return fresh;
@@ -195,3 +196,21 @@ export function endTilemapResize(game) {
195
196
  getEditorState(game).tilemapResize = null;
196
197
  return r;
197
198
  }
199
+ // --- Rect-entity resize-handle drag (2026-06-10) --------------------------
200
+ export function getEntityResize(game) {
201
+ return getEditorState(game).entityResize;
202
+ }
203
+ export function beginEntityResize(game, resize) {
204
+ getEditorState(game).entityResize = resize;
205
+ }
206
+ export function updateEntityResize(game, patch) {
207
+ const r = getEditorState(game).entityResize;
208
+ if (!r)
209
+ return;
210
+ Object.assign(r, patch);
211
+ }
212
+ export function endEntityResize(game) {
213
+ const r = getEditorState(game).entityResize;
214
+ getEditorState(game).entityResize = null;
215
+ return r;
216
+ }
@@ -638,6 +638,36 @@ export interface EditorDragEndMessage {
638
638
  y: number;
639
639
  };
640
640
  }
641
+ /**
642
+ * Rect-entity resize-handle drag completed (2026-06-10).
643
+ *
644
+ * Selected rect entities get the same 8-handle (4 corners + 4 edges)
645
+ * resize affordance as tilemaps. The SDK applies the new size + center to
646
+ * the live GameObject during the drag (Rectangle.setSize is cheap); on
647
+ * pointer-up it posts this message so the host records ONE modify-entity
648
+ * command for undo + flush — mirrors how `dragEnd` works for moves.
649
+ *
650
+ * `width` / `height` are the entity record's BASE dims (display px divided
651
+ * by transform scale), i.e. exactly what belongs in `RectEntity.width/
652
+ * height`. `x` / `y` are the entity transform's center position — it shifts
653
+ * during a resize because the opposite edge stays anchored.
654
+ */
655
+ export interface EditorEntityResizedMessage {
656
+ type: 'umicat:editor:entityResized';
657
+ entityId: string;
658
+ before: {
659
+ x: number;
660
+ y: number;
661
+ width: number;
662
+ height: number;
663
+ };
664
+ after: {
665
+ x: number;
666
+ y: number;
667
+ width: number;
668
+ height: number;
669
+ };
670
+ }
641
671
  /**
642
672
  * Editor keyboard shortcut intercepted inside the iframe (user clicked the
643
673
  * canvas, so focus is on the iframe and the host window doesn't see the
@@ -746,7 +776,7 @@ export interface EditorTilemapTilePickedMessage {
746
776
  layerId: string;
747
777
  tileIndex: number | null;
748
778
  }
749
- export type EditorSdkToHostMessage = EditorSceneLoadedMessage | EditorSelectionPickedMessage | EditorDragEndMessage | EditorShortcutMessage | EditorSelectionRectMessage | EditorRulesLoadedMessage | EditorCameraStateMessage | EditorTilemapEditedMessage | EditorTilemapTilePickedMessage;
779
+ export type EditorSdkToHostMessage = EditorSceneLoadedMessage | EditorSelectionPickedMessage | EditorDragEndMessage | EditorEntityResizedMessage | EditorShortcutMessage | EditorSelectionRectMessage | EditorRulesLoadedMessage | EditorCameraStateMessage | EditorTilemapEditedMessage | EditorTilemapTilePickedMessage;
750
780
  export type RpcMethod = 'saves.get' | 'saves.set' | 'saves.delete' | 'saves.list' | 'gameData.get' | 'gameData.set' | 'gameData.delete' | 'gameData.list' | 'realtime.getToken';
751
781
  export interface SavesGetParams {
752
782
  key: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@umicat/phaser-sdk",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
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",