@unboxy/phaser-sdk 0.2.36 → 0.2.37

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';
@@ -28,5 +28,5 @@ 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
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';
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, } 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,7 +14,7 @@ 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';
@@ -2,7 +2,7 @@ import Phaser from 'phaser';
2
2
  import { SCHEMA_VERSION, } 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,34 @@ const LAYER_DEPTH = {
79
79
  overlay: 200,
80
80
  modal: 300,
81
81
  };
82
+ /**
83
+ * Render an image asset at (x, y) sized to (width, height). When the asset
84
+ * carries `ninePatch` metadata, uses `phaser3-rex-plugins`' NinePatch (corners
85
+ * fixed, edges + center stretch); otherwise falls back to a regular Image
86
+ * scaled with `setDisplaySize`. Caller owns origin/anchor — both Image and
87
+ * NinePatch (which extends RenderTexture) expose `setOrigin`. Returns the
88
+ * created GameObject already added to the scene's display list.
89
+ */
90
+ function createImageOrNinePatch(scene, x, y, width, height, asset) {
91
+ if (asset.ninePatch) {
92
+ const np = asset.ninePatch;
93
+ const factory = scene.add.rexNinePatch;
94
+ if (factory) {
95
+ // Middle column/row left undefined → that segment stretches to fill
96
+ // the remaining space between the two fixed-width edges.
97
+ return factory.call(scene.add, x, y, width, height, asset.textureKey, [np.leftWidth, undefined, np.rightWidth], [np.topHeight, undefined, np.bottomHeight]);
98
+ }
99
+ // Plugin not registered — degrade to a stretched Image rather than crash
100
+ // an entire HUD render. Symptom is "corners distort on resize", not a
101
+ // missing widget. Likely cause: a host that bypassed createUnboxyGame's
102
+ // auto-registration (e.g. an external Phaser.Game instance).
103
+ // eslint-disable-next-line no-console
104
+ console.warn(`[unboxy/hud] rexNinePatch plugin not registered; falling back to stretched Image for asset '${asset.id}'`);
105
+ }
106
+ const image = scene.add.image(x, y, asset.textureKey);
107
+ image.setDisplaySize(width, height);
108
+ return image;
109
+ }
82
110
  /**
83
111
  * Spawn a HUD widget into the scene. The returned GameObject is recorded in
84
112
  * the entity registry so the editor + behavior code can find it by id.
@@ -161,6 +189,23 @@ function createImage(ctx, entity, pos) {
161
189
  container.setSize(w, h);
162
190
  return container;
163
191
  }
192
+ // 9-slice path. When the asset has `ninePatch` metadata the SDK renders via
193
+ // rex-plugins NinePatch (corners fixed; edges + center stretch). NinePatch
194
+ // requires explicit dims, so missing width/height falls back to the texture's
195
+ // native source dims — keeps existing data shapes (image-without-dims) working.
196
+ if (asset.ninePatch) {
197
+ const tex = ctx.scene.textures.get(asset.textureKey);
198
+ const src = tex?.getSourceImage();
199
+ const w = entity.visual.width ?? src?.width ?? 64;
200
+ const h = entity.visual.height ?? src?.height ?? 64;
201
+ const np = createImageOrNinePatch(ctx.scene, pos.x, pos.y, w, h, asset);
202
+ if (entity.visual.tint)
203
+ np.setTint?.(parseColor(entity.visual.tint));
204
+ if (typeof entity.visual.alpha === 'number')
205
+ np.setAlpha(entity.visual.alpha);
206
+ applyOriginFromAnchor(np, entity.anchor.side);
207
+ return np;
208
+ }
164
209
  const image = entity.visual.frame !== undefined
165
210
  ? ctx.scene.add.image(pos.x, pos.y, asset.textureKey, entity.visual.frame)
166
211
  : ctx.scene.add.image(pos.x, pos.y, asset.textureKey);
@@ -174,19 +219,16 @@ function createImage(ctx, entity, pos) {
174
219
  applyOriginFromAnchor(image, entity.anchor.side);
175
220
  return image;
176
221
  }
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);
222
+ /**
223
+ * Build the v0 colored-rect background for an icon-button (rectangle or
224
+ * circle). Used when `backgroundAssetId` is unset OR the referenced asset
225
+ * couldn't be resolved.
226
+ */
227
+ function makeColoredRectBg(scene, v, w, h, fillColor, strokeColor, strokeW) {
186
228
  const bg = v.shape === 'circle'
187
- ? ctx.scene.add.circle(0, 0, Math.max(w, h) / 2, fillColor)
229
+ ? scene.add.circle(0, 0, Math.max(w, h) / 2, fillColor)
188
230
  : (() => {
189
- const r = ctx.scene.add.rectangle(0, 0, w, h, fillColor);
231
+ const r = scene.add.rectangle(0, 0, w, h, fillColor);
190
232
  r.setOrigin(0.5, 0.5);
191
233
  return r;
192
234
  })();
@@ -198,6 +240,40 @@ function createIconButton(ctx, entity, pos) {
198
240
  bg.setStrokeStyle(strokeW, strokeColor);
199
241
  }
200
242
  }
243
+ return bg;
244
+ }
245
+ function createIconButton(ctx, entity, pos) {
246
+ const v = entity.visual;
247
+ const w = v.width ?? 96;
248
+ const h = v.height ?? 48;
249
+ const fillColor = parseColor(v.fillColor ?? '#3b82f6');
250
+ const strokeColor = v.strokeColor === null ? null : parseColor(v.strokeColor ?? '#1e40af');
251
+ const strokeW = v.strokeWidth ?? 0;
252
+ // Container at anchor pos. Children drawn with their own origins.
253
+ const container = ctx.scene.add.container(pos.x, pos.y);
254
+ // Background source (textured vs. colored-rect). When `backgroundAssetId`
255
+ // is set we render the asset image (NinePatch when the asset has 9-slice
256
+ // metadata; stretched Image otherwise); colored-rect bg is the v0 fallback.
257
+ // The interactive surface is always the `bg` GameObject — POINTER_DOWN/UP
258
+ // events fire on whichever variant is active.
259
+ let bg;
260
+ let texturedBg = false;
261
+ if (v.backgroundAssetId) {
262
+ try {
263
+ const bgAsset = ctx.resolveAsset(v.backgroundAssetId);
264
+ const np = createImageOrNinePatch(ctx.scene, 0, 0, w, h, bgAsset);
265
+ np.setOrigin?.(0.5, 0.5);
266
+ bg = np;
267
+ texturedBg = true;
268
+ }
269
+ catch {
270
+ // Background asset missing — fall through to colored-rect.
271
+ bg = makeColoredRectBg(ctx.scene, v, w, h, fillColor, strokeColor, strokeW);
272
+ }
273
+ }
274
+ else {
275
+ bg = makeColoredRectBg(ctx.scene, v, w, h, fillColor, strokeColor, strokeW);
276
+ }
201
277
  container.add(bg);
202
278
  if (v.iconAssetId) {
203
279
  try {
@@ -230,19 +306,23 @@ function createIconButton(ctx, entity, pos) {
230
306
  // code subscribes by entity id (or role) to wire its onPress action.
231
307
  // The editor mode ignores this — input is captured by the EditorOverlay.
232
308
  bg.setInteractive({ useHandCursor: true });
309
+ // pressedFillColor only applies to the colored-rect bg (Rectangle/Arc have
310
+ // setFillStyle). Textured backgrounds render the asset's pixels directly —
311
+ // pressed-state visual feedback for those is v2.
312
+ const supportsFillSwap = !texturedBg && !!v.pressedFillColor;
233
313
  bg.on(Phaser.Input.Events.POINTER_DOWN, () => {
234
- if (v.pressedFillColor) {
314
+ if (supportsFillSwap) {
235
315
  bg.setFillStyle?.(parseColor(v.pressedFillColor));
236
316
  }
237
317
  });
238
318
  bg.on(Phaser.Input.Events.POINTER_UP, () => {
239
- if (v.pressedFillColor) {
319
+ if (supportsFillSwap) {
240
320
  bg.setFillStyle?.(fillColor);
241
321
  }
242
322
  ctx.scene.events.emit('hud:press', entity.id, entity);
243
323
  });
244
324
  bg.on(Phaser.Input.Events.POINTER_OUT, () => {
245
- if (v.pressedFillColor) {
325
+ if (supportsFillSwap) {
246
326
  bg.setFillStyle?.(fillColor);
247
327
  }
248
328
  });
@@ -351,35 +431,54 @@ function createPanel(ctx, entity, pos) {
351
431
  const w = v.width ?? 200;
352
432
  const h = v.height ?? 100;
353
433
  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);
434
+ // Textured-background path: when `backgroundAssetId` is set we render the
435
+ // asset image (NinePatch when it carries 9-slice metadata; stretched Image
436
+ // otherwise). The colored Graphics path below is the fallback when the
437
+ // asset is missing or no background asset is configured.
438
+ let bgRendered = false;
439
+ if (v.backgroundAssetId) {
440
+ try {
441
+ const bgAsset = ctx.resolveAsset(v.backgroundAssetId);
442
+ const np = createImageOrNinePatch(ctx.scene, 0, 0, w, h, bgAsset);
443
+ np.setOrigin?.(0.5, 0.5);
444
+ container.add(np);
445
+ bgRendered = true;
368
446
  }
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);
447
+ catch {
448
+ // Background asset missing — fall through to Graphics rect.
375
449
  }
376
- };
377
- draw();
450
+ }
451
+ if (!bgRendered) {
452
+ const g = ctx.scene.add.graphics();
453
+ container.add(g);
454
+ const draw = () => {
455
+ g.clear();
456
+ const radius = v.borderRadius ?? 0;
457
+ // Centered draw — see createProgressBar for the rationale.
458
+ const x0 = -w / 2;
459
+ const y0 = -h / 2;
460
+ if (v.backgroundColor) {
461
+ g.fillStyle(parseColor(v.backgroundColor), v.backgroundAlpha ?? 1);
462
+ if (radius > 0)
463
+ g.fillRoundedRect(x0, y0, w, h, radius);
464
+ else
465
+ g.fillRect(x0, y0, w, h);
466
+ }
467
+ if (v.borderColor && (v.borderWidth ?? 0) > 0) {
468
+ g.lineStyle(v.borderWidth ?? 1, parseColor(v.borderColor), 1);
469
+ if (radius > 0)
470
+ g.strokeRoundedRect(x0, y0, w, h, radius);
471
+ else
472
+ g.strokeRect(x0, y0, w, h);
473
+ }
474
+ };
475
+ draw();
476
+ container.setData('hudRedraw', draw);
477
+ }
378
478
  container.setSize(w, h);
379
479
  container.setData('editorHitWidth', w);
380
480
  container.setData('editorHitHeight', h);
381
481
  applyContainerOriginShift(container, entity.anchor.side, w, h);
382
- container.setData('hudRedraw', draw);
383
482
  return container;
384
483
  }
385
484
  /** Read a numeric source (static or dynamic from registry). */
@@ -528,10 +627,18 @@ export function preloadHudAssets(scene, hudScene, manifest) {
528
627
  function collectHudAssetIds(entities) {
529
628
  const ids = new Set();
530
629
  for (const e of entities) {
531
- if (e.kind === 'image')
630
+ if (e.kind === 'image') {
532
631
  ids.add(e.visual.assetId);
533
- else if (e.kind === 'icon-button' && e.visual.iconAssetId)
534
- ids.add(e.visual.iconAssetId);
632
+ }
633
+ else if (e.kind === 'icon-button') {
634
+ if (e.visual.iconAssetId)
635
+ ids.add(e.visual.iconAssetId);
636
+ if (e.visual.backgroundAssetId)
637
+ ids.add(e.visual.backgroundAssetId);
638
+ }
639
+ else if (e.kind === 'panel' && e.visual.backgroundAssetId) {
640
+ ids.add(e.visual.backgroundAssetId);
641
+ }
535
642
  }
536
643
  return Array.from(ids);
537
644
  }
@@ -558,6 +665,11 @@ export async function loadHudScene(scene, hudId) {
558
665
  }
559
666
  preloadHudAssets(scene, hudScene, manifest);
560
667
  await runLoader(scene);
668
+ // Mirrors loadWorldScene: NEAREST filter on every `pixelArt: true` asset.
669
+ // HUD scene runs in its own Phaser scene with its own texture references
670
+ // (textures are game-global so this is mostly idempotent with the world
671
+ // pass, but the HUD might load image assets the world doesn't reference).
672
+ applyPixelArtFilters(scene, manifest);
561
673
  const registry = attachEntityRegistry(scene);
562
674
  const safeArea = hudScene.design?.safeArea ?? DEFAULT_SAFE_AREA;
563
675
  const ctx = {
@@ -783,23 +895,56 @@ export function applyHudPatch(game, entityId, patch) {
783
895
  if (patch.visual && entity.kind === 'image') {
784
896
  const v = entity.visual;
785
897
  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);
898
+ // 9-slice live objects are RenderTextures, not Images — setDisplaySize /
899
+ // setTint don't behave the same. Mirror the panel/icon-button approach:
900
+ // mutate the entity record + re-spawn in place. Drag-resize from the
901
+ // editor lands here; the rebuild keeps NinePatch geometry correct.
902
+ const manifest = getManifest(hud);
903
+ const asset = manifest.assets?.find((a) => a.id === v.assetId);
904
+ if (asset?.ninePatch) {
905
+ for (const [k, val] of Object.entries(p)) {
906
+ if (val !== undefined)
907
+ v[k] = val;
908
+ }
909
+ const oldDepth = go.depth;
910
+ go.destroy();
911
+ reg.unregister(entityId);
912
+ const ctx = {
913
+ scene: hud,
914
+ registry: reg,
915
+ safeArea,
916
+ resolveAsset: (id) => {
917
+ const m = getManifest(hud);
918
+ const a = m.assets?.find((x) => x.id === id);
919
+ if (!a)
920
+ throw new Error(`[unboxy/hud] manifest has no asset with id '${id}'`);
921
+ return a;
922
+ },
923
+ };
924
+ const fresh = spawnHudEntity(ctx, entity);
925
+ if (typeof oldDepth === 'number') {
926
+ fresh.setDepth?.(oldDepth);
927
+ }
798
928
  }
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);
929
+ else {
930
+ const image = go;
931
+ if (typeof p.tint === 'string') {
932
+ v.tint = p.tint;
933
+ image.setTint(parseColor(v.tint));
934
+ }
935
+ else if (p.tint === null) {
936
+ v.tint = undefined;
937
+ image.clearTint();
938
+ }
939
+ if (typeof p.alpha === 'number') {
940
+ v.alpha = p.alpha;
941
+ image.setAlpha(p.alpha);
942
+ }
943
+ if (typeof p.width === 'number' && typeof p.height === 'number') {
944
+ v.width = p.width;
945
+ v.height = p.height;
946
+ image.setDisplaySize(p.width, p.height);
947
+ }
803
948
  }
804
949
  }
805
950
  if (patch.visual && (entity.kind === 'progress-bar' || entity.kind === 'panel')) {
@@ -847,6 +992,10 @@ export function applyHudPatch(game, entityId, patch) {
847
992
  v.fillColor = p.fillColor;
848
993
  if (typeof p.iconAssetId === 'string')
849
994
  v.iconAssetId = p.iconAssetId;
995
+ if (typeof p.backgroundAssetId === 'string')
996
+ v.backgroundAssetId = p.backgroundAssetId;
997
+ else if (p.backgroundAssetId === null)
998
+ v.backgroundAssetId = undefined;
850
999
  // Patches that change the rendered visual deeply (label, colour, icon)
851
1000
  // re-render by destroying + re-spawning the container in place.
852
1001
  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,6 +72,34 @@ export interface AssetRecord {
72
72
  animations?: SheetAnimation[];
73
73
  /** Default playback rate for `animations[]`. Defaults to 8. */
74
74
  fps?: number;
75
+ /**
76
+ * For `image`: 9-slice cut-line metadata. When present, HUD widgets that
77
+ * render this asset (image / icon-button background / panel background)
78
+ * scale via `phaser3-rex-plugins` NinePatch instead of plain stretched
79
+ * Image — corners stay fixed, edges + center stretch. Pack-import vision
80
+ * captures these on UI button/panel imports; users can edit in the Adjust
81
+ * modal at import time.
82
+ *
83
+ * <p>Single-image only in v1 (slice 7.5). Per-frame 9-slice on uniform-grid
84
+ * sheets (e.g. `Square Buttons 26x26.png`) is v2 — see
85
+ * `unboxy-design/features/visual-editor/07-asset-pack-import.md` §7.9.
86
+ */
87
+ ninePatch?: NinePatchConfig;
88
+ /**
89
+ * Vision-detected pixel-art flag. When true, SDK applies
90
+ * `Phaser.Textures.FilterMode.NEAREST` to the texture after load so it
91
+ * renders crisp instead of bilinear-blurred. Population is per-source:
92
+ * pack-import sets it via vision; AI generation / manual upload / library
93
+ * backfill close their own gaps separately (see design doc 07 §8.4).
94
+ */
95
+ pixelArt?: boolean;
96
+ }
97
+ /** Single-image 9-slice config. Pixels from each edge to the slice line. */
98
+ export interface NinePatchConfig {
99
+ leftWidth: number;
100
+ rightWidth: number;
101
+ topHeight: number;
102
+ bottomHeight: number;
75
103
  }
76
104
  export interface SheetAnimation {
77
105
  /** Phaser animation key — used as `sprite.play('<name>')`. */
@@ -345,7 +373,16 @@ export interface HudIconButtonVisual {
345
373
  label?: string;
346
374
  /** Optional icon asset shown inside the button. */
347
375
  iconAssetId?: string;
348
- /** Button shape. Default `rounded-rect`. */
376
+ /**
377
+ * Optional image asset rendered as the button background. When the
378
+ * referenced asset has `ninePatch` metadata, the SDK renders it via
379
+ * `phaser3-rex-plugins` NinePatch so corners stay fixed at any width/height;
380
+ * otherwise the image is stretched to the widget's size. When set, the
381
+ * colored-rect bg below (fillColor / strokeColor / shape) is ignored, and
382
+ * `pressedFillColor` no-ops (no fill swap on a textured bg in v1).
383
+ */
384
+ backgroundAssetId?: string;
385
+ /** Button shape. Default `rounded-rect`. Ignored when backgroundAssetId is set. */
349
386
  shape?: 'rounded-rect' | 'circle';
350
387
  width?: number;
351
388
  height?: number;
@@ -389,6 +426,15 @@ export interface HudPanelVisual {
389
426
  kind: 'panel';
390
427
  width?: number;
391
428
  height?: number;
429
+ /**
430
+ * Optional image asset rendered as the panel background. When the
431
+ * referenced asset has `ninePatch` metadata, the SDK renders it via
432
+ * `phaser3-rex-plugins` NinePatch so corners stay fixed at any width/height;
433
+ * otherwise the image is stretched to the widget's size. When set,
434
+ * `backgroundColor` / `backgroundAlpha` / `borderColor` / `borderWidth` are
435
+ * ignored — the asset image carries its own pixels.
436
+ */
437
+ backgroundAssetId?: string;
392
438
  /** Solid fill colour. Falls back to a transparent panel if omitted. */
393
439
  backgroundColor?: string;
394
440
  /** Background alpha (independent of widget-level alpha). */
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.37",
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": {