@energy8platform/game-engine 0.22.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 +4 -4
  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
package/dist/slot.cjs.js CHANGED
@@ -454,22 +454,23 @@ class SpriteAnimation {
454
454
  }
455
455
  }
456
456
 
457
+ const dims = (size) => typeof size === 'number' ? { width: size, height: size } : size;
457
458
  /** Built-in SymbolView: a static base sprite with optional idle/win spritesheet frames. */
458
459
  class AnimatedSymbol extends pixi_js.Container {
459
460
  _base;
460
461
  _anim = null;
461
462
  _textures;
462
- _size;
463
+ _w = 0;
464
+ _h = 0;
463
465
  _fps;
464
466
  constructor(config) {
465
467
  super();
466
468
  this._textures = config.textures;
467
- this._size = config.size;
468
469
  this._fps = config.fps ?? 24;
469
470
  this._base = new pixi_js.Sprite(config.textures.base);
470
471
  this._base.anchor.set(0.5);
471
472
  this.addChild(this._base);
472
- this.resize(this._size);
473
+ this.resize(config.size);
473
474
  }
474
475
  setTextures(t) {
475
476
  this._textures = t;
@@ -477,12 +478,14 @@ class AnimatedSymbol extends pixi_js.Container {
477
478
  this.showStatic();
478
479
  }
479
480
  resize(size) {
480
- this._size = size;
481
- this._base.width = size;
482
- this._base.height = size;
481
+ const { width, height } = dims(size);
482
+ this._w = width;
483
+ this._h = height;
484
+ this._base.width = width;
485
+ this._base.height = height;
483
486
  if (this._anim) {
484
- this._anim.width = size;
485
- this._anim.height = size;
487
+ this._anim.width = width;
488
+ this._anim.height = height;
486
489
  }
487
490
  }
488
491
  showStatic() {
@@ -512,8 +515,8 @@ class AnimatedSymbol extends pixi_js.Container {
512
515
  this._base.visible = false;
513
516
  const a = SpriteAnimation.create(frames, { loop, autoPlay: true, onComplete });
514
517
  a.anchor.set(0.5);
515
- a.width = this._size;
516
- a.height = this._size;
518
+ a.width = this._w;
519
+ a.height = this._h;
517
520
  a.animationSpeed = this._fps / 60;
518
521
  this.addChild(a);
519
522
  this._anim = a;
@@ -527,9 +530,11 @@ const DEFAULT_STYLE = {
527
530
  removed: { color: 0x223047, alpha: 0.12 },
528
531
  fresh: { color: 0xffffff, alpha: 0.6 },
529
532
  };
533
+ const cellDims = (size) => typeof size === 'number' ? { width: size, height: size } : size;
530
534
  class SymbolCell extends pixi_js.Container {
531
535
  __uiComponent = true;
532
- _size;
536
+ _w;
537
+ _h;
533
538
  _resolve;
534
539
  _style;
535
540
  _frame;
@@ -541,7 +546,9 @@ class SymbolCell extends pixi_js.Container {
541
546
  frameStyleKey = 'idle';
542
547
  constructor(config) {
543
548
  super();
544
- this._size = config.size;
549
+ const { width, height } = cellDims(config.size);
550
+ this._w = width;
551
+ this._h = height;
545
552
  this._resolve = config.resolve;
546
553
  this._style = { ...DEFAULT_STYLE, ...(config.frameStyle ?? {}) };
547
554
  this._frame = new pixi_js.Graphics();
@@ -569,7 +576,7 @@ class SymbolCell extends pixi_js.Container {
569
576
  }
570
577
  const v = this._resolve(data.symbol);
571
578
  if (v) {
572
- v.resize?.(this._size);
579
+ v.resize?.({ width: this._w, height: this._h });
573
580
  this.addChildAt(v, 1); // above frame, below badges
574
581
  this._view = v;
575
582
  }
@@ -610,7 +617,7 @@ class SymbolCell extends pixi_js.Container {
610
617
  const s = this._style[key];
611
618
  this._frame.clear();
612
619
  this._frame
613
- .roundRect(-this._size / 2, -this._size / 2, this._size, this._size, this._style.radius)
620
+ .roundRect(-this._w / 2, -this._h / 2, this._w, this._h, this._style.radius)
614
621
  .fill({ color: s.color, alpha: s.alpha });
615
622
  // store the colour as tint for cheap inspection/testing
616
623
  this._frame.tint = s.color;
@@ -623,7 +630,7 @@ class SymbolCell extends pixi_js.Container {
623
630
  if (!value || value <= 1)
624
631
  return;
625
632
  this._multBadge = this._badge(`×${value}`, 0xffd24a);
626
- this._multBadge.position.set(this._size / 2 - 12, -this._size / 2 + 12);
633
+ this._multBadge.position.set(this._w / 2 - 12, -this._h / 2 + 12);
627
634
  this._badges.addChild(this._multBadge);
628
635
  }
629
636
  _setBonus(value) {
@@ -634,7 +641,7 @@ class SymbolCell extends pixi_js.Container {
634
641
  if (!value || value <= 0)
635
642
  return;
636
643
  this._bonusBadge = this._badge(`+${value}`, 0x7ad7ff);
637
- this._bonusBadge.position.set(-this._size / 2 + 12, -this._size / 2 + 12);
644
+ this._bonusBadge.position.set(-this._w / 2 + 12, -this._h / 2 + 12);
638
645
  this._badges.addChild(this._bonusBadge);
639
646
  }
640
647
  _badge(label, color) {
@@ -646,17 +653,110 @@ class SymbolCell extends pixi_js.Container {
646
653
  }
647
654
  }
648
655
 
656
+ // packages/game-engine/src/slot/grid/geometry.ts
657
+ //
658
+ // Pure geometry resolver for the reel grid. Turns the (backward-compatible) grid config
659
+ // — a square `cellSize` + single `gap`, optionally overridden by rectangular / per-strip
660
+ // dimensions and per-strip gaps — into a fully-resolved, per-reel layout.
661
+ //
662
+ // Coordinate convention (unchanged from the original square grid):
663
+ // - `cellPosition` returns CELL-CENTRE coordinates.
664
+ // - Cell (0,0)'s centre sits at local x = 0. Reels extend to the right.
665
+ // - Variable-height reels are vertically CENTRED about a shared centre line, so the
666
+ // tallest reel's row 0 sits at y = 0 (matching the old Megaways behaviour).
667
+ //
668
+ // See docs/reels-analysis-and-design.md §6.
669
+ const perReelSize = (spec, w, h) => {
670
+ if (typeof spec === 'number')
671
+ return [spec, spec];
672
+ if (spec && typeof spec === 'object')
673
+ return [spec.width, spec.height];
674
+ return [w, h];
675
+ };
676
+ const gapAt = (g, i, base) => Array.isArray(g) ? (g[i] ?? base) : (g ?? base);
677
+ /** Resolve a grid config into a fully-populated per-reel geometry. */
678
+ function resolveGeometry(g) {
679
+ const cols = Math.max(0, g.cols);
680
+ const rowsPerReel = g.rowsPerReel && g.rowsPerReel.length === cols
681
+ ? g.rowsPerReel.slice()
682
+ : Array.from({ length: cols }, () => g.rows);
683
+ const maxRows = cols ? Math.max(1, ...rowsPerReel) : 0;
684
+ const baseW = g.cellWidth ?? g.cellSize;
685
+ const baseH = g.cellHeight ?? g.cellSize;
686
+ const baseGap = g.gap ?? 0;
687
+ const cellW = [];
688
+ const cellH = [];
689
+ const rowGap = [];
690
+ for (let c = 0; c < cols; c++) {
691
+ const [w, h] = perReelSize(g.cellSizePerReel?.[c], baseW, baseH);
692
+ cellW[c] = w;
693
+ cellH[c] = h;
694
+ rowGap[c] = gapAt(g.rowGap, c, baseGap);
695
+ }
696
+ const colGap = [];
697
+ for (let i = 0; i < Math.max(0, cols - 1); i++)
698
+ colGap[i] = gapAt(g.colGap, i, baseGap);
699
+ // Horizontal: reel 0 centre at x = 0, then accumulate half-widths + between-reel gaps.
700
+ const colX = [];
701
+ if (cols)
702
+ colX[0] = 0;
703
+ for (let c = 1; c < cols; c++)
704
+ colX[c] = colX[c - 1] + cellW[c - 1] / 2 + colGap[c - 1] + cellW[c] / 2;
705
+ // Vertical: each reel's rows span (rows-1)*step; centre every reel about a shared line so
706
+ // the tallest reel's row 0 stays at y = 0 (parity with the old uniform Megaways layout).
707
+ const span = rowsPerReel.map((rr, c) => Math.max(0, rr - 1) * (cellH[c] + rowGap[c]));
708
+ const halfMaxSpan = cols ? Math.max(...span) / 2 : 0;
709
+ const yOff = span.map((s) => halfMaxSpan - s / 2);
710
+ // Bounding box.
711
+ let leftX = 0;
712
+ let rightX = 0;
713
+ let topY = 0;
714
+ let bottomY = 0;
715
+ for (let c = 0; c < cols; c++) {
716
+ leftX = Math.min(leftX, colX[c] - cellW[c] / 2);
717
+ rightX = Math.max(rightX, colX[c] + cellW[c] / 2);
718
+ const reelTop = yOff[c] - cellH[c] / 2;
719
+ topY = Math.min(topY, reelTop);
720
+ bottomY = Math.max(bottomY, reelTop + rowsPerReel[c] * cellH[c] + Math.max(0, rowsPerReel[c] - 1) * rowGap[c]);
721
+ }
722
+ const gridW = rightX - leftX;
723
+ const gridH = bottomY - topY;
724
+ return {
725
+ cols,
726
+ rowsPerReel,
727
+ maxRows,
728
+ cellW,
729
+ cellH,
730
+ rowGap,
731
+ colGap,
732
+ colX,
733
+ yOff,
734
+ gridW,
735
+ gridH,
736
+ centerX: (leftX + rightX) / 2,
737
+ centerY: (topY + bottomY) / 2,
738
+ leftX,
739
+ topY,
740
+ };
741
+ }
742
+ /** Cell-centre position from a resolved geometry. */
743
+ function cellPositionOf(geom, col, row) {
744
+ return {
745
+ x: geom.colX[col] ?? 0,
746
+ y: (geom.yOff[col] ?? 0) + row * ((geom.cellH[col] ?? 0) + (geom.rowGap[col] ?? 0)),
747
+ };
748
+ }
749
+
649
750
  /**
650
- * A grid of `SymbolCell`s. Supports uniform grids and variable-height reels (Megaways):
651
- * when `rowsPerReel` is set each reel is vertically centred inside the tallest reel's envelope.
751
+ * A grid of `SymbolCell`s. Supports uniform grids, variable-height reels (Megaways), and
752
+ * rectangular / per-strip cell sizes with per-strip gaps. All layout flows through a single
753
+ * resolved geometry (see grid/geometry.ts); every consumer reads positions via `cellPosition`
754
+ * and dimensions via `cellSize(col)` rather than assuming a square cell.
652
755
  */
653
756
  class ReelGrid extends pixi_js.Container {
654
757
  __uiComponent = true;
655
- _cols;
656
- _rowsPerReel;
657
- _maxRows;
658
- _cellSize;
659
- _gap;
758
+ _cfg;
759
+ _geom;
660
760
  _cells = [];
661
761
  _cellLayer = new pixi_js.Container();
662
762
  _resolve;
@@ -664,39 +764,35 @@ class ReelGrid extends pixi_js.Container {
664
764
  _mask = null;
665
765
  constructor(config) {
666
766
  super();
667
- this._cols = config.cols;
668
- this._rowsPerReel =
669
- config.rowsPerReel && config.rowsPerReel.length === config.cols
670
- ? config.rowsPerReel.slice()
671
- : Array.from({ length: config.cols }, () => config.rows);
672
- this._maxRows = Math.max(1, ...this._rowsPerReel);
673
- this._cellSize = config.cellSize;
674
- this._gap = config.gap ?? 0;
767
+ this._cfg = { ...config };
675
768
  this._resolve = config.resolve;
676
769
  this._frameStyle = config.frameStyle;
677
- if (config.decoration) {
770
+ this._geom = resolveGeometry(config);
771
+ if (config.decoration?.texture) {
678
772
  const pad = config.decoration.padding ?? 0;
679
- const w = this._cols * (this._cellSize + this._gap) - this._gap + pad * 2;
680
- const h = this._maxRows * (this._cellSize + this._gap) - this._gap + pad * 2;
681
- if (config.decoration.texture) {
682
- const deco = new pixi_js.Sprite(config.decoration.texture);
683
- deco.width = w;
684
- deco.height = h;
685
- deco.position.set(-pad - this._cellSize / 2, -pad - this._cellSize / 2);
686
- this.addChild(deco);
687
- }
773
+ const deco = new pixi_js.Sprite(config.decoration.texture);
774
+ deco.width = this._geom.gridW + pad * 2;
775
+ deco.height = this._geom.gridH + pad * 2;
776
+ deco.position.set(this._geom.leftX - pad, this._geom.topY - pad);
777
+ this.addChild(deco);
688
778
  }
689
779
  this.addChild(this._cellLayer);
690
780
  this._buildCells();
691
781
  if (config.mask)
692
782
  this._applyMask();
693
783
  }
784
+ get _cols() {
785
+ return this._geom.cols;
786
+ }
787
+ get _rowsPerReel() {
788
+ return this._geom.rowsPerReel;
789
+ }
694
790
  _buildCells() {
695
791
  for (let c = 0; c < this._cols; c++) {
696
792
  this._cells[c] = [];
697
793
  for (let r = 0; r < this._rowsPerReel[c]; r++) {
698
794
  const cell = new SymbolCell({
699
- size: this._cellSize,
795
+ size: this.cellSize(c),
700
796
  resolve: this._resolve,
701
797
  frameStyle: this._frameStyle,
702
798
  });
@@ -707,10 +803,19 @@ class ReelGrid extends pixi_js.Container {
707
803
  }
708
804
  }
709
805
  }
806
+ /** Per-strip mask: one window per reel sized to that reel's own cell width × column height. */
710
807
  _applyMask() {
711
- const w = this._cols * (this._cellSize + this._gap) - this._gap;
712
- const h = this._maxRows * (this._cellSize + this._gap) - this._gap;
713
- const m = new pixi_js.Graphics().rect(-this._cellSize / 2, -this._cellSize / 2, w, h).fill(0xffffff);
808
+ const g = this._geom;
809
+ const m = new pixi_js.Graphics();
810
+ for (let c = 0; c < this._cols; c++) {
811
+ const rows = this._rowsPerReel[c];
812
+ const w = g.cellW[c];
813
+ const h = rows * g.cellH[c] + Math.max(0, rows - 1) * g.rowGap[c];
814
+ const x = g.colX[c] - w / 2;
815
+ const y = g.yOff[c] - g.cellH[c] / 2;
816
+ m.rect(x, y, w, h);
817
+ }
818
+ m.fill(0xffffff);
714
819
  this._cellLayer.mask = m;
715
820
  this.addChild(m);
716
821
  this._mask = m;
@@ -720,10 +825,15 @@ class ReelGrid extends pixi_js.Container {
720
825
  }
721
826
  /** Tallest reel's row count (the grid's visual height in rows). */
722
827
  get rows() {
723
- return this._maxRows;
828
+ return this._geom.maxRows;
829
+ }
830
+ /** Cell dimensions (px) for a reel. Rectangular / per-strip aware. */
831
+ cellSize(col) {
832
+ return { width: this._geom.cellW[col] ?? 0, height: this._geom.cellH[col] ?? 0 };
724
833
  }
725
- get cellSize() {
726
- return this._cellSize;
834
+ /** The fully-resolved grid geometry (read-only view). */
835
+ get geometry() {
836
+ return this._geom;
727
837
  }
728
838
  rowsOf(col) {
729
839
  return this._rowsPerReel[col] ?? 0;
@@ -731,12 +841,13 @@ class ReelGrid extends pixi_js.Container {
731
841
  get rowsPerReel() {
732
842
  return this._rowsPerReel.slice();
733
843
  }
734
- /** Cell centre position. Variable-height reels are centred in the max-rows envelope. */
844
+ /** Cell centre position. Variable-height reels are centred about a shared centre line. */
735
845
  cellPosition(col, row) {
736
- const step = this._cellSize + this._gap;
737
- const reelRows = this._rowsPerReel[col] ?? this._maxRows;
738
- const yOffset = ((this._maxRows - reelRows) / 2) * step;
739
- return { x: col * step, y: yOffset + row * step };
846
+ return cellPositionOf(this._geom, col, row);
847
+ }
848
+ /** Grid bounding-box centre in local coords. */
849
+ center() {
850
+ return { x: this._geom.centerX, y: this._geom.centerY };
740
851
  }
741
852
  getCell(col, row) {
742
853
  return this._cells[col][row];
@@ -754,8 +865,8 @@ class ReelGrid extends pixi_js.Container {
754
865
  return;
755
866
  this._cellLayer.removeChildren().forEach((c) => c.destroy());
756
867
  this._cells = [];
757
- this._rowsPerReel = rowsPerReel.slice();
758
- this._maxRows = Math.max(1, ...this._rowsPerReel);
868
+ this._cfg = { ...this._cfg, rowsPerReel: rowsPerReel.slice() };
869
+ this._geom = resolveGeometry(this._cfg);
759
870
  this._buildCells();
760
871
  if (this._mask) {
761
872
  this._mask.destroy();
@@ -764,8 +875,19 @@ class ReelGrid extends pixi_js.Container {
764
875
  this._applyMask();
765
876
  }
766
877
  }
767
- resize(cellSize) {
768
- this._cellSize = cellSize;
878
+ /**
879
+ * Re-resolve geometry after a base cell-size change and reposition cells. Accepts a square
880
+ * scalar (updates `cellSize`) or explicit `{width,height}`. Per-strip overrides still apply.
881
+ * (Cell frames are not re-drawn here — a geometry change routes through a full rebuild upstream.)
882
+ */
883
+ resize(size) {
884
+ if (typeof size === 'number') {
885
+ this._cfg = { ...this._cfg, cellSize: size, cellWidth: undefined, cellHeight: undefined };
886
+ }
887
+ else {
888
+ this._cfg = { ...this._cfg, cellWidth: size.width, cellHeight: size.height };
889
+ }
890
+ this._geom = resolveGeometry(this._cfg);
769
891
  for (let c = 0; c < this._cols; c++) {
770
892
  for (let r = 0; r < this._rowsPerReel[c]; r++) {
771
893
  const { x, y } = this.cellPosition(c, r);
@@ -1138,6 +1260,10 @@ function effectiveRowsPerReel(grid) {
1138
1260
  return grid.rowsPerReel.slice();
1139
1261
  return Array.from({ length: grid.cols }, () => grid.rows);
1140
1262
  }
1263
+ /** Resolve a `GridConfig` into a fully-populated per-reel geometry (rectangular / per-strip aware). */
1264
+ function resolveGridGeometry(grid) {
1265
+ return resolveGeometry(grid);
1266
+ }
1141
1267
  /** Total ways-to-win for a ways/megaways grid (product of per-reel heights). */
1142
1268
  function waysCount(grid) {
1143
1269
  return effectiveRowsPerReel(grid).reduce((a, b) => a * b, 1);
@@ -1497,7 +1623,7 @@ class SpinEngine {
1497
1623
  tape.x = base.x;
1498
1624
  const landingStart = tapeLen - rows;
1499
1625
  for (let i = 0; i < tapeLen; i++) {
1500
- const cell = new SymbolCell({ size: this._grid.cellSize, resolve: this._resolve });
1626
+ const cell = new SymbolCell({ size: this._grid.cellSize(p.reel), resolve: this._resolve });
1501
1627
  const sym = i >= landingStart
1502
1628
  ? (p.landing[i - landingStart]?.symbol ?? null)
1503
1629
  : (pool[i % pool.length] ?? null);
@@ -1822,8 +1948,10 @@ class TumbleController {
1822
1948
  cell.alpha = 1;
1823
1949
  }
1824
1950
  this._undim();
1825
- // 3. gravity: survivors slide down, new cells drop from above
1826
- const rowStep = this._grid.cellPosition(0, 1).y - this._grid.cellPosition(0, 0).y;
1951
+ // 3. gravity: survivors slide down, new cells drop from above (per-reel row step)
1952
+ const rowStepOf = (col) => this._grid.rowsOf(col) > 1
1953
+ ? this._grid.cellPosition(col, 1).y - this._grid.cellPosition(col, 0).y
1954
+ : this._grid.cellSize(col).height;
1827
1955
  const slides = this._cfg.gravity ? (step.drops ?? this.deriveDrops(step)) : [];
1828
1956
  const anims = [];
1829
1957
  for (const d of slides) {
@@ -1843,7 +1971,7 @@ class TumbleController {
1843
1971
  cell.setState({ fresh: true });
1844
1972
  cell.alpha = 1;
1845
1973
  cell.scale.set(1);
1846
- cell.position.set(home.x, home.y - rowStep * (this._grid.rowsOf(n.col) + 1));
1974
+ cell.position.set(home.x, home.y - rowStepOf(n.col) * (this._grid.rowsOf(n.col) + 1));
1847
1975
  const idx = (perCol[n.col] = (perCol[n.col] ?? 0) + 1);
1848
1976
  anims.push((async () => {
1849
1977
  await Tween.delay(idx * 28 * f);
@@ -1907,17 +2035,26 @@ class TumbleController {
1907
2035
  function cellCenter(grid, col, row) {
1908
2036
  return grid.cellPosition(col, row);
1909
2037
  }
1910
- function rowStepOf(grid) {
1911
- return grid.cellPosition(0, 1).y - grid.cellPosition(0, 0).y;
2038
+ /** Vertical row-to-row step for a reel (falls back to the reel's cell height for 1-row reels). */
2039
+ function rowStepOf(grid, col = 0) {
2040
+ if (grid.rowsOf(col) > 1)
2041
+ return grid.cellPosition(col, 1).y - grid.cellPosition(col, 0).y;
2042
+ return grid.cellSize(col).height;
2043
+ }
2044
+ /** Horizontal reel-to-reel step at a boundary (falls back to the reel's cell width for 1-col grids). */
2045
+ function colStepOf(grid, col = 0) {
2046
+ if (col + 1 < grid.cols)
2047
+ return grid.cellPosition(col + 1, 0).x - grid.cellPosition(col, 0).x;
2048
+ return grid.cellSize(col).width;
1912
2049
  }
1913
2050
  /** A glowing ring drawn around a cell. Returns a disposer. */
1914
2051
  function glowRing(fx, grid, col, row, color) {
1915
2052
  if (fx.destroyed)
1916
2053
  return () => { };
1917
2054
  const { x, y } = cellCenter(grid, col, row);
1918
- const s = grid.cellSize;
2055
+ const { width, height } = grid.cellSize(col);
1919
2056
  const g = new pixi_js.Graphics()
1920
- .roundRect(x - s / 2, y - s / 2, s, s, 10)
2057
+ .roundRect(x - width / 2, y - height / 2, width, height, 10)
1921
2058
  .stroke({ color, width: 3, alpha: 0.9 });
1922
2059
  fx.addChild(g);
1923
2060
  return () => g.destroy();
@@ -1960,7 +2097,7 @@ async function dropCell(grid, cell, col, row, ms = 280) {
1960
2097
  if (cell.destroyed)
1961
2098
  return;
1962
2099
  const home = grid.cellPosition(col, row);
1963
- cell.position.set(home.x, home.y - rowStepOf(grid) * (row + 2));
2100
+ cell.position.set(home.x, home.y - rowStepOf(grid, col) * (row + 2));
1964
2101
  cell.alpha = 1;
1965
2102
  cell.scale.set(1);
1966
2103
  await Tween.to(cell, { 'position.y': home.y }, ms, easingByName('easeOutBounce'));
@@ -2025,7 +2162,7 @@ const StickySymbols = {
2025
2162
  glowRing(ctx.fx, ctx.grid, p.col, p.row, f.ringColor);
2026
2163
  await pulseCell(cell, 1.2, 260);
2027
2164
  const { x, y } = cellCenter(ctx.grid, p.col, p.row);
2028
- await floatLabel(ctx.fx, x, y - ctx.grid.cellSize / 2, f.durationSpins ? `STICKY ${f.durationSpins}` : 'STICKY', f.ringColor, 600 + i * 50);
2165
+ await floatLabel(ctx.fx, x, y - ctx.grid.cellSize(p.col).height / 2, f.durationSpins ? `STICKY ${f.durationSpins}` : 'STICKY', f.ringColor, 600 + i * 50);
2029
2166
  }));
2030
2167
  },
2031
2168
  };
@@ -2166,14 +2303,15 @@ const GiantSymbol = {
2166
2303
  const view = ctx.resolve(sym);
2167
2304
  if (!view)
2168
2305
  return;
2169
- const step = rowStepOf(ctx.grid);
2306
+ const cs = ctx.grid.cellSize(anchorCol);
2307
+ const stepX = colStepOf(ctx.grid, anchorCol);
2308
+ const step = rowStepOf(ctx.grid, anchorCol);
2170
2309
  const tl = cellCenter(ctx.grid, anchorCol, 0);
2171
2310
  const giant = new pixi_js.Container();
2172
2311
  giant.addChild(view);
2173
- view.resize?.(ctx.grid.cellSize * Math.max(w, h));
2174
- if (view.scale)
2175
- view.scale.set(((ctx.grid.cellSize * w) / (ctx.grid.cellSize * Math.max(w, h))) * view.scale.x, ((ctx.grid.cellSize * h) / (ctx.grid.cellSize * Math.max(w, h))) * view.scale.y);
2176
- giant.position.set(tl.x + ((w - 1) * step) / 2, tl.y + ((h - 1) * step) / 2);
2312
+ // span w×h cells (footprint ignores gaps, matching the previous single-cell unit)
2313
+ view.resize?.({ width: cs.width * w, height: cs.height * h });
2314
+ giant.position.set(tl.x + ((w - 1) * stepX) / 2, tl.y + ((h - 1) * step) / 2);
2177
2315
  // hide covered cells
2178
2316
  for (let c = anchorCol; c < anchorCol + w; c++)
2179
2317
  for (let r = 0; r < h; r++)
@@ -2205,8 +2343,9 @@ const SplitSymbol = {
2205
2343
  await Promise.all(left.map(async (p, i) => {
2206
2344
  await Tween.delay((i % 6) * 30);
2207
2345
  const { x, y } = cellCenter(ctx.grid, p.col, p.row);
2346
+ const cs = ctx.grid.cellSize(p.col);
2208
2347
  await pulseCell(ctx.grid.getCell(p.col, p.row), 1.12, 200);
2209
- await floatLabel(ctx.fx, x + ctx.grid.cellSize / 3, y - ctx.grid.cellSize / 3, `×${f.factor}`, 0x9b5cff, 600);
2348
+ await floatLabel(ctx.fx, x + cs.width / 3, y - cs.height / 3, `×${f.factor}`, 0x9b5cff, 600);
2210
2349
  }));
2211
2350
  },
2212
2351
  };
@@ -2259,8 +2398,7 @@ const MultiplierSymbols = {
2259
2398
  const { x, y } = cellCenter(ctx.grid, p.col, p.row);
2260
2399
  await floatLabel(ctx.fx, x, y, `×${values[spots.indexOf(p)]}`, 0xffd24a, 500);
2261
2400
  }));
2262
- const cx = (ctx.grid.cols * ctx.grid.cellSize) / 2 - ctx.grid.cellSize / 2;
2263
- const cy = (ctx.grid.rows * ctx.grid.cellSize) / 2 - ctx.grid.cellSize / 2;
2401
+ const { x: cx, y: cy } = ctx.grid.center();
2264
2402
  await floatLabel(ctx.fx, cx, cy, `×${capped}`, 0xffe24a, 900);
2265
2403
  },
2266
2404
  };
@@ -2466,7 +2604,12 @@ function createReelSystem(opts) {
2466
2604
  rows: config.grid.rows,
2467
2605
  rowsPerReel: config.grid.rowsPerReel ?? effectiveRowsPerReel(config.grid),
2468
2606
  cellSize: config.grid.cellSize,
2607
+ cellWidth: config.grid.cellWidth,
2608
+ cellHeight: config.grid.cellHeight,
2609
+ cellSizePerReel: config.grid.cellSizePerReel,
2469
2610
  gap: config.grid.gap,
2611
+ colGap: config.grid.colGap,
2612
+ rowGap: config.grid.rowGap,
2470
2613
  resolve,
2471
2614
  frameStyle: config.grid.frameStyle,
2472
2615
  mask: config.grid.mask,
@@ -2487,8 +2630,13 @@ function createReelSystem(opts) {
2487
2630
  return (a.cols !== b.cols ||
2488
2631
  a.rows !== b.rows ||
2489
2632
  a.cellSize !== b.cellSize ||
2633
+ a.cellWidth !== b.cellWidth ||
2634
+ a.cellHeight !== b.cellHeight ||
2490
2635
  a.gap !== b.gap ||
2491
2636
  a.mask !== b.mask ||
2637
+ JSON.stringify(a.cellSizePerReel) !== JSON.stringify(b.cellSizePerReel) ||
2638
+ JSON.stringify(a.colGap) !== JSON.stringify(b.colGap) ||
2639
+ JSON.stringify(a.rowGap) !== JSON.stringify(b.rowGap) ||
2492
2640
  JSON.stringify(a.rowsPerReel) !== JSON.stringify(b.rowsPerReel) ||
2493
2641
  (a.decoration?.padding ?? 0) !== (b.decoration?.padding ?? 0));
2494
2642
  }
@@ -2797,11 +2945,14 @@ exports.ReelSpinController = ReelSpinController;
2797
2945
  exports.SpinEngine = SpinEngine;
2798
2946
  exports.SymbolCell = SymbolCell;
2799
2947
  exports.TumbleController = TumbleController;
2948
+ exports.cellPositionOf = cellPositionOf;
2800
2949
  exports.createReelSystem = createReelSystem;
2801
2950
  exports.easingByName = easingByName;
2802
2951
  exports.effectiveRowsPerReel = effectiveRowsPerReel;
2803
2952
  exports.mergeReelConfig = mergeReelConfig;
2804
2953
  exports.pickTier = pickTier;
2954
+ exports.resolveGeometry = resolveGeometry;
2955
+ exports.resolveGridGeometry = resolveGridGeometry;
2805
2956
  exports.resolveReelConfig = resolveReelConfig;
2806
2957
  exports.tierIndexAtValue = tierIndexAtValue;
2807
2958
  exports.valueAt = valueAt;