@energy8platform/game-engine 0.23.0 → 0.24.0

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.
Files changed (45) hide show
  1. package/dist/core.cjs.js +2 -2
  2. package/dist/core.cjs.js.map +1 -1
  3. package/dist/core.d.ts +3 -3
  4. package/dist/core.esm.js +2 -2
  5. package/dist/core.esm.js.map +1 -1
  6. package/dist/host.cjs.js +35 -10
  7. package/dist/host.cjs.js.map +1 -1
  8. package/dist/host.d.ts +22 -9
  9. package/dist/host.esm.js +24 -11
  10. package/dist/host.esm.js.map +1 -1
  11. package/dist/index.cjs.js +2 -2
  12. package/dist/index.cjs.js.map +1 -1
  13. package/dist/index.d.ts +3 -3
  14. package/dist/index.esm.js +2 -2
  15. package/dist/index.esm.js.map +1 -1
  16. package/dist/shell.cjs.js +4 -4
  17. package/dist/shell.d.ts +1 -1
  18. package/dist/shell.esm.js +1 -1
  19. package/dist/slot.cjs.js +226 -75
  20. package/dist/slot.cjs.js.map +1 -1
  21. package/dist/slot.d.ts +137 -19
  22. package/dist/slot.esm.js +224 -76
  23. package/dist/slot.esm.js.map +1 -1
  24. package/package.json +2 -2
  25. package/src/core/GameApplication.ts +3 -3
  26. package/src/host/createSlotGame.ts +6 -3
  27. package/src/host/index.ts +11 -1
  28. package/src/host/shellConfig.ts +22 -12
  29. package/src/host/types.ts +13 -2
  30. package/src/shell/index.ts +4 -3
  31. package/src/slot/cascade/TumbleController.ts +6 -3
  32. package/src/slot/config/ReelSystemConfig.ts +19 -0
  33. package/src/slot/features/extra.ts +1 -2
  34. package/src/slot/features/symbols.ts +10 -10
  35. package/src/slot/features/types.ts +13 -5
  36. package/src/slot/features/wilds.ts +1 -1
  37. package/src/slot/grid/AnimatedSymbol.ts +20 -11
  38. package/src/slot/grid/ReelGrid.ts +80 -41
  39. package/src/slot/grid/SymbolCell.ts +14 -7
  40. package/src/slot/grid/SymbolView.ts +2 -2
  41. package/src/slot/grid/geometry.ts +155 -0
  42. package/src/slot/index.ts +3 -0
  43. package/src/slot/motion/SpinEngine.ts +1 -1
  44. package/src/slot/system/ReelSystem.ts +10 -0
  45. package/src/types.ts +1 -1
@@ -6,7 +6,7 @@ import { loadFonts, applyTextureDefaults, bootGuard } from './preboot';
6
6
  import { showFatalError, installGlobalErrorHandlers } from './fatalError';
7
7
  import type { CreateSlotGameOptions, SlotGameHandle } from './types';
8
8
  import type { SlotSpinResultBase } from '@energy8platform/platform-core/slot-result';
9
- import type { ShellMode } from '@energy8platform/pixi-shell';
9
+ import type { ShellMode } from '@energy8platform/shell/pixi';
10
10
  import type { SceneApi, SlotSceneController } from './sceneController';
11
11
 
12
12
  /**
@@ -143,7 +143,7 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
143
143
  });
144
144
 
145
145
  if (opts.shell) {
146
- const { createPixiShell } = await import('@energy8platform/pixi-shell');
146
+ const { createPixiShell } = await import('@energy8platform/shell/pixi');
147
147
  const { buildShellConfig } = await import('./shellConfig');
148
148
  const { resolveReplayBonusId } = await import('./replay');
149
149
 
@@ -206,7 +206,10 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
206
206
  // pixi-shell mounts its root onto the engine's unscaled, screen-space UI layer (above the
207
207
  // scaled world/scene root) so the control bar fills the real screen, not the letterboxed game.
208
208
  // The host adds the mount target (`app`) + parent; buildShellConfig produces everything else.
209
- shell = createPixiShell({ ...buildShellConfig(opts.shell, opts.model, runtime), app: game.app, parent: game.uiLayer });
209
+ const pixiShellCfg: import('@energy8platform/shell/pixi').PixiShellConfig = { ...buildShellConfig(opts.shell, opts.model, runtime), app: game.app, parent: game.uiLayer };
210
+ // The game may swap in its own shell (a custom renderer over the same core) via shellFactory;
211
+ // default is the built-in Pixi shell. The host drives whichever it gets through the Shell contract.
212
+ shell = (opts.shellFactory ?? createPixiShell)(pixiShellCfg);
210
213
  // Scope the bar to the slot scene: show only when a SlotSceneController scene is current
211
214
  // (hidden over the intro / non-slot scenes). Applies in BOTH base and replay modes.
212
215
  shell.setVisible(!!gameScene());
package/src/host/index.ts CHANGED
@@ -6,7 +6,17 @@ export type {
6
6
  StakeIntegration,
7
7
  SceneRegistration,
8
8
  SceneNavData,
9
+ ShellFactory,
9
10
  } from './types';
11
+
12
+ // Shell contract for authors plugging a custom renderer via createSlotGame({ shellFactory }).
13
+ // Implement `ShellRenderer`, build the shell with `createShell({ renderer, ...config })`, and the
14
+ // shell core drives bet/balance/overlays unchanged. `createPixiShell`/`PixiRenderer` are the built-in.
15
+ export { createShell, createPixiShell, PixiRenderer } from '@energy8platform/shell/pixi';
16
+ export type {
17
+ Shell, ShellRenderer, ShellSurface, SafeArea, ShellHost, ShellActions, ShellTokens,
18
+ ShellLayoutMode, OverlayRequest, OverlayHandle, ResolvedShellConfig, PixiShellConfig,
19
+ } from '@energy8platform/shell/pixi';
10
20
  export { buildShellConfig, stakeForAction } from './shellConfig';
11
21
  export type { SlotShellOptions } from './shellConfig';
12
22
  export { resolveReplayBonusId } from './replay';
@@ -17,4 +27,4 @@ export type {
17
27
  } from './sceneController';
18
28
  // Social-casino word-swap. The shell auto-socializes all gameInfo/buyBonus text in social mode;
19
29
  // authors only need this to socialize strings they render themselves (e.g. inside a custom DOM node).
20
- export { socialize } from '@energy8platform/platform-core/shell';
30
+ export { socialize } from '@energy8platform/shell';
@@ -1,12 +1,14 @@
1
1
  // packages/game-engine/src/host/shellConfig.ts
2
- // `socialize` / `createI18n` are runtime helpers sourced from platform-core/shell;
3
- // pixi-shell re-exports only types so we import directly from platform-core.
4
- import { socialize, createI18n } from '@energy8platform/platform-core/shell';
5
- import type { Lang } from '@energy8platform/platform-core/shell';
2
+ // `socialize` / `createI18n` / `Lang` are renderer-agnostic helpers from the shell core
3
+ // (@energy8platform/shell). Renderer-agnostic types (GameInfoSection/Content, PaytableRow,
4
+ // GameMode) and the pixi-specific surface types come from the @energy8platform/shell/pixi entry.
5
+ import { socialize, createI18n } from '@energy8platform/shell';
6
+ import type { Lang } from '@energy8platform/shell';
7
+ import type { GameInfoContent, GameInfoSection, PaytableRow, GameMode } from '@energy8platform/shell/pixi';
6
8
  import type {
7
- PixiShellConfig, ShellMode, CurrencyConfig, GameInfoContent, GameInfoSection, PaytableRow,
8
- BonusOption, ShellFeatures, GameMode,
9
- } from '@energy8platform/pixi-shell';
9
+ PixiShellConfig, ShellMode, CurrencyConfig,
10
+ BonusOption, ShellFeatures,
11
+ } from '@energy8platform/shell/pixi';
10
12
  import type { GameModel } from '@energy8platform/platform-core/game-spec';
11
13
  import type { WinTier } from '../slot';
12
14
 
@@ -276,20 +278,26 @@ function sectionKey(s: GameInfoSection): string {
276
278
  */
277
279
  export function mergeGameInfo(derived: GameInfoContent, override?: GameInfoContent): GameInfoContent {
278
280
  if (!override) return derived;
281
+ // `GameInfoContent.sections` is typed with the core GameInfoSection (node?: unknown) because
282
+ // shell/pixi re-exports GameInfoContent unchanged from core. Host-built sections never set `node`,
283
+ // so they are structurally compatible with pixi's GameInfoSection (node?: Container). Cast once
284
+ // here so the rest of the function works against the pixi-widened type.
285
+ const derivedSections = (derived.sections ?? []) as GameInfoSection[];
286
+ const overrideSections = (override.sections ?? []) as GameInfoSection[];
279
287
  const authorByKey = new Map<string, GameInfoSection>();
280
- for (const s of override.sections ?? []) authorByKey.set(sectionKey(s), s);
288
+ for (const s of overrideSections) authorByKey.set(sectionKey(s), s);
281
289
 
282
290
  const out: GameInfoSection[] = [];
283
291
  const used = new Set<string>();
284
292
  // Keep derived order; swap in the author's version where identities collide.
285
- for (const s of derived.sections ?? []) {
293
+ for (const s of derivedSections) {
286
294
  const k = sectionKey(s);
287
295
  const replacement = authorByKey.get(k);
288
296
  if (replacement) { out.push(replacement); used.add(k); }
289
297
  else out.push(s);
290
298
  }
291
299
  // Append author sections whose identity wasn't in the derived set, in author order.
292
- for (const s of override.sections ?? []) {
300
+ for (const s of overrideSections) {
293
301
  const k = sectionKey(s);
294
302
  if (!used.has(k)) { out.push(s); used.add(k); }
295
303
  }
@@ -340,7 +348,7 @@ export function buildShellConfig(
340
348
  opts: SlotShellOptions,
341
349
  model: GameModel,
342
350
  runtime: ShellRuntime,
343
- ): Omit<PixiShellConfig, 'app' | 'parent'> {
351
+ ): Omit<PixiShellConfig, 'app' | 'parent' | 'gameInfo'> & { gameInfo: GameInfoContent } {
344
352
  // Prefer the currency-specific ladder from /wallet/authenticate; fall back to the spec (dev/devBridge).
345
353
  const betLevels = runtime.betLevels?.length ? runtime.betLevels : model.spec.betLevels;
346
354
  // Stake requires the default to come from authenticate on every entry; spec default is the dev fallback.
@@ -360,7 +368,9 @@ export function buildShellConfig(
360
368
  const authored = typeof opts.gameInfo === 'function' ? opts.gameInfo(t) : opts.gameInfo;
361
369
  // Pass t() through to spec-derived sections so symbol names, mode titles, and descriptions are
362
370
  // pre-translated before they reach the shell renderer.
363
- let gameInfo = mergeGameInfo(defaultGameInfo(model, runtime, t), authored);
371
+ // Cast to { sections?: GameInfoSection[] } (pixi-widened) so downstream map/filter calls work
372
+ // against the pixi section union. Host-derived sections never set `node`, so the widening is safe.
373
+ let gameInfo: { sections?: GameInfoSection[] } = mergeGameInfo(defaultGameInfo(model, runtime, t), authored) as { sections?: GameInfoSection[] };
364
374
  // The DISCLAIMER is required legal copy and must be shown VERBATIM — never socialized (its
365
375
  // wording is mandated, and word-swaps like "bet → play" would corrupt the legal text).
366
376
  if (isSocial) {
package/src/host/types.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  import type { ApplicationOptions } from 'pixi.js';
3
3
  import type { GameModel } from '@energy8platform/platform-core/game-spec';
4
4
  import type { AssetManifest, LoadingScreenConfig } from '@energy8platform/platform-core';
5
- import type { PixiGameShell } from '@energy8platform/pixi-shell';
5
+ import type { Shell, PixiShellConfig } from '@energy8platform/shell/pixi';
6
6
  import type { AudioConfig, ScaleMode, Orientation, SceneConstructor } from '../types';
7
7
  import type { BookAdapter, AdapterModule, StakeBridge } from '@energy8platform/stake-bridge';
8
8
  import type { GameApplication } from '../core';
@@ -58,14 +58,25 @@ export interface CreateSlotGameOptions<T extends SlotSpinResultBase = SlotSpinRe
58
58
  dev?: boolean;
59
59
  stake?: StakeIntegration;
60
60
  shell?: SlotShellOptions;
61
+ /** Override how the control-bar shell is built. The host resolves the full shell config (theme,
62
+ * features, gameInfo, currency, balance) and the Pixi mount (`app`/`parent`) and hands it to this
63
+ * factory; return any `Shell` — e.g. `createShell({ renderer: new MyRenderer(...), ...config })`
64
+ * to plug a custom renderer while the shell core still drives bet/balance/overlays. A custom
65
+ * renderer can ignore `app`/`parent` and mount elsewhere (a DOM overlay, another canvas).
66
+ * Default: the built-in Pixi shell (`createPixiShell`). */
67
+ shellFactory?: ShellFactory;
61
68
  /** Double-tap on the play area to skip the current spin animation. Default `true`. Set `false`
62
69
  * to disable the gesture (e.g. games where a tap means something else). */
63
70
  skipGesture?: boolean;
64
71
  onFatalError?: (message: string) => void;
65
72
  }
66
73
 
74
+ /** Builds the shell the host drives. Receives the fully-resolved Pixi shell config (a custom
75
+ * renderer may ignore the `app`/`parent` mount fields). Must return a `Shell`. */
76
+ export type ShellFactory = (config: PixiShellConfig) => Shell;
77
+
67
78
  export interface SlotGameHandle {
68
79
  game: GameApplication;
69
80
  stakeBridge: StakeBridge | null;
70
- shell: PixiGameShell | null;
81
+ shell: Shell | null;
71
82
  }
@@ -1,10 +1,10 @@
1
- // Re-export the renderer-agnostic branded game shell from platform-core so
1
+ // Re-export the renderer-agnostic branded game shell from @energy8platform/shell/html so
2
2
  // game-engine consumers can import it via @energy8platform/game-engine/shell.
3
3
  export {
4
4
  createGameShell,
5
5
  removeGameShell,
6
6
  GameShell,
7
- } from '@energy8platform/platform-core/shell';
7
+ } from '@energy8platform/shell/html';
8
8
  export type {
9
9
  ShellConfig,
10
10
  ShellMode,
@@ -17,4 +17,5 @@ export type {
17
17
  GameInfoContent,
18
18
  AutoplayOptions,
19
19
  FreeSpinsState,
20
- } from '@energy8platform/platform-core/shell';
20
+ Lang,
21
+ } from '@energy8platform/shell/html';
@@ -147,8 +147,11 @@ export class TumbleController {
147
147
  }
148
148
  this._undim();
149
149
 
150
- // 3. gravity: survivors slide down, new cells drop from above
151
- const rowStep = this._grid.cellPosition(0, 1).y - this._grid.cellPosition(0, 0).y;
150
+ // 3. gravity: survivors slide down, new cells drop from above (per-reel row step)
151
+ const rowStepOf = (col: number): number =>
152
+ this._grid.rowsOf(col) > 1
153
+ ? this._grid.cellPosition(col, 1).y - this._grid.cellPosition(col, 0).y
154
+ : this._grid.cellSize(col).height;
152
155
  const slides = this._cfg.gravity ? (step.drops ?? this.deriveDrops(step)) : [];
153
156
  const anims: Promise<void>[] = [];
154
157
 
@@ -172,7 +175,7 @@ export class TumbleController {
172
175
  cell.setState({ fresh: true });
173
176
  cell.alpha = 1;
174
177
  cell.scale.set(1);
175
- cell.position.set(home.x, home.y - rowStep * (this._grid.rowsOf(n.col) + 1));
178
+ cell.position.set(home.x, home.y - rowStepOf(n.col) * (this._grid.rowsOf(n.col) + 1));
176
179
  const idx = (perCol[n.col] = (perCol[n.col] ?? 0) + 1);
177
180
  anims.push(
178
181
  (async () => {
@@ -8,6 +8,9 @@
8
8
  // Design notes are in docs/reels-analysis-and-design.md.
9
9
 
10
10
  import type { CellFrameStyle } from '../grid/SymbolCell';
11
+ import { resolveGeometry, type CellSizeSpec, type ResolvedGeometry } from '../grid/geometry';
12
+
13
+ export type { CellSizeSpec, ResolvedGeometry };
11
14
 
12
15
  /** Names of easing functions available in the engine's `Easing` map (see anim/easing-map.ts). */
13
16
  export type EasingName =
@@ -41,8 +44,19 @@ export interface GridConfig {
41
44
  rows: number;
42
45
  /** Per-reel row counts (Megaways / variable-height reels). Length should equal `cols`. */
43
46
  rowsPerReel?: number[];
47
+ /** Square cell size (shorthand: same width & height, all reels). */
44
48
  cellSize: number;
49
+ /** Rectangular cells, uniform across reels. Override `cellSize` when set. */
50
+ cellWidth?: number;
51
+ cellHeight?: number;
52
+ /** Per-strip cell size (square scalar or {width,height}). Overrides the above for that reel. */
53
+ cellSizePerReel?: CellSizeSpec[];
54
+ /** Uniform gap (shorthand for both axes). */
45
55
  gap: number;
56
+ /** Horizontal gap between adjacent reels. Scalar, or per-boundary (length cols-1). Overrides `gap`. */
57
+ colGap?: number | number[];
58
+ /** Vertical gap between rows. Scalar, or per-reel (length cols). Overrides `gap`. */
59
+ rowGap?: number | number[];
46
60
  evaluation: EvaluationMode;
47
61
  /** For Megaways: clamp per-reel rows to [minRows, maxRows]. */
48
62
  minRows?: number;
@@ -553,6 +567,11 @@ export function effectiveRowsPerReel(grid: GridConfig): number[] {
553
567
  return Array.from({ length: grid.cols }, () => grid.rows);
554
568
  }
555
569
 
570
+ /** Resolve a `GridConfig` into a fully-populated per-reel geometry (rectangular / per-strip aware). */
571
+ export function resolveGridGeometry(grid: GridConfig): ResolvedGeometry {
572
+ return resolveGeometry(grid);
573
+ }
574
+
556
575
  /** Total ways-to-win for a ways/megaways grid (product of per-reel heights). */
557
576
  export function waysCount(grid: GridConfig): number {
558
577
  return effectiveRowsPerReel(grid).reduce((a, b) => a * b, 1);
@@ -46,8 +46,7 @@ export const MultiplierSymbols: ReelFeature = {
46
46
  await floatLabel(ctx.fx, x, y, `×${values[spots.indexOf(p)]}`, 0xffd24a, 500);
47
47
  }),
48
48
  );
49
- const cx = (ctx.grid.cols * ctx.grid.cellSize) / 2 - ctx.grid.cellSize / 2;
50
- const cy = (ctx.grid.rows * ctx.grid.cellSize) / 2 - ctx.grid.cellSize / 2;
49
+ const { x: cx, y: cy } = ctx.grid.center();
51
50
  await floatLabel(ctx.fx, cx, cy, `×${capped}`, 0xffe24a, 900);
52
51
  },
53
52
  };
@@ -6,6 +6,7 @@ import {
6
6
  type FeatureContext,
7
7
  type ReelFeature,
8
8
  cellCenter,
9
+ colStepOf,
9
10
  floatLabel,
10
11
  morphSymbol,
11
12
  pickFromBoard,
@@ -96,17 +97,15 @@ export const GiantSymbol: ReelFeature = {
96
97
  ctx.log?.(`Giant ${w}×${h} "${sym}"`);
97
98
  const view = ctx.resolve(sym);
98
99
  if (!view) return;
99
- const step = rowStepOf(ctx.grid);
100
+ const cs = ctx.grid.cellSize(anchorCol);
101
+ const stepX = colStepOf(ctx.grid, anchorCol);
102
+ const step = rowStepOf(ctx.grid, anchorCol);
100
103
  const tl = cellCenter(ctx.grid, anchorCol, 0);
101
104
  const giant = new Container();
102
105
  giant.addChild(view);
103
- view.resize?.(ctx.grid.cellSize * Math.max(w, h));
104
- if (view.scale)
105
- view.scale.set(
106
- ((ctx.grid.cellSize * w) / (ctx.grid.cellSize * Math.max(w, h))) * view.scale.x,
107
- ((ctx.grid.cellSize * h) / (ctx.grid.cellSize * Math.max(w, h))) * view.scale.y,
108
- );
109
- giant.position.set(tl.x + ((w - 1) * step) / 2, tl.y + ((h - 1) * step) / 2);
106
+ // span w×h cells (footprint ignores gaps, matching the previous single-cell unit)
107
+ view.resize?.({ width: cs.width * w, height: cs.height * h });
108
+ giant.position.set(tl.x + ((w - 1) * stepX) / 2, tl.y + ((h - 1) * step) / 2);
110
109
  // hide covered cells
111
110
  for (let c = anchorCol; c < anchorCol + w; c++)
112
111
  for (let r = 0; r < h; r++) ctx.grid.getCell(c, r).visible = false;
@@ -143,11 +142,12 @@ export const SplitSymbol: ReelFeature = {
143
142
  left.map(async (p, i) => {
144
143
  await Tween.delay((i % 6) * 30);
145
144
  const { x, y } = cellCenter(ctx.grid, p.col, p.row);
145
+ const cs = ctx.grid.cellSize(p.col);
146
146
  await pulseCell(ctx.grid.getCell(p.col, p.row), 1.12, 200);
147
147
  await floatLabel(
148
148
  ctx.fx,
149
- x + ctx.grid.cellSize / 3,
150
- y - ctx.grid.cellSize / 3,
149
+ x + cs.width / 3,
150
+ y - cs.height / 3,
151
151
  `×${f.factor}`,
152
152
  0x9b5cff,
153
153
  600,
@@ -43,8 +43,16 @@ export function cellCenter(grid: ReelGrid, col: number, row: number): { x: numbe
43
43
  return grid.cellPosition(col, row);
44
44
  }
45
45
 
46
- export function rowStepOf(grid: ReelGrid): number {
47
- return grid.cellPosition(0, 1).y - grid.cellPosition(0, 0).y;
46
+ /** Vertical row-to-row step for a reel (falls back to the reel's cell height for 1-row reels). */
47
+ export function rowStepOf(grid: ReelGrid, col = 0): number {
48
+ if (grid.rowsOf(col) > 1) return grid.cellPosition(col, 1).y - grid.cellPosition(col, 0).y;
49
+ return grid.cellSize(col).height;
50
+ }
51
+
52
+ /** Horizontal reel-to-reel step at a boundary (falls back to the reel's cell width for 1-col grids). */
53
+ export function colStepOf(grid: ReelGrid, col = 0): number {
54
+ if (col + 1 < grid.cols) return grid.cellPosition(col + 1, 0).x - grid.cellPosition(col, 0).x;
55
+ return grid.cellSize(col).width;
48
56
  }
49
57
 
50
58
  /** A glowing ring drawn around a cell. Returns a disposer. */
@@ -57,9 +65,9 @@ export function glowRing(
57
65
  ): () => void {
58
66
  if (fx.destroyed) return () => {};
59
67
  const { x, y } = cellCenter(grid, col, row);
60
- const s = grid.cellSize;
68
+ const { width, height } = grid.cellSize(col);
61
69
  const g = new Graphics()
62
- .roundRect(x - s / 2, y - s / 2, s, s, 10)
70
+ .roundRect(x - width / 2, y - height / 2, width, height, 10)
63
71
  .stroke({ color, width: 3, alpha: 0.9 });
64
72
  fx.addChild(g);
65
73
  return () => g.destroy();
@@ -118,7 +126,7 @@ export async function dropCell(
118
126
  ): Promise<void> {
119
127
  if (cell.destroyed) return;
120
128
  const home = grid.cellPosition(col, row);
121
- cell.position.set(home.x, home.y - rowStepOf(grid) * (row + 2));
129
+ cell.position.set(home.x, home.y - rowStepOf(grid, col) * (row + 2));
122
130
  cell.alpha = 1;
123
131
  cell.scale.set(1);
124
132
  await Tween.to(cell, { 'position.y': home.y }, ms, easingByName('easeOutBounce'));
@@ -69,7 +69,7 @@ export const StickySymbols: ReelFeature = {
69
69
  await floatLabel(
70
70
  ctx.fx,
71
71
  x,
72
- y - ctx.grid.cellSize / 2,
72
+ y - ctx.grid.cellSize(p.col).height / 2,
73
73
  f.durationSpins ? `STICKY ${f.durationSpins}` : 'STICKY',
74
74
  f.ringColor,
75
75
  600 + i * 50,
@@ -3,25 +3,32 @@ import { SpriteAnimation } from '../../animation';
3
3
  import type { SymbolView } from './SymbolView';
4
4
 
5
5
  export interface SymbolTextures { base: Texture; idle?: Texture[]; win?: Texture[]; }
6
- export interface AnimatedSymbolConfig { textures: SymbolTextures; size: number; fps?: number; }
6
+ export interface AnimatedSymbolConfig {
7
+ textures: SymbolTextures;
8
+ size: number | { width: number; height: number };
9
+ fps?: number;
10
+ }
11
+
12
+ const dims = (size: number | { width: number; height: number }) =>
13
+ typeof size === 'number' ? { width: size, height: size } : size;
7
14
 
8
15
  /** Built-in SymbolView: a static base sprite with optional idle/win spritesheet frames. */
9
16
  export class AnimatedSymbol extends Container implements SymbolView {
10
17
  private _base: Sprite;
11
18
  private _anim: AnimatedSprite | null = null;
12
19
  private _textures: SymbolTextures;
13
- private _size: number;
20
+ private _w = 0;
21
+ private _h = 0;
14
22
  private _fps: number;
15
23
 
16
24
  constructor(config: AnimatedSymbolConfig) {
17
25
  super();
18
26
  this._textures = config.textures;
19
- this._size = config.size;
20
27
  this._fps = config.fps ?? 24;
21
28
  this._base = new Sprite(config.textures.base);
22
29
  this._base.anchor.set(0.5);
23
30
  this.addChild(this._base);
24
- this.resize(this._size);
31
+ this.resize(config.size);
25
32
  }
26
33
 
27
34
  setTextures(t: SymbolTextures): void {
@@ -30,11 +37,13 @@ export class AnimatedSymbol extends Container implements SymbolView {
30
37
  this.showStatic();
31
38
  }
32
39
 
33
- resize(size: number): void {
34
- this._size = size;
35
- this._base.width = size;
36
- this._base.height = size;
37
- if (this._anim) { this._anim.width = size; this._anim.height = size; }
40
+ resize(size: number | { width: number; height: number }): void {
41
+ const { width, height } = dims(size);
42
+ this._w = width;
43
+ this._h = height;
44
+ this._base.width = width;
45
+ this._base.height = height;
46
+ if (this._anim) { this._anim.width = width; this._anim.height = height; }
38
47
  }
39
48
 
40
49
  showStatic(): void {
@@ -59,8 +68,8 @@ export class AnimatedSymbol extends Container implements SymbolView {
59
68
  this._base.visible = false;
60
69
  const a = SpriteAnimation.create(frames, { loop, autoPlay: true, onComplete });
61
70
  a.anchor.set(0.5);
62
- a.width = this._size;
63
- a.height = this._size;
71
+ a.width = this._w;
72
+ a.height = this._h;
64
73
  a.animationSpeed = this._fps / 60;
65
74
  this.addChild(a);
66
75
  this._anim = a;
@@ -1,6 +1,12 @@
1
1
  import { Container, Graphics, Sprite, type Texture } from 'pixi.js';
2
2
  import { SymbolCell, type CellData, type CellFrameStyle } from './SymbolCell';
3
3
  import type { SymbolResolver } from './SymbolView';
4
+ import {
5
+ resolveGeometry,
6
+ cellPositionOf,
7
+ type CellSizeSpec,
8
+ type ResolvedGeometry,
9
+ } from './geometry';
4
10
 
5
11
  export interface DecorationConfig {
6
12
  texture?: Texture;
@@ -12,8 +18,19 @@ export interface ReelGridConfig {
12
18
  rows: number;
13
19
  /** Per-reel row counts for Megaways / variable-height reels. Length must equal `cols`. */
14
20
  rowsPerReel?: number[];
21
+ /** Square cell size (shorthand: same width & height, all reels). */
15
22
  cellSize: number;
23
+ /** Rectangular cells, uniform across reels. Override `cellSize` when set. */
24
+ cellWidth?: number;
25
+ cellHeight?: number;
26
+ /** Per-strip cell size (square scalar or {width,height}). */
27
+ cellSizePerReel?: CellSizeSpec[];
28
+ /** Uniform gap (shorthand for both axes). */
16
29
  gap?: number;
30
+ /** Horizontal gap between adjacent reels. Scalar, or per-boundary (length cols-1). */
31
+ colGap?: number | number[];
32
+ /** Vertical gap between rows. Scalar, or per-reel (length cols). */
33
+ rowGap?: number | number[];
17
34
  resolve: SymbolResolver;
18
35
  frameStyle?: CellFrameStyle;
19
36
  decoration?: DecorationConfig;
@@ -21,17 +38,16 @@ export interface ReelGridConfig {
21
38
  }
22
39
 
23
40
  /**
24
- * A grid of `SymbolCell`s. Supports uniform grids and variable-height reels (Megaways):
25
- * when `rowsPerReel` is set each reel is vertically centred inside the tallest reel's envelope.
41
+ * A grid of `SymbolCell`s. Supports uniform grids, variable-height reels (Megaways), and
42
+ * rectangular / per-strip cell sizes with per-strip gaps. All layout flows through a single
43
+ * resolved geometry (see grid/geometry.ts); every consumer reads positions via `cellPosition`
44
+ * and dimensions via `cellSize(col)` rather than assuming a square cell.
26
45
  */
27
46
  export class ReelGrid extends Container {
28
47
  readonly __uiComponent = true as const;
29
48
 
30
- private _cols: number;
31
- private _rowsPerReel: number[];
32
- private _maxRows: number;
33
- private _cellSize: number;
34
- private _gap: number;
49
+ private _cfg: ReelGridConfig;
50
+ private _geom: ResolvedGeometry;
35
51
  private _cells: SymbolCell[][] = [];
36
52
  private _cellLayer = new Container();
37
53
  private _resolve: SymbolResolver;
@@ -40,28 +56,18 @@ export class ReelGrid extends Container {
40
56
 
41
57
  constructor(config: ReelGridConfig) {
42
58
  super();
43
- this._cols = config.cols;
44
- this._rowsPerReel =
45
- config.rowsPerReel && config.rowsPerReel.length === config.cols
46
- ? config.rowsPerReel.slice()
47
- : Array.from({ length: config.cols }, () => config.rows);
48
- this._maxRows = Math.max(1, ...this._rowsPerReel);
49
- this._cellSize = config.cellSize;
50
- this._gap = config.gap ?? 0;
59
+ this._cfg = { ...config };
51
60
  this._resolve = config.resolve;
52
61
  this._frameStyle = config.frameStyle;
62
+ this._geom = resolveGeometry(config);
53
63
 
54
- if (config.decoration) {
64
+ if (config.decoration?.texture) {
55
65
  const pad = config.decoration.padding ?? 0;
56
- const w = this._cols * (this._cellSize + this._gap) - this._gap + pad * 2;
57
- const h = this._maxRows * (this._cellSize + this._gap) - this._gap + pad * 2;
58
- if (config.decoration.texture) {
59
- const deco = new Sprite(config.decoration.texture);
60
- deco.width = w;
61
- deco.height = h;
62
- deco.position.set(-pad - this._cellSize / 2, -pad - this._cellSize / 2);
63
- this.addChild(deco);
64
- }
66
+ const deco = new Sprite(config.decoration.texture);
67
+ deco.width = this._geom.gridW + pad * 2;
68
+ deco.height = this._geom.gridH + pad * 2;
69
+ deco.position.set(this._geom.leftX - pad, this._geom.topY - pad);
70
+ this.addChild(deco);
65
71
  }
66
72
 
67
73
  this.addChild(this._cellLayer);
@@ -70,12 +76,19 @@ export class ReelGrid extends Container {
70
76
  if (config.mask) this._applyMask();
71
77
  }
72
78
 
79
+ private get _cols(): number {
80
+ return this._geom.cols;
81
+ }
82
+ private get _rowsPerReel(): number[] {
83
+ return this._geom.rowsPerReel;
84
+ }
85
+
73
86
  private _buildCells(): void {
74
87
  for (let c = 0; c < this._cols; c++) {
75
88
  this._cells[c] = [];
76
89
  for (let r = 0; r < this._rowsPerReel[c]; r++) {
77
90
  const cell = new SymbolCell({
78
- size: this._cellSize,
91
+ size: this.cellSize(c),
79
92
  resolve: this._resolve,
80
93
  frameStyle: this._frameStyle,
81
94
  });
@@ -87,10 +100,19 @@ export class ReelGrid extends Container {
87
100
  }
88
101
  }
89
102
 
103
+ /** Per-strip mask: one window per reel sized to that reel's own cell width × column height. */
90
104
  private _applyMask(): void {
91
- const w = this._cols * (this._cellSize + this._gap) - this._gap;
92
- const h = this._maxRows * (this._cellSize + this._gap) - this._gap;
93
- const m = new Graphics().rect(-this._cellSize / 2, -this._cellSize / 2, w, h).fill(0xffffff);
105
+ const g = this._geom;
106
+ const m = new Graphics();
107
+ for (let c = 0; c < this._cols; c++) {
108
+ const rows = this._rowsPerReel[c];
109
+ const w = g.cellW[c];
110
+ const h = rows * g.cellH[c] + Math.max(0, rows - 1) * g.rowGap[c];
111
+ const x = g.colX[c] - w / 2;
112
+ const y = g.yOff[c] - g.cellH[c] / 2;
113
+ m.rect(x, y, w, h);
114
+ }
115
+ m.fill(0xffffff);
94
116
  this._cellLayer.mask = m;
95
117
  this.addChild(m);
96
118
  this._mask = m;
@@ -101,10 +123,15 @@ export class ReelGrid extends Container {
101
123
  }
102
124
  /** Tallest reel's row count (the grid's visual height in rows). */
103
125
  get rows(): number {
104
- return this._maxRows;
126
+ return this._geom.maxRows;
127
+ }
128
+ /** Cell dimensions (px) for a reel. Rectangular / per-strip aware. */
129
+ cellSize(col: number): { width: number; height: number } {
130
+ return { width: this._geom.cellW[col] ?? 0, height: this._geom.cellH[col] ?? 0 };
105
131
  }
106
- get cellSize(): number {
107
- return this._cellSize;
132
+ /** The fully-resolved grid geometry (read-only view). */
133
+ get geometry(): ResolvedGeometry {
134
+ return this._geom;
108
135
  }
109
136
  rowsOf(col: number): number {
110
137
  return this._rowsPerReel[col] ?? 0;
@@ -113,12 +140,14 @@ export class ReelGrid extends Container {
113
140
  return this._rowsPerReel.slice();
114
141
  }
115
142
 
116
- /** Cell centre position. Variable-height reels are centred in the max-rows envelope. */
143
+ /** Cell centre position. Variable-height reels are centred about a shared centre line. */
117
144
  cellPosition(col: number, row: number): { x: number; y: number } {
118
- const step = this._cellSize + this._gap;
119
- const reelRows = this._rowsPerReel[col] ?? this._maxRows;
120
- const yOffset = ((this._maxRows - reelRows) / 2) * step;
121
- return { x: col * step, y: yOffset + row * step };
145
+ return cellPositionOf(this._geom, col, row);
146
+ }
147
+
148
+ /** Grid bounding-box centre in local coords. */
149
+ center(): { x: number; y: number } {
150
+ return { x: this._geom.centerX, y: this._geom.centerY };
122
151
  }
123
152
 
124
153
  getCell(col: number, row: number): SymbolCell {
@@ -138,8 +167,8 @@ export class ReelGrid extends Container {
138
167
  if (rowsPerReel.length !== this._cols) return;
139
168
  this._cellLayer.removeChildren().forEach((c) => c.destroy());
140
169
  this._cells = [];
141
- this._rowsPerReel = rowsPerReel.slice();
142
- this._maxRows = Math.max(1, ...this._rowsPerReel);
170
+ this._cfg = { ...this._cfg, rowsPerReel: rowsPerReel.slice() };
171
+ this._geom = resolveGeometry(this._cfg);
143
172
  this._buildCells();
144
173
  if (this._mask) {
145
174
  this._mask.destroy();
@@ -149,8 +178,18 @@ export class ReelGrid extends Container {
149
178
  }
150
179
  }
151
180
 
152
- resize(cellSize: number): void {
153
- this._cellSize = cellSize;
181
+ /**
182
+ * Re-resolve geometry after a base cell-size change and reposition cells. Accepts a square
183
+ * scalar (updates `cellSize`) or explicit `{width,height}`. Per-strip overrides still apply.
184
+ * (Cell frames are not re-drawn here — a geometry change routes through a full rebuild upstream.)
185
+ */
186
+ resize(size: number | { width: number; height: number }): void {
187
+ if (typeof size === 'number') {
188
+ this._cfg = { ...this._cfg, cellSize: size, cellWidth: undefined, cellHeight: undefined };
189
+ } else {
190
+ this._cfg = { ...this._cfg, cellWidth: size.width, cellHeight: size.height };
191
+ }
192
+ this._geom = resolveGeometry(this._cfg);
154
193
  for (let c = 0; c < this._cols; c++) {
155
194
  for (let r = 0; r < this._rowsPerReel[c]; r++) {
156
195
  const { x, y } = this.cellPosition(c, r);