@unboxy/phaser-sdk 0.2.36 → 0.2.38

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.
@@ -1,4 +1,5 @@
1
1
  import Phaser from 'phaser';
2
+ import NinePatchPlugin from 'phaser3-rex-plugins/plugins/ninepatch-plugin.js';
2
3
  import { setupScreenshotListener } from '../screenshot/ScreenshotManager.js';
3
4
  import { setupRecordingListener } from '../recording/RecordingManager.js';
4
5
  import { setupEditorModeListener } from '../scene/EditorMode.js';
@@ -13,6 +14,20 @@ export function createUnboxyGame(options) {
13
14
  const { width, height } = 'orientation' in options && options.orientation
14
15
  ? ORIENTATION_DIMENSIONS[options.orientation]
15
16
  : { width: options.width, height: options.height };
17
+ // Auto-register rex-plugins NinePatch so HUD widgets with 9-slice asset
18
+ // metadata (`Asset.ninePatch`) can scale without corner distortion. Plugin
19
+ // exposes `scene.add.rexNinePatch(...)` factory; HudRuntime calls it on
20
+ // demand. Merged with any user-supplied `plugins.global` so existing
21
+ // rexVirtualJoystick / rexUI registrations keep working.
22
+ const ninePatchEntry = {
23
+ key: 'rexNinePatchPlugin',
24
+ plugin: NinePatchPlugin,
25
+ start: true,
26
+ };
27
+ const plugins = {
28
+ ...(options.plugins ?? {}),
29
+ global: [ninePatchEntry, ...(options.plugins?.global ?? [])],
30
+ };
16
31
  const config = {
17
32
  type: Phaser.AUTO,
18
33
  backgroundColor: options.backgroundColor ?? '#1a1a2e',
@@ -30,7 +45,7 @@ export function createUnboxyGame(options) {
30
45
  default: 'arcade',
31
46
  arcade: { gravity: { x: 0, y: 0 }, debug: false },
32
47
  },
33
- ...(options.plugins ? { plugins: options.plugins } : {}),
48
+ plugins,
34
49
  // UnboxyHudScene is auto-registered alongside the game's scenes so
35
50
  // `loadWorldScene` can launch it when the active world scene's manifest
36
51
  // entry sets `hud: '<id>'`. Registered at the end so the user's scenes
package/dist/index.d.ts CHANGED
@@ -16,7 +16,7 @@ export type { ChatMessage, ChatMessageKind } from './realtime/UnboxyRoom.js';
16
16
  export { RpcError } from './core/Transport.js';
17
17
  export type { Transport, TransportKind } from './core/Transport.js';
18
18
  export type { UnboxyUser } from './protocol.js';
19
- export { loadWorldScene, preloadManifest, preloadSceneAssets, getManifest, SCENES_BASE, MANIFEST_PATH, } from './scene/SceneLoader.js';
19
+ export { loadWorldScene, preloadManifest, preloadSceneAssets, applyPixelArtFilters, getManifest, SCENES_BASE, MANIFEST_PATH, } from './scene/SceneLoader.js';
20
20
  export type { LoadWorldSceneOptions } from './scene/SceneLoader.js';
21
21
  export { spawnEntity, parseColor } from './scene/spawnEntity.js';
22
22
  export type { SpawnContext, AssetResolver, RenderScriptResolver, } from './scene/spawnEntity.js';
@@ -27,6 +27,6 @@ export type { RenderScriptModule } from './scene/renderScripts.js';
27
27
  export { setupEditorBridge } from './editor/EditorBridge.js';
28
28
  export { EditorOverlayScene, EDITOR_OVERLAY_KEY } from './editor/EditorOverlayScene.js';
29
29
  export type { EditorEntityPatch, EditorEnterMessage, EditorExitMessage, EditorGetSceneMessage, EditorApplyEditMessage, EditorSetSelectionMessage, EditorPanZoomMessage, EditorHostToSdkMessage, EditorSceneLoadedMessage, EditorSelectionPickedMessage, EditorDragEndMessage, EditorShortcutMessage, EditorCreateEntityMessage, EditorDeleteEntityMessage, EditorSetEditModeMessage, EditorSelectionRectMessage, EditorSdkToHostMessage, } from './protocol.js';
30
- export { SCHEMA_VERSION, } from './scene/types.js';
31
- export type { Manifest, SceneRef, HudRef, AssetRecord, AssetKind, SceneType, SceneFile, WorldScene, HudScene, WorldSceneConfig, CameraConfig, WorldEntity, WorldEntityKind, NonGroupWorldEntity, SpriteEntity, PrimitiveEntity, CodeRenderedEntity, GroupEntity, TilemapEntity, TriggerEntity, TriggerShape, WorldVisual, SpriteVisual, PrimitiveVisual, PrimitiveRectVisual, PrimitiveCircleVisual, CodeRenderedVisual, HudEntity, HudEntityKind, HudEntityBase, HudTextEntity, HudImageEntity, HudIconButtonEntity, HudProgressBarEntity, HudPanelEntity, HudVisual, HudTextVisual, HudImageVisual, HudIconButtonVisual, HudProgressBarVisual, HudPanelVisual, HudTextSource, HudNumberSource, HudLayer, Transform, Anchor, AnchorSide, } from './scene/types.js';
30
+ export { SCHEMA_VERSION, isPerFrameNinePatch, } from './scene/types.js';
31
+ export type { Manifest, SceneRef, HudRef, AssetRecord, AssetKind, SceneType, SceneFile, WorldScene, HudScene, WorldSceneConfig, CameraConfig, WorldEntity, WorldEntityKind, NonGroupWorldEntity, SpriteEntity, PrimitiveEntity, CodeRenderedEntity, GroupEntity, TilemapEntity, TriggerEntity, TriggerShape, WorldVisual, SpriteVisual, PrimitiveVisual, PrimitiveRectVisual, PrimitiveCircleVisual, CodeRenderedVisual, HudEntity, HudEntityKind, HudEntityBase, HudTextEntity, HudImageEntity, HudIconButtonEntity, HudProgressBarEntity, HudPanelEntity, HudVisual, HudTextVisual, HudImageVisual, HudIconButtonVisual, HudProgressBarVisual, HudPanelVisual, HudTextSource, HudNumberSource, HudLayer, Transform, Anchor, AnchorSide, NinePatchConfig, NinePatchPerFrame, } from './scene/types.js';
32
32
  export { PROTOCOL_VERSION, type HelloMessage, type InitMessage, type RpcRequestMessage, type RpcResultOk, type RpcResultError, type HostToSdkMessage, type SdkToHostMessage, type RpcErrorPayload, type RpcMethod, type SavesGetParams, type SavesGetResult, type SavesSetParams, type SavesSetResult, type SavesDeleteParams, type SavesDeleteResult, type SavesListResult, type GameDataGetParams, type GameDataGetResult, type GameDataSetParams, type GameDataSetResult, type GameDataDeleteParams, type GameDataDeleteResult, type GameDataListResult, type RealtimeGetTokenParams, type RealtimeGetTokenResult, } from './protocol.js';
package/dist/index.js CHANGED
@@ -14,12 +14,12 @@ export { RealtimeModule } from './realtime/RealtimeModule.js';
14
14
  export { UnboxyRoom, PlayerDataFacade, RoomDataFacade, ChatFacade, MAX_CHAT_TEXT_LEN, } from './realtime/UnboxyRoom.js';
15
15
  export { RpcError } from './core/Transport.js';
16
16
  // Scene-as-data (visual editor foundation, slice 1)
17
- export { loadWorldScene, preloadManifest, preloadSceneAssets, getManifest, SCENES_BASE, MANIFEST_PATH, } from './scene/SceneLoader.js';
17
+ export { loadWorldScene, preloadManifest, preloadSceneAssets, applyPixelArtFilters, getManifest, SCENES_BASE, MANIFEST_PATH, } from './scene/SceneLoader.js';
18
18
  export { spawnEntity, parseColor } from './scene/spawnEntity.js';
19
19
  export { EntityRegistry, attachEntityRegistry, getEntityRegistry, } from './scene/EntityRegistry.js';
20
20
  export { setupEditorModeListener, isEditMode } from './scene/EditorMode.js';
21
21
  export { setRenderScriptRegistry, getRenderScriptRegistry, resolveRenderScript, } from './scene/renderScripts.js';
22
22
  export { setupEditorBridge } from './editor/EditorBridge.js';
23
23
  export { EditorOverlayScene, EDITOR_OVERLAY_KEY } from './editor/EditorOverlayScene.js';
24
- export { SCHEMA_VERSION, } from './scene/types.js';
24
+ export { SCHEMA_VERSION, isPerFrameNinePatch, } from './scene/types.js';
25
25
  export { PROTOCOL_VERSION, } from './protocol.js';
@@ -1,8 +1,8 @@
1
1
  import Phaser from 'phaser';
2
- import { SCHEMA_VERSION, } from './types.js';
2
+ import { SCHEMA_VERSION, isPerFrameNinePatch, } from './types.js';
3
3
  import { attachEntityRegistry, getEntityRegistry, } from './EntityRegistry.js';
4
4
  import { parseColor } from './spawnEntity.js';
5
- import { getManifest, SCENES_BASE } from './SceneLoader.js';
5
+ import { applyPixelArtFilters, getManifest, SCENES_BASE } from './SceneLoader.js';
6
6
  /**
7
7
  * HUD runtime — slice 5.
8
8
  *
@@ -79,6 +79,61 @@ const LAYER_DEPTH = {
79
79
  overlay: 200,
80
80
  modal: 300,
81
81
  };
82
+ /**
83
+ * Render an image (or one cell of a uniform-grid spritesheet) at (x, y) sized
84
+ * to (width, height). When the asset carries `ninePatch` metadata, uses
85
+ * `phaser3-rex-plugins`' NinePatch (corners fixed, edges + center stretch);
86
+ * otherwise falls back to a regular Image scaled with `setDisplaySize`.
87
+ *
88
+ * Two ninePatch shapes accepted:
89
+ * - Single image (slice 7.5): `asset.ninePatch` is a `NinePatchConfig`. The
90
+ * whole texture slices into 9 regions.
91
+ * - Per-frame on a uniform-grid sheet (slice 7.6): `asset.ninePatch` is
92
+ * `{ perFrame: NinePatchConfig }`. The shared cuts apply to whichever
93
+ * frame the caller picks via `frame`. Common case: itch.io button packs
94
+ * where every cell is a different button color/state with the same
95
+ * corner radius.
96
+ *
97
+ * Caller owns origin/anchor — both Image and NinePatch (which extends
98
+ * RenderTexture) expose `setOrigin`. Returns the created GameObject already
99
+ * added to the scene's display list.
100
+ */
101
+ function createImageOrNinePatch(scene, x, y, width, height, asset, frame) {
102
+ if (asset.ninePatch) {
103
+ const np = asset.ninePatch;
104
+ const isPerFrame = isPerFrameNinePatch(np);
105
+ const cuts = isPerFrame ? np.perFrame : np;
106
+ const factory = scene.add.rexNinePatch;
107
+ if (factory) {
108
+ const columns = [cuts.leftWidth, undefined, cuts.rightWidth];
109
+ const rows = [cuts.topHeight, undefined, cuts.bottomHeight];
110
+ // Per-frame uses rex's 9-arg `(scene, x, y, w, h, key, baseFrame, cols, rows)`
111
+ // form — baseFrame scopes the slicing to that one cell of the spritesheet.
112
+ // rex types baseFrame as string; Phaser accepts either string or number
113
+ // for spritesheet frame access, but passing String() satisfies the typed
114
+ // overload on rex's side.
115
+ if (isPerFrame) {
116
+ return factory.call(scene.add, x, y, width, height, asset.textureKey, String(frame ?? 0), columns, rows);
117
+ }
118
+ // Middle column/row left undefined → that segment stretches to fill
119
+ // the remaining space between the two fixed-width edges.
120
+ return factory.call(scene.add, x, y, width, height, asset.textureKey, columns, rows);
121
+ }
122
+ // Plugin not registered — degrade to a stretched Image rather than crash
123
+ // an entire HUD render. Symptom is "corners distort on resize", not a
124
+ // missing widget. Likely cause: a host that bypassed createUnboxyGame's
125
+ // auto-registration (e.g. an external Phaser.Game instance).
126
+ // eslint-disable-next-line no-console
127
+ console.warn(`[unboxy/hud] rexNinePatch plugin not registered; falling back to stretched Image for asset '${asset.id}'`);
128
+ }
129
+ // Fallback: plain Image. Frame index threaded through for spritesheet
130
+ // assets so per-cell rendering still works without 9-slice.
131
+ const image = frame !== undefined
132
+ ? scene.add.image(x, y, asset.textureKey, frame)
133
+ : scene.add.image(x, y, asset.textureKey);
134
+ image.setDisplaySize(width, height);
135
+ return image;
136
+ }
82
137
  /**
83
138
  * Spawn a HUD widget into the scene. The returned GameObject is recorded in
84
139
  * the entity registry so the editor + behavior code can find it by id.
@@ -161,6 +216,23 @@ function createImage(ctx, entity, pos) {
161
216
  container.setSize(w, h);
162
217
  return container;
163
218
  }
219
+ // 9-slice path. When the asset has `ninePatch` metadata the SDK renders via
220
+ // rex-plugins NinePatch (corners fixed; edges + center stretch). NinePatch
221
+ // requires explicit dims, so missing width/height falls back to the texture's
222
+ // native source dims — keeps existing data shapes (image-without-dims) working.
223
+ if (asset.ninePatch) {
224
+ const tex = ctx.scene.textures.get(asset.textureKey);
225
+ const src = tex?.getSourceImage();
226
+ const w = entity.visual.width ?? src?.width ?? 64;
227
+ const h = entity.visual.height ?? src?.height ?? 64;
228
+ const np = createImageOrNinePatch(ctx.scene, pos.x, pos.y, w, h, asset);
229
+ if (entity.visual.tint)
230
+ np.setTint?.(parseColor(entity.visual.tint));
231
+ if (typeof entity.visual.alpha === 'number')
232
+ np.setAlpha(entity.visual.alpha);
233
+ applyOriginFromAnchor(np, entity.anchor.side);
234
+ return np;
235
+ }
164
236
  const image = entity.visual.frame !== undefined
165
237
  ? ctx.scene.add.image(pos.x, pos.y, asset.textureKey, entity.visual.frame)
166
238
  : ctx.scene.add.image(pos.x, pos.y, asset.textureKey);
@@ -174,19 +246,16 @@ function createImage(ctx, entity, pos) {
174
246
  applyOriginFromAnchor(image, entity.anchor.side);
175
247
  return image;
176
248
  }
177
- function createIconButton(ctx, entity, pos) {
178
- const v = entity.visual;
179
- const w = v.width ?? 96;
180
- const h = v.height ?? 48;
181
- const fillColor = parseColor(v.fillColor ?? '#3b82f6');
182
- const strokeColor = v.strokeColor === null ? null : parseColor(v.strokeColor ?? '#1e40af');
183
- const strokeW = v.strokeWidth ?? 0;
184
- // Container at anchor pos. Children drawn with their own origins.
185
- const container = ctx.scene.add.container(pos.x, pos.y);
249
+ /**
250
+ * Build the v0 colored-rect background for an icon-button (rectangle or
251
+ * circle). Used when `backgroundAssetId` is unset OR the referenced asset
252
+ * couldn't be resolved.
253
+ */
254
+ function makeColoredRectBg(scene, v, w, h, fillColor, strokeColor, strokeW) {
186
255
  const bg = v.shape === 'circle'
187
- ? ctx.scene.add.circle(0, 0, Math.max(w, h) / 2, fillColor)
256
+ ? scene.add.circle(0, 0, Math.max(w, h) / 2, fillColor)
188
257
  : (() => {
189
- const r = ctx.scene.add.rectangle(0, 0, w, h, fillColor);
258
+ const r = scene.add.rectangle(0, 0, w, h, fillColor);
190
259
  r.setOrigin(0.5, 0.5);
191
260
  return r;
192
261
  })();
@@ -198,6 +267,40 @@ function createIconButton(ctx, entity, pos) {
198
267
  bg.setStrokeStyle(strokeW, strokeColor);
199
268
  }
200
269
  }
270
+ return bg;
271
+ }
272
+ function createIconButton(ctx, entity, pos) {
273
+ const v = entity.visual;
274
+ const w = v.width ?? 96;
275
+ const h = v.height ?? 48;
276
+ const fillColor = parseColor(v.fillColor ?? '#3b82f6');
277
+ const strokeColor = v.strokeColor === null ? null : parseColor(v.strokeColor ?? '#1e40af');
278
+ const strokeW = v.strokeWidth ?? 0;
279
+ // Container at anchor pos. Children drawn with their own origins.
280
+ const container = ctx.scene.add.container(pos.x, pos.y);
281
+ // Background source (textured vs. colored-rect). When `backgroundAssetId`
282
+ // is set we render the asset image (NinePatch when the asset has 9-slice
283
+ // metadata; stretched Image otherwise); colored-rect bg is the v0 fallback.
284
+ // The interactive surface is always the `bg` GameObject — POINTER_DOWN/UP
285
+ // events fire on whichever variant is active.
286
+ let bg;
287
+ let texturedBg = false;
288
+ if (v.backgroundAssetId) {
289
+ try {
290
+ const bgAsset = ctx.resolveAsset(v.backgroundAssetId);
291
+ const np = createImageOrNinePatch(ctx.scene, 0, 0, w, h, bgAsset, v.backgroundFrame);
292
+ np.setOrigin?.(0.5, 0.5);
293
+ bg = np;
294
+ texturedBg = true;
295
+ }
296
+ catch {
297
+ // Background asset missing — fall through to colored-rect.
298
+ bg = makeColoredRectBg(ctx.scene, v, w, h, fillColor, strokeColor, strokeW);
299
+ }
300
+ }
301
+ else {
302
+ bg = makeColoredRectBg(ctx.scene, v, w, h, fillColor, strokeColor, strokeW);
303
+ }
201
304
  container.add(bg);
202
305
  if (v.iconAssetId) {
203
306
  try {
@@ -230,19 +333,23 @@ function createIconButton(ctx, entity, pos) {
230
333
  // code subscribes by entity id (or role) to wire its onPress action.
231
334
  // The editor mode ignores this — input is captured by the EditorOverlay.
232
335
  bg.setInteractive({ useHandCursor: true });
336
+ // pressedFillColor only applies to the colored-rect bg (Rectangle/Arc have
337
+ // setFillStyle). Textured backgrounds render the asset's pixels directly —
338
+ // pressed-state visual feedback for those is v2.
339
+ const supportsFillSwap = !texturedBg && !!v.pressedFillColor;
233
340
  bg.on(Phaser.Input.Events.POINTER_DOWN, () => {
234
- if (v.pressedFillColor) {
341
+ if (supportsFillSwap) {
235
342
  bg.setFillStyle?.(parseColor(v.pressedFillColor));
236
343
  }
237
344
  });
238
345
  bg.on(Phaser.Input.Events.POINTER_UP, () => {
239
- if (v.pressedFillColor) {
346
+ if (supportsFillSwap) {
240
347
  bg.setFillStyle?.(fillColor);
241
348
  }
242
349
  ctx.scene.events.emit('hud:press', entity.id, entity);
243
350
  });
244
351
  bg.on(Phaser.Input.Events.POINTER_OUT, () => {
245
- if (v.pressedFillColor) {
352
+ if (supportsFillSwap) {
246
353
  bg.setFillStyle?.(fillColor);
247
354
  }
248
355
  });
@@ -351,35 +458,54 @@ function createPanel(ctx, entity, pos) {
351
458
  const w = v.width ?? 200;
352
459
  const h = v.height ?? 100;
353
460
  const container = ctx.scene.add.container(pos.x, pos.y);
354
- const g = ctx.scene.add.graphics();
355
- container.add(g);
356
- const draw = () => {
357
- g.clear();
358
- const radius = v.borderRadius ?? 0;
359
- // Centered draw — see createProgressBar for the rationale.
360
- const x0 = -w / 2;
361
- const y0 = -h / 2;
362
- if (v.backgroundColor) {
363
- g.fillStyle(parseColor(v.backgroundColor), v.backgroundAlpha ?? 1);
364
- if (radius > 0)
365
- g.fillRoundedRect(x0, y0, w, h, radius);
366
- else
367
- g.fillRect(x0, y0, w, h);
461
+ // Textured-background path: when `backgroundAssetId` is set we render the
462
+ // asset image (NinePatch when it carries 9-slice metadata; stretched Image
463
+ // otherwise). The colored Graphics path below is the fallback when the
464
+ // asset is missing or no background asset is configured.
465
+ let bgRendered = false;
466
+ if (v.backgroundAssetId) {
467
+ try {
468
+ const bgAsset = ctx.resolveAsset(v.backgroundAssetId);
469
+ const np = createImageOrNinePatch(ctx.scene, 0, 0, w, h, bgAsset, v.backgroundFrame);
470
+ np.setOrigin?.(0.5, 0.5);
471
+ container.add(np);
472
+ bgRendered = true;
368
473
  }
369
- if (v.borderColor && (v.borderWidth ?? 0) > 0) {
370
- g.lineStyle(v.borderWidth ?? 1, parseColor(v.borderColor), 1);
371
- if (radius > 0)
372
- g.strokeRoundedRect(x0, y0, w, h, radius);
373
- else
374
- g.strokeRect(x0, y0, w, h);
474
+ catch {
475
+ // Background asset missing — fall through to Graphics rect.
375
476
  }
376
- };
377
- draw();
477
+ }
478
+ if (!bgRendered) {
479
+ const g = ctx.scene.add.graphics();
480
+ container.add(g);
481
+ const draw = () => {
482
+ g.clear();
483
+ const radius = v.borderRadius ?? 0;
484
+ // Centered draw — see createProgressBar for the rationale.
485
+ const x0 = -w / 2;
486
+ const y0 = -h / 2;
487
+ if (v.backgroundColor) {
488
+ g.fillStyle(parseColor(v.backgroundColor), v.backgroundAlpha ?? 1);
489
+ if (radius > 0)
490
+ g.fillRoundedRect(x0, y0, w, h, radius);
491
+ else
492
+ g.fillRect(x0, y0, w, h);
493
+ }
494
+ if (v.borderColor && (v.borderWidth ?? 0) > 0) {
495
+ g.lineStyle(v.borderWidth ?? 1, parseColor(v.borderColor), 1);
496
+ if (radius > 0)
497
+ g.strokeRoundedRect(x0, y0, w, h, radius);
498
+ else
499
+ g.strokeRect(x0, y0, w, h);
500
+ }
501
+ };
502
+ draw();
503
+ container.setData('hudRedraw', draw);
504
+ }
378
505
  container.setSize(w, h);
379
506
  container.setData('editorHitWidth', w);
380
507
  container.setData('editorHitHeight', h);
381
508
  applyContainerOriginShift(container, entity.anchor.side, w, h);
382
- container.setData('hudRedraw', draw);
383
509
  return container;
384
510
  }
385
511
  /** Read a numeric source (static or dynamic from registry). */
@@ -528,10 +654,18 @@ export function preloadHudAssets(scene, hudScene, manifest) {
528
654
  function collectHudAssetIds(entities) {
529
655
  const ids = new Set();
530
656
  for (const e of entities) {
531
- if (e.kind === 'image')
657
+ if (e.kind === 'image') {
532
658
  ids.add(e.visual.assetId);
533
- else if (e.kind === 'icon-button' && e.visual.iconAssetId)
534
- ids.add(e.visual.iconAssetId);
659
+ }
660
+ else if (e.kind === 'icon-button') {
661
+ if (e.visual.iconAssetId)
662
+ ids.add(e.visual.iconAssetId);
663
+ if (e.visual.backgroundAssetId)
664
+ ids.add(e.visual.backgroundAssetId);
665
+ }
666
+ else if (e.kind === 'panel' && e.visual.backgroundAssetId) {
667
+ ids.add(e.visual.backgroundAssetId);
668
+ }
535
669
  }
536
670
  return Array.from(ids);
537
671
  }
@@ -558,6 +692,11 @@ export async function loadHudScene(scene, hudId) {
558
692
  }
559
693
  preloadHudAssets(scene, hudScene, manifest);
560
694
  await runLoader(scene);
695
+ // Mirrors loadWorldScene: NEAREST filter on every `pixelArt: true` asset.
696
+ // HUD scene runs in its own Phaser scene with its own texture references
697
+ // (textures are game-global so this is mostly idempotent with the world
698
+ // pass, but the HUD might load image assets the world doesn't reference).
699
+ applyPixelArtFilters(scene, manifest);
561
700
  const registry = attachEntityRegistry(scene);
562
701
  const safeArea = hudScene.design?.safeArea ?? DEFAULT_SAFE_AREA;
563
702
  const ctx = {
@@ -783,23 +922,56 @@ export function applyHudPatch(game, entityId, patch) {
783
922
  if (patch.visual && entity.kind === 'image') {
784
923
  const v = entity.visual;
785
924
  const p = patch.visual;
786
- const image = go;
787
- if (typeof p.tint === 'string') {
788
- v.tint = p.tint;
789
- image.setTint(parseColor(v.tint));
790
- }
791
- else if (p.tint === null) {
792
- v.tint = undefined;
793
- image.clearTint();
794
- }
795
- if (typeof p.alpha === 'number') {
796
- v.alpha = p.alpha;
797
- image.setAlpha(p.alpha);
925
+ // 9-slice live objects are RenderTextures, not Images — setDisplaySize /
926
+ // setTint don't behave the same. Mirror the panel/icon-button approach:
927
+ // mutate the entity record + re-spawn in place. Drag-resize from the
928
+ // editor lands here; the rebuild keeps NinePatch geometry correct.
929
+ const manifest = getManifest(hud);
930
+ const asset = manifest.assets?.find((a) => a.id === v.assetId);
931
+ if (asset?.ninePatch) {
932
+ for (const [k, val] of Object.entries(p)) {
933
+ if (val !== undefined)
934
+ v[k] = val;
935
+ }
936
+ const oldDepth = go.depth;
937
+ go.destroy();
938
+ reg.unregister(entityId);
939
+ const ctx = {
940
+ scene: hud,
941
+ registry: reg,
942
+ safeArea,
943
+ resolveAsset: (id) => {
944
+ const m = getManifest(hud);
945
+ const a = m.assets?.find((x) => x.id === id);
946
+ if (!a)
947
+ throw new Error(`[unboxy/hud] manifest has no asset with id '${id}'`);
948
+ return a;
949
+ },
950
+ };
951
+ const fresh = spawnHudEntity(ctx, entity);
952
+ if (typeof oldDepth === 'number') {
953
+ fresh.setDepth?.(oldDepth);
954
+ }
798
955
  }
799
- if (typeof p.width === 'number' && typeof p.height === 'number') {
800
- v.width = p.width;
801
- v.height = p.height;
802
- image.setDisplaySize(p.width, p.height);
956
+ else {
957
+ const image = go;
958
+ if (typeof p.tint === 'string') {
959
+ v.tint = p.tint;
960
+ image.setTint(parseColor(v.tint));
961
+ }
962
+ else if (p.tint === null) {
963
+ v.tint = undefined;
964
+ image.clearTint();
965
+ }
966
+ if (typeof p.alpha === 'number') {
967
+ v.alpha = p.alpha;
968
+ image.setAlpha(p.alpha);
969
+ }
970
+ if (typeof p.width === 'number' && typeof p.height === 'number') {
971
+ v.width = p.width;
972
+ v.height = p.height;
973
+ image.setDisplaySize(p.width, p.height);
974
+ }
803
975
  }
804
976
  }
805
977
  if (patch.visual && (entity.kind === 'progress-bar' || entity.kind === 'panel')) {
@@ -847,6 +1019,14 @@ export function applyHudPatch(game, entityId, patch) {
847
1019
  v.fillColor = p.fillColor;
848
1020
  if (typeof p.iconAssetId === 'string')
849
1021
  v.iconAssetId = p.iconAssetId;
1022
+ if (typeof p.backgroundAssetId === 'string')
1023
+ v.backgroundAssetId = p.backgroundAssetId;
1024
+ else if (p.backgroundAssetId === null)
1025
+ v.backgroundAssetId = undefined;
1026
+ if (typeof p.backgroundFrame === 'number')
1027
+ v.backgroundFrame = p.backgroundFrame;
1028
+ else if (p.backgroundFrame === null)
1029
+ v.backgroundFrame = undefined;
850
1030
  // Patches that change the rendered visual deeply (label, colour, icon)
851
1031
  // re-render by destroying + re-spawning the container in place.
852
1032
  const reg2 = reg;
@@ -33,6 +33,33 @@ export declare function getManifest(scene: Phaser.Scene): Manifest;
33
33
  * scene that uses it is free.
34
34
  */
35
35
  export declare function preloadSceneAssets(scene: Phaser.Scene, sceneFile: SceneFile, manifest: Manifest): void;
36
+ /**
37
+ * Walk every `kind=spritesheet` asset in the manifest and register each one's
38
+ * `animations[]` as a Phaser animation, so behavior code can call
39
+ * `sprite.play('walk-down')` directly. Idempotent — uses `scene.anims.exists()`
40
+ * to skip duplicates (Phaser's anims.create throws otherwise).
41
+ *
42
+ * <p>Animation keys are scene-global. Two sheets registering the same name
43
+ * (e.g. two characters both with `walk-down`) → first wins, second logs a
44
+ * warning. Sheets that need disambiguation should namespace their keys
45
+ * (e.g. `cow:walk-down`) at metadata time.
46
+ */
47
+ /**
48
+ * Walk every asset in the manifest and apply `Phaser.Textures.FilterMode.NEAREST`
49
+ * to each `pixelArt: true` entry's loaded texture. Without this, pixel-art
50
+ * sprites render bilinear-blurred — visible immediately on imported character
51
+ * sheets when the agent hasn't manually set Phaser game config's `pixelArt`.
52
+ *
53
+ * <p>Idempotent: setFilter is safe to call multiple times. Skip silently when
54
+ * a texture isn't loaded yet (next call after that asset is requested will
55
+ * pick it up). Wrap in try/catch so a single bad asset doesn't block the rest
56
+ * of scene init.
57
+ *
58
+ * <p>Per-asset NEAREST closes the visible blur gap; the game-wide flag
59
+ * (`Phaser.Game.config.pixelArt` + `camera.setRoundPixels`) is a separate
60
+ * concern (framebuffer-stretch crispness, perf) deferred to a follow-up.
61
+ */
62
+ export declare function applyPixelArtFilters(scene: Phaser.Scene, manifest: Manifest): void;
36
63
  export interface LoadWorldSceneOptions {
37
64
  /**
38
65
  * Optional render-script resolver for `code-rendered` entities. When
@@ -126,6 +126,37 @@ function collectAssetIds(entities) {
126
126
  * warning. Sheets that need disambiguation should namespace their keys
127
127
  * (e.g. `cow:walk-down`) at metadata time.
128
128
  */
129
+ /**
130
+ * Walk every asset in the manifest and apply `Phaser.Textures.FilterMode.NEAREST`
131
+ * to each `pixelArt: true` entry's loaded texture. Without this, pixel-art
132
+ * sprites render bilinear-blurred — visible immediately on imported character
133
+ * sheets when the agent hasn't manually set Phaser game config's `pixelArt`.
134
+ *
135
+ * <p>Idempotent: setFilter is safe to call multiple times. Skip silently when
136
+ * a texture isn't loaded yet (next call after that asset is requested will
137
+ * pick it up). Wrap in try/catch so a single bad asset doesn't block the rest
138
+ * of scene init.
139
+ *
140
+ * <p>Per-asset NEAREST closes the visible blur gap; the game-wide flag
141
+ * (`Phaser.Game.config.pixelArt` + `camera.setRoundPixels`) is a separate
142
+ * concern (framebuffer-stretch crispness, perf) deferred to a follow-up.
143
+ */
144
+ export function applyPixelArtFilters(scene, manifest) {
145
+ for (const asset of manifest.assets ?? []) {
146
+ if (!asset.pixelArt)
147
+ continue;
148
+ if (!scene.textures.exists(asset.textureKey))
149
+ continue;
150
+ try {
151
+ const tex = scene.textures.get(asset.textureKey);
152
+ tex.setFilter(Phaser.Textures.FilterMode.NEAREST);
153
+ }
154
+ catch (e) {
155
+ // eslint-disable-next-line no-console
156
+ console.warn(`[unboxy/scene] failed to apply NEAREST filter to '${asset.id}': ${String(e)}`);
157
+ }
158
+ }
159
+ }
129
160
  function registerSheetAnimations(scene, manifest) {
130
161
  const assets = manifest.assets ?? [];
131
162
  for (const asset of assets) {
@@ -249,6 +280,9 @@ export async function loadWorldScene(scene, sceneId, options = {}) {
249
280
  // Lazy preload of any new assets this scene needs, then await loader.
250
281
  preloadSceneAssets(scene, sceneFile, manifest);
251
282
  await runLoader(scene);
283
+ // Per-asset NEAREST filter for `pixelArt: true` entries. Must run after
284
+ // textures are loaded — setFilter on a missing texture is a no-op.
285
+ applyPixelArtFilters(scene, manifest);
252
286
  // Register Phaser animations declared in `manifest.assets[].animations`.
253
287
  // Done after texture load + before entity spawn so behavior code can call
254
288
  // `sprite.play('walk-down')` from frame 1 without manual `anims.create()`.
@@ -72,7 +72,53 @@ export interface AssetRecord {
72
72
  animations?: SheetAnimation[];
73
73
  /** Default playback rate for `animations[]`. Defaults to 8. */
74
74
  fps?: number;
75
+ /**
76
+ * 9-slice cut-line metadata. Two shapes accepted:
77
+ *
78
+ * - **Single image** (slice 7.5) — `NinePatchConfig` directly. Used on
79
+ * `kind: 'image'` assets like a single button/panel background. All four
80
+ * corner widths apply to the image as a whole.
81
+ * - **Per-frame on a uniform grid** (slice 7.6) — `{ perFrame: NinePatchConfig }`.
82
+ * Used on `kind: 'spritesheet'` assets where every cell is its own
83
+ * stretchable button/panel sharing the same corner radius (typical
84
+ * itch.io UI button packs, e.g. `Square Buttons 26x26.png`). The HUD
85
+ * widget references which cell to render via `backgroundFrame`.
86
+ *
87
+ * Per-cell keyed config (`{ perFrame: Record<number, NinePatchConfig> }`)
88
+ * is deferred to v3 per design 07 §7.9.5 — promote only when a real pack
89
+ * needs different corner radii on different cells.
90
+ */
91
+ ninePatch?: NinePatchConfig | NinePatchPerFrame;
92
+ /**
93
+ * Vision-detected pixel-art flag. When true, SDK applies
94
+ * `Phaser.Textures.FilterMode.NEAREST` to the texture after load so it
95
+ * renders crisp instead of bilinear-blurred. Population is per-source:
96
+ * pack-import sets it via vision; AI generation / manual upload / library
97
+ * backfill close their own gaps separately (see design doc 07 §8.4).
98
+ */
99
+ pixelArt?: boolean;
75
100
  }
101
+ /** Single-image 9-slice config. Pixels from each edge to the slice line. */
102
+ export interface NinePatchConfig {
103
+ leftWidth: number;
104
+ rightWidth: number;
105
+ topHeight: number;
106
+ bottomHeight: number;
107
+ }
108
+ /**
109
+ * Per-frame 9-slice config (slice 7.6). One shared `NinePatchConfig` applied
110
+ * to every frame of a uniform-grid spritesheet. Covers ~95% of itch.io UI
111
+ * button packs, where each cell is a different button color/state but all
112
+ * share the same corner radius.
113
+ */
114
+ export interface NinePatchPerFrame {
115
+ perFrame: NinePatchConfig;
116
+ }
117
+ /**
118
+ * Type guard — true when `ninePatch` is the per-frame variant. Lets call
119
+ * sites that only need to handle one shape stay narrow.
120
+ */
121
+ export declare function isPerFrameNinePatch(np: NinePatchConfig | NinePatchPerFrame | undefined): np is NinePatchPerFrame;
76
122
  export interface SheetAnimation {
77
123
  /** Phaser animation key — used as `sprite.play('<name>')`. */
78
124
  name: string;
@@ -345,7 +391,27 @@ export interface HudIconButtonVisual {
345
391
  label?: string;
346
392
  /** Optional icon asset shown inside the button. */
347
393
  iconAssetId?: string;
348
- /** Button shape. Default `rounded-rect`. */
394
+ /**
395
+ * Optional image asset rendered as the button background. When the
396
+ * referenced asset has `ninePatch` metadata, the SDK renders it via
397
+ * `phaser3-rex-plugins` NinePatch so corners stay fixed at any width/height;
398
+ * otherwise the image is stretched to the widget's size. When set, the
399
+ * colored-rect bg below (fillColor / strokeColor / shape) is ignored, and
400
+ * `pressedFillColor` no-ops (no fill swap on a textured bg in v1).
401
+ *
402
+ * For uniform-grid spritesheet assets with `ninePatch.perFrame` set
403
+ * (slice 7.6), pair this with `backgroundFrame` to pick which cell to
404
+ * render — common case is a button-pack sheet where every cell is a
405
+ * different button color/state.
406
+ */
407
+ backgroundAssetId?: string;
408
+ /**
409
+ * For uniform-grid spritesheet backgrounds (slice 7.6): index of the cell
410
+ * to render. Defaults to 0 when omitted. Ignored when the background asset
411
+ * is a single image.
412
+ */
413
+ backgroundFrame?: number;
414
+ /** Button shape. Default `rounded-rect`. Ignored when backgroundAssetId is set. */
349
415
  shape?: 'rounded-rect' | 'circle';
350
416
  width?: number;
351
417
  height?: number;
@@ -389,6 +455,25 @@ export interface HudPanelVisual {
389
455
  kind: 'panel';
390
456
  width?: number;
391
457
  height?: number;
458
+ /**
459
+ * Optional image asset rendered as the panel background. When the
460
+ * referenced asset has `ninePatch` metadata, the SDK renders it via
461
+ * `phaser3-rex-plugins` NinePatch so corners stay fixed at any width/height;
462
+ * otherwise the image is stretched to the widget's size. When set,
463
+ * `backgroundColor` / `backgroundAlpha` / `borderColor` / `borderWidth` are
464
+ * ignored — the asset image carries its own pixels.
465
+ *
466
+ * For uniform-grid spritesheet assets with `ninePatch.perFrame` set
467
+ * (slice 7.6), pair this with `backgroundFrame` to pick which cell to
468
+ * render.
469
+ */
470
+ backgroundAssetId?: string;
471
+ /**
472
+ * For uniform-grid spritesheet backgrounds (slice 7.6): index of the cell
473
+ * to render. Defaults to 0 when omitted. Ignored when the background asset
474
+ * is a single image.
475
+ */
476
+ backgroundFrame?: number;
392
477
  /** Solid fill colour. Falls back to a transparent panel if omitted. */
393
478
  backgroundColor?: string;
394
479
  /** Background alpha (independent of widget-level alpha). */
@@ -8,3 +8,10 @@
8
8
  * code-rendered visuals will land in later slices.
9
9
  */
10
10
  export const SCHEMA_VERSION = 1;
11
+ /**
12
+ * Type guard — true when `ninePatch` is the per-frame variant. Lets call
13
+ * sites that only need to handle one shape stay narrow.
14
+ */
15
+ export function isPerFrameNinePatch(np) {
16
+ return !!np && 'perFrame' in np;
17
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unboxy/phaser-sdk",
3
- "version": "0.2.36",
3
+ "version": "0.2.38",
4
4
  "description": "Unboxy Phaser 3 SDK — game infrastructure for the Unboxy platform",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -18,11 +18,13 @@
18
18
  "colyseus.js": "^0.16.0"
19
19
  },
20
20
  "peerDependencies": {
21
- "phaser": "^3.60.0"
21
+ "phaser": "^3.60.0",
22
+ "phaser3-rex-plugins": "^1.80.0"
22
23
  },
23
24
  "devDependencies": {
24
25
  "jose": "^6.2.2",
25
26
  "phaser": "^3.80.0",
27
+ "phaser3-rex-plugins": "^1.80.20",
26
28
  "typescript": "^5.5.0"
27
29
  },
28
30
  "publishConfig": {