@unboxy/phaser-sdk 0.2.39 → 0.2.41

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.
@@ -101,37 +101,8 @@ const LAYER_DEPTH = {
101
101
  function createImageOrNinePatch(scene, x, y, width, height, asset, frame) {
102
102
  if (asset.ninePatch) {
103
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
- // Construct at a placeholder 1×1 size, then resize() to target. Reason:
111
- // Phaser 3.60+ refactored RenderTexture so that setSize() (which rex's
112
- // constructor calls) only updates the gameobject's display dimensions,
113
- // NOT the underlying DynamicTexture's canvas. The 9-slice draw pass
114
- // then draws into the default 32×32 canvas and only the top-left
115
- // corner of the patch is visible at 200×64 display. resize() — unlike
116
- // setSize() — does call DynamicTexture.setSize, but rex's resize early-
117
- // returns when current dims match target. Forcing initial dims of 1×1
118
- // ensures resize() to (width, height) does real work.
119
- let np;
120
- if (isPerFrame) {
121
- np = factory.call(scene.add, x, y, 1, 1, asset.textureKey, String(frame ?? 0), columns, rows);
122
- }
123
- else {
124
- np = factory.call(scene.add, x, y, 1, 1, asset.textureKey, columns, rows);
125
- }
126
- np.resize(width, height);
127
- return np;
128
- }
129
- // Plugin not registered — degrade to a stretched Image rather than crash
130
- // an entire HUD render. Symptom is "corners distort on resize", not a
131
- // missing widget. Likely cause: a host that bypassed createUnboxyGame's
132
- // auto-registration (e.g. an external Phaser.Game instance).
133
- // eslint-disable-next-line no-console
134
- console.warn(`[unboxy/hud] rexNinePatch plugin not registered; falling back to stretched Image for asset '${asset.id}'`);
104
+ const cuts = isPerFrameNinePatch(np) ? np.perFrame : np;
105
+ return createCustomNinePatch(scene, x, y, width, height, asset, cuts, frame);
135
106
  }
136
107
  // Fallback: plain Image. Frame index threaded through for spritesheet
137
108
  // assets so per-cell rendering still works without 9-slice.
@@ -141,6 +112,105 @@ function createImageOrNinePatch(scene, x, y, width, height, asset, frame) {
141
112
  image.setDisplaySize(width, height);
142
113
  return image;
143
114
  }
115
+ /**
116
+ * 9-slice renderer built directly on Phaser primitives. We tried
117
+ * `phaser3-rex-plugins`' NinePatch first (slice 7.5/7.6) but ran into a
118
+ * RenderTexture sizing bug in Phaser 3.60+ that's hard to work around
119
+ * without monkey-patching: rex's constructor calls `super(scene)` with no
120
+ * dims (defaulting the DynamicTexture to 32×32), then `setSize(w, h)` —
121
+ * which in Phaser 3.60+ only sets display dimensions, not the underlying
122
+ * texture. The 9-slice gets drawn into the small default canvas and
123
+ * displayed stretched. Reproducing this without rex is faster than the
124
+ * monkey-patch dance.
125
+ *
126
+ * <p>Algorithm: split the source texture into 9 sub-frames via
127
+ * `texture.add()` (idempotent — Phaser ignores re-adds), spawn one Phaser
128
+ * Image per sub-frame, position + stretch them inside a Container. Corners
129
+ * keep their native dimensions; edges stretch in one axis; the middle
130
+ * stretches in both. Returns the container, sized to the target dims with
131
+ * an explicit hit area so `setInteractive` works as expected on the
132
+ * icon-button widget.
133
+ *
134
+ * <p>Source layout: `(sx, sy)` is the source-image origin within the
135
+ * texture; for plain Images it's (0,0). For per-frame on a uniform-grid
136
+ * sheet, `frame` selects which cell — the corresponding frame's `cutX/Y`
137
+ * gives the origin.
138
+ */
139
+ function createCustomNinePatch(scene, x, y, width, height, asset, cuts, frame) {
140
+ const texKey = asset.textureKey;
141
+ const texture = scene.textures.get(texKey);
142
+ // Per-frame variant: look up the specified cell's source rect. Single
143
+ // image: the texture's `__BASE` frame covers the entire image.
144
+ const baseFrame = frame !== undefined
145
+ ? texture.get(frame)
146
+ : texture.get('__BASE');
147
+ const sx = baseFrame.cutX;
148
+ const sy = baseFrame.cutY;
149
+ const sw = baseFrame.width;
150
+ const sh = baseFrame.height;
151
+ const L = cuts.leftWidth;
152
+ const R = cuts.rightWidth;
153
+ const T = cuts.topHeight;
154
+ const B = cuts.bottomHeight;
155
+ // Source middle widths/heights (the part that stretches).
156
+ const midSrcW = Math.max(0, sw - L - R);
157
+ const midSrcH = Math.max(0, sh - T - B);
158
+ // Target middle widths/heights — what remains after the four corners are
159
+ // placed at their fixed sizes. When the widget is sized smaller than
160
+ // L+R / T+B, the corners overlap; rare but handled by clamping to 0
161
+ // rather than producing negative-size sprites.
162
+ const midDstW = Math.max(0, width - L - R);
163
+ const midDstH = Math.max(0, height - T - B);
164
+ const container = scene.add.container(x, y);
165
+ // Register a sub-frame on the source texture (idempotent) and add a
166
+ // Phaser Image rendering that frame at the target position + size.
167
+ // Position is relative to container's origin; Image origin (0, 0) makes
168
+ // (dx, dy) the top-left of the sub-region.
169
+ const frameSuffix = frame !== undefined ? `__f${frame}` : '';
170
+ const addSubFrame = (suffix, fx, fy, fw, fh, dx, dy, dw, dh) => {
171
+ if (fw <= 0 || fh <= 0 || dw <= 0 || dh <= 0)
172
+ return;
173
+ const subKey = `${texKey}${frameSuffix}_${suffix}`;
174
+ if (!texture.has(subKey)) {
175
+ texture.add(subKey, 0, sx + fx, sy + fy, fw, fh);
176
+ }
177
+ const img = scene.add.image(dx, dy, texKey, subKey);
178
+ img.setOrigin(0, 0);
179
+ img.setDisplaySize(dw, dh);
180
+ container.add(img);
181
+ };
182
+ // Layout offsets — container's local origin is at its center, so the
183
+ // 9-slice patch spans (-width/2, -height/2) to (width/2, height/2).
184
+ const x0 = -width / 2;
185
+ const y0 = -height / 2;
186
+ const xL = x0 + L;
187
+ const xR = x0 + width - R;
188
+ const yT = y0 + T;
189
+ const yB = y0 + height - B;
190
+ // Source frame offsets within the base frame.
191
+ const fxL = 0;
192
+ const fxM = L;
193
+ const fxR = sw - R;
194
+ const fyT = 0;
195
+ const fyM = T;
196
+ const fyB = sh - B;
197
+ // Top row
198
+ addSubFrame('tl', fxL, fyT, L, T, x0, y0, L, T);
199
+ addSubFrame('tm', fxM, fyT, midSrcW, T, xL, y0, midDstW, T);
200
+ addSubFrame('tr', fxR, fyT, R, T, xR, y0, R, T);
201
+ // Middle row
202
+ addSubFrame('ml', fxL, fyM, L, midSrcH, x0, yT, L, midDstH);
203
+ addSubFrame('mm', fxM, fyM, midSrcW, midSrcH, xL, yT, midDstW, midDstH);
204
+ addSubFrame('mr', fxR, fyM, R, midSrcH, xR, yT, R, midDstH);
205
+ // Bottom row
206
+ addSubFrame('bl', fxL, fyB, L, B, x0, yB, L, B);
207
+ addSubFrame('bm', fxM, fyB, midSrcW, B, xL, yB, midDstW, B);
208
+ addSubFrame('br', fxR, fyB, R, B, xR, yB, R, B);
209
+ // Explicit hit area so setInteractive works (Phaser containers have no
210
+ // intrinsic bounds; the icon-button widget calls setInteractive on this).
211
+ container.setSize(width, height);
212
+ return container;
213
+ }
144
214
  /**
145
215
  * Spawn a HUD widget into the scene. The returned GameObject is recorded in
146
216
  * the entity registry so the editor + behavior code can find it by id.
@@ -223,21 +293,29 @@ function createImage(ctx, entity, pos) {
223
293
  container.setSize(w, h);
224
294
  return container;
225
295
  }
226
- // 9-slice path. When the asset has `ninePatch` metadata the SDK renders via
227
- // rex-plugins NinePatch (corners fixed; edges + center stretch). NinePatch
228
- // requires explicit dims, so missing width/height falls back to the texture's
229
- // native source dims keeps existing data shapes (image-without-dims) working.
296
+ // 9-slice path. When the asset has `ninePatch` metadata, the SDK renders
297
+ // via our custom Container-based NinePatch (corners fixed; edges + center
298
+ // stretch). Missing width/height falls back to the texture's native source
299
+ // dims so existing image-without-dims data still works.
230
300
  if (asset.ninePatch) {
231
301
  const tex = ctx.scene.textures.get(asset.textureKey);
232
302
  const src = tex?.getSourceImage();
233
303
  const w = entity.visual.width ?? src?.width ?? 64;
234
304
  const h = entity.visual.height ?? src?.height ?? 64;
235
305
  const np = createImageOrNinePatch(ctx.scene, pos.x, pos.y, w, h, asset);
236
- if (entity.visual.tint)
237
- np.setTint?.(parseColor(entity.visual.tint));
238
- if (typeof entity.visual.alpha === 'number')
239
- np.setAlpha(entity.visual.alpha);
240
- applyOriginFromAnchor(np, entity.anchor.side);
306
+ if (typeof entity.visual.alpha === 'number') {
307
+ np.setAlpha?.(entity.visual.alpha);
308
+ }
309
+ // The custom NinePatch is a Container with children centered around
310
+ // (0, 0). Use the same origin-shift trick the container widgets use so
311
+ // the anchor side resolves correctly. Tint isn't supported on
312
+ // Container; image widgets needing tint should bypass ninePatch.
313
+ if (np instanceof Phaser.GameObjects.Container) {
314
+ applyContainerOriginShift(np, entity.anchor.side, w, h);
315
+ }
316
+ else {
317
+ applyOriginFromAnchor(np, entity.anchor.side);
318
+ }
241
319
  return np;
242
320
  }
243
321
  const image = entity.visual.frame !== undefined
@@ -1015,25 +1093,19 @@ export function applyHudPatch(game, entityId, patch) {
1015
1093
  }
1016
1094
  }
1017
1095
  if (patch.visual && entity.kind === 'icon-button') {
1018
- // Icon-button is a Container — visual patches require a small refresh.
1019
- // For v1 we just patch the entity record + label/colour fields cheaply;
1020
- // wholesale re-spawn lands in slice 5.5 if we add live re-styling.
1096
+ // Icon-button is a Container — visual patches re-spawn the container
1097
+ // in place. Use a generic loop to copy every patched field into the
1098
+ // entity record so we don't have to enumerate fields here every time
1099
+ // the schema grows. Older fields kept as explicit for clarity below
1100
+ // are now subsumed by this loop but left commented as a reminder.
1021
1101
  const v = entity.visual;
1022
1102
  const p = patch.visual;
1023
- if (typeof p.label === 'string')
1024
- v.label = p.label;
1025
- if (typeof p.fillColor === 'string')
1026
- v.fillColor = p.fillColor;
1027
- if (typeof p.iconAssetId === 'string')
1028
- v.iconAssetId = p.iconAssetId;
1029
- if (typeof p.backgroundAssetId === 'string')
1030
- v.backgroundAssetId = p.backgroundAssetId;
1031
- else if (p.backgroundAssetId === null)
1032
- v.backgroundAssetId = undefined;
1033
- if (typeof p.backgroundFrame === 'number')
1034
- v.backgroundFrame = p.backgroundFrame;
1035
- else if (p.backgroundFrame === null)
1036
- v.backgroundFrame = undefined;
1103
+ for (const [k, val] of Object.entries(p)) {
1104
+ if (val === null)
1105
+ delete v[k];
1106
+ else if (val !== undefined)
1107
+ v[k] = val;
1108
+ }
1037
1109
  // Patches that change the rendered visual deeply (label, colour, icon)
1038
1110
  // re-render by destroying + re-spawning the container in place.
1039
1111
  const reg2 = reg;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unboxy/phaser-sdk",
3
- "version": "0.2.39",
3
+ "version": "0.2.41",
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",