@energy8platform/game-engine 0.17.0 → 0.18.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 (69) hide show
  1. package/dist/core.cjs.js +62 -1
  2. package/dist/core.cjs.js.map +1 -1
  3. package/dist/core.d.ts +33 -2
  4. package/dist/core.esm.js +63 -3
  5. package/dist/core.esm.js.map +1 -1
  6. package/dist/game-spec.cjs.js +13 -0
  7. package/dist/game-spec.cjs.js.map +1 -0
  8. package/dist/game-spec.d.ts +1 -0
  9. package/dist/game-spec.esm.js +2 -0
  10. package/dist/game-spec.esm.js.map +1 -0
  11. package/dist/host.cjs.js +3346 -0
  12. package/dist/host.cjs.js.map +1 -0
  13. package/dist/host.d.ts +915 -0
  14. package/dist/host.esm.js +3337 -0
  15. package/dist/host.esm.js.map +1 -0
  16. package/dist/index.cjs.js +13 -1
  17. package/dist/index.cjs.js.map +1 -1
  18. package/dist/index.d.ts +6 -1
  19. package/dist/index.esm.js +13 -1
  20. package/dist/index.esm.js.map +1 -1
  21. package/dist/react.cjs.js.map +1 -1
  22. package/dist/react.d.ts +6 -1
  23. package/dist/react.esm.js.map +1 -1
  24. package/dist/shell.cjs.js +19 -0
  25. package/dist/shell.cjs.js.map +1 -0
  26. package/dist/shell.d.ts +1 -0
  27. package/dist/shell.esm.js +2 -0
  28. package/dist/shell.esm.js.map +1 -0
  29. package/dist/slot.cjs.js +998 -0
  30. package/dist/slot.cjs.js.map +1 -0
  31. package/dist/slot.d.ts +333 -0
  32. package/dist/slot.esm.js +985 -0
  33. package/dist/slot.esm.js.map +1 -0
  34. package/package.json +26 -1
  35. package/src/core/GameApplication.ts +16 -1
  36. package/src/core/index.ts +2 -0
  37. package/src/game-spec/index.ts +1 -0
  38. package/src/host/autoplay.ts +78 -0
  39. package/src/host/balanceGate.ts +46 -0
  40. package/src/host/buildConfig.ts +28 -0
  41. package/src/host/createSlotGame.ts +423 -0
  42. package/src/host/fatalError.ts +104 -0
  43. package/src/host/freeSpinsCounter.ts +44 -0
  44. package/src/host/index.ts +18 -0
  45. package/src/host/playError.ts +64 -0
  46. package/src/host/preboot.ts +25 -0
  47. package/src/host/replay.ts +9 -0
  48. package/src/host/runRound.ts +63 -0
  49. package/src/host/sceneController.ts +31 -0
  50. package/src/host/sceneStart.ts +25 -0
  51. package/src/host/shellConfig.ts +379 -0
  52. package/src/host/slotPlay.ts +62 -0
  53. package/src/host/types.ts +71 -0
  54. package/src/scenes/IntroScene.ts +66 -0
  55. package/src/shell/index.ts +20 -0
  56. package/src/slot/anim/CascadeController.ts +102 -0
  57. package/src/slot/anim/ReelSpinController.ts +81 -0
  58. package/src/slot/anim/easing-map.ts +14 -0
  59. package/src/slot/freeSpins/FreeSpinsSession.ts +40 -0
  60. package/src/slot/grid/AnimatedSymbol.ts +68 -0
  61. package/src/slot/grid/ReelGrid.ts +92 -0
  62. package/src/slot/grid/SymbolCell.ts +127 -0
  63. package/src/slot/grid/SymbolView.ts +13 -0
  64. package/src/slot/index.ts +21 -0
  65. package/src/slot/multiplier/MultiplierAccumulator.ts +29 -0
  66. package/src/slot/overlay/BigWinOverlay.ts +89 -0
  67. package/src/slot/overlay/CountUpDisplay.ts +56 -0
  68. package/src/slot/overlay/tiers.ts +29 -0
  69. package/src/types.ts +3 -0
@@ -0,0 +1,71 @@
1
+ // packages/game-engine/src/host/types.ts
2
+ import type { ApplicationOptions } from 'pixi.js';
3
+ import type { GameModel } from '@energy8platform/platform-core/game-spec';
4
+ import type { AssetManifest, LoadingScreenConfig } from '@energy8platform/platform-core';
5
+ import type { GameShell } from '@energy8platform/platform-core/shell';
6
+ import type { AudioConfig, ScaleMode, Orientation, SceneConstructor } from '../types';
7
+ import type { BookAdapter, AdapterModule, StakeBridge } from '@energy8platform/stake-bridge';
8
+ import type { GameApplication } from '../core';
9
+ import type { SlotShellOptions } from './shellConfig';
10
+ import type { SlotSpinResultBase, SlotResultNormalizer } from '@energy8platform/platform-core/slot-result';
11
+
12
+ export interface StakeIntegration {
13
+ /** The game's BookAdapter (or its module). modeMap + gameId come from the model. */
14
+ adapter: BookAdapter | AdapterModule;
15
+ }
16
+
17
+ /** One scene registered with the host: a key + its constructor. The list order matters — the
18
+ * first scene that is eligible for the current launch mode is the start scene (unless an explicit
19
+ * `startScene` overrides it). */
20
+ export interface SceneRegistration {
21
+ key: string;
22
+ scene: SceneConstructor;
23
+ /** Skip this scene as a START scene on a replay launch (e.g. an intro). It is still registered
24
+ * (other scenes can `goto` it), it just isn't auto-started — the first non-skipped scene is. */
25
+ skipOnReplay?: boolean;
26
+ }
27
+
28
+ /** @deprecated alias kept for one release — use {@link SceneRegistration}. */
29
+ export type SceneEntry = SceneRegistration;
30
+
31
+ /** Navigation injected into the start data of EVERY scene the host registers.
32
+ * Any scene (intro, game, …) reads it from its `onEnter(data)` to navigate. */
33
+ export interface SceneNavData {
34
+ /** Switch to another registered scene by key. */
35
+ goto: (key: string, data?: unknown) => void;
36
+ }
37
+
38
+ export interface CreateSlotGameOptions<T extends SlotSpinResultBase = SlotSpinResultBase> {
39
+ model: GameModel;
40
+ /** REQUIRED: maps the raw play result into the game's typed result. The host calls it on every play. */
41
+ normalize: SlotResultNormalizer<T>;
42
+ /** ALL scenes the game uses, registered up front, in order. The first scene eligible for the
43
+ * launch mode is the start scene — so a replay launch skips any leading `skipOnReplay` scene
44
+ * (e.g. the intro) and starts directly on the game scene. */
45
+ scenes: SceneRegistration[];
46
+ /** Optional explicit start scene key. Defaults to the first scene eligible for the launch mode
47
+ * (honoured only when that scene is itself eligible; otherwise the first eligible one wins). */
48
+ startScene?: string;
49
+ /** Start data passed to the start scene's `onEnter` (merged with the injected `goto`). */
50
+ startData?: unknown;
51
+ manifest: AssetManifest;
52
+ container?: HTMLElement | string;
53
+ design?: { width: number; height: number };
54
+ scaleMode?: ScaleMode;
55
+ orientation?: Orientation;
56
+ loading?: LoadingScreenConfig;
57
+ audio?: AudioConfig;
58
+ pixi?: Partial<ApplicationOptions>;
59
+ fonts?: string[];
60
+ textureDefaults?: boolean;
61
+ dev?: boolean;
62
+ stake?: StakeIntegration;
63
+ shell?: SlotShellOptions;
64
+ onFatalError?: (message: string) => void;
65
+ }
66
+
67
+ export interface SlotGameHandle {
68
+ game: GameApplication;
69
+ stakeBridge: StakeBridge | null;
70
+ shell: GameShell | null;
71
+ }
@@ -0,0 +1,66 @@
1
+ // packages/game-engine/src/scenes/IntroScene.ts
2
+ import { Container, Graphics, Text } from 'pixi.js';
3
+ import { Scene } from '../core/Scene';
4
+
5
+ export interface IntroSceneConfig {
6
+ title?: string;
7
+ logo?: string; // texture alias (optional; title text is the default)
8
+ tapToStart?: boolean; // default true
9
+ /** Where to navigate on tap. Defaults to the conventional 'game' key. */
10
+ next?: string;
11
+ /** Optional explicit start callback. Takes precedence over `goto(next)`. */
12
+ onStart?: () => void;
13
+ }
14
+
15
+ /**
16
+ * Reusable splash scene: shows a title (or logo) + "tap to start", then advances.
17
+ *
18
+ * The host no longer special-cases the intro. Navigation works like every other
19
+ * scene: the host injects `goto(key)` into this scene's start data. On tap this
20
+ * scene calls `onStart` if the game supplied one, otherwise `goto(next ?? 'game')`.
21
+ * (The built-in can't know the game's scene key generically, so it falls back to
22
+ * the conventional 'game' key — override via `next`. Scaffold-generated intros
23
+ * skip this primitive and call `goto('game')` directly.)
24
+ */
25
+ export class IntroScene extends Scene {
26
+ private layer?: Container;
27
+
28
+ async onEnter(data?: unknown): Promise<void> {
29
+ const cfg = (data ?? {}) as IntroSceneConfig & { goto?: (key: string, data?: unknown) => void };
30
+ const start = () =>
31
+ cfg.onStart ? cfg.onStart() : cfg.goto?.(cfg.next ?? 'game');
32
+ const layer = new Container();
33
+ this.layer = layer;
34
+ this.container.addChild(layer);
35
+
36
+ const title = new Text({
37
+ text: cfg.title ?? 'PLAY',
38
+ style: { fill: 0xffffff, fontSize: 96, fontFamily: 'Inter', align: 'center' },
39
+ });
40
+ title.anchor.set(0.5);
41
+ title.position.set(960, 460);
42
+ layer.addChild(title);
43
+
44
+ if (cfg.tapToStart !== false) {
45
+ const hint = new Text({
46
+ text: 'Tap to start',
47
+ style: { fill: 0xffd24a, fontSize: 36, fontFamily: 'Inter' },
48
+ });
49
+ hint.anchor.set(0.5);
50
+ hint.position.set(960, 600);
51
+ layer.addChild(hint);
52
+ }
53
+
54
+ // full-screen tap target
55
+ const hit = new Graphics().rect(0, 0, 1920, 1080).fill({ color: 0x000000, alpha: 0.001 });
56
+ hit.eventMode = 'static';
57
+ hit.cursor = 'pointer';
58
+ hit.once('pointerdown', () => start());
59
+ layer.addChild(hit);
60
+ }
61
+
62
+ onExit(): void {
63
+ this.layer?.destroy({ children: true });
64
+ this.layer = undefined;
65
+ }
66
+ }
@@ -0,0 +1,20 @@
1
+ // Re-export the renderer-agnostic branded game shell from platform-core so
2
+ // game-engine consumers can import it via @energy8platform/game-engine/shell.
3
+ export {
4
+ createGameShell,
5
+ removeGameShell,
6
+ GameShell,
7
+ } from '@energy8platform/platform-core/shell';
8
+ export type {
9
+ ShellConfig,
10
+ ShellMode,
11
+ ShellFeatures,
12
+ ShellState,
13
+ ShellEvents,
14
+ BonusOption,
15
+ CurrencyConfig,
16
+ ThemeConfig,
17
+ GameInfoContent,
18
+ AutoplayOptions,
19
+ FreeSpinsState,
20
+ } from '@energy8platform/platform-core/shell';
@@ -0,0 +1,102 @@
1
+ // packages/game-engine/src/slot/anim/CascadeController.ts
2
+ import { Tween } from '../../animation';
3
+ import { EASING_BY_NAME } from './easing-map';
4
+ import type { ReelGrid } from '../grid/ReelGrid';
5
+ import type { CellData } from '../grid/SymbolCell';
6
+
7
+ export interface CascadeStepData {
8
+ winningCells: { col: number; row: number }[];
9
+ removedCells: { col: number; row: number }[];
10
+ newCells: { col: number; row: number; symbol: string }[];
11
+ settledGrid: CellData[][];
12
+ }
13
+ export interface CascadeTimings { reveal: number; highlight: number; remove: number; drop: number; refill: number; wait: number; }
14
+ export interface CascadeAnim {
15
+ col: number; row: number;
16
+ phase: 'reveal' | 'highlight' | 'remove' | 'drop' | 'refill';
17
+ from?: { x: number; y: number };
18
+ to?: { x: number; y: number };
19
+ scale?: number;
20
+ alpha?: number;
21
+ duration: number;
22
+ easing?: string;
23
+ delay?: number;
24
+ }
25
+
26
+ const DEFAULT_TIMINGS: CascadeTimings = { reveal: 300, highlight: 400, remove: 250, drop: 200, refill: 220, wait: 150 };
27
+
28
+ export class CascadeController {
29
+ private _grid: ReelGrid;
30
+ private _t: CascadeTimings;
31
+ private _killed = false;
32
+
33
+ constructor(grid: ReelGrid, timings?: Partial<CascadeTimings>) {
34
+ this._grid = grid;
35
+ this._t = { ...DEFAULT_TIMINGS, ...(timings ?? {}) };
36
+ }
37
+
38
+ /** PURE: ordered animation descriptors for a cascade step. */
39
+ plan(step: CascadeStepData, opts?: { turbo?: boolean }): CascadeAnim[] {
40
+ const f = opts?.turbo ? 0.5 : 1;
41
+ const out: CascadeAnim[] = [];
42
+
43
+ for (const w of step.winningCells) {
44
+ out.push({ col: w.col, row: w.row, phase: 'highlight', scale: 1.08, duration: this._t.highlight * f, easing: 'easeOutQuad' });
45
+ }
46
+ for (const w of step.winningCells) {
47
+ out.push({ col: w.col, row: w.row, phase: 'remove', scale: 0, alpha: 0, duration: this._t.remove * f, easing: 'easeInBack' });
48
+ }
49
+ // new cells drop from two row-heights above their target, staggered per column.
50
+ // Row height is derived purely from public geometry (no private grid access).
51
+ const rowStep = this._grid.cellPosition(0, 1).y - this._grid.cellPosition(0, 0).y;
52
+ const perCol: Record<number, number> = {};
53
+ for (const n of step.newCells) {
54
+ const to = this._grid.cellPosition(n.col, n.row);
55
+ const from = { x: to.x, y: to.y - rowStep * 2 };
56
+ const idx = (perCol[n.col] = (perCol[n.col] ?? 0) + 1);
57
+ out.push({ col: n.col, row: n.row, phase: 'drop', from, to, duration: this._t.drop * f, easing: 'easeOutBounce', delay: idx * 30 * f });
58
+ }
59
+ return out;
60
+ }
61
+
62
+ /** Execute the plan via Tween. Not unit-tested (Ticker doesn't tick in node). */
63
+ async run(step: CascadeStepData, opts?: { turbo?: boolean }): Promise<void> {
64
+ this._killed = false;
65
+ const plan = this.plan(step, opts);
66
+ // highlight + remove first
67
+ for (const a of plan.filter((p) => p.phase === 'highlight')) {
68
+ if (this._killed) return;
69
+ const cell = this._grid.getCell(a.col, a.row);
70
+ cell.setState({ winning: true });
71
+ await Tween.to(cell, { 'scale.x': a.scale!, 'scale.y': a.scale! }, a.duration, EASING_BY_NAME[a.easing ?? 'easeOutQuad']);
72
+ }
73
+ for (const a of plan.filter((p) => p.phase === 'remove')) {
74
+ if (this._killed) return;
75
+ const cell = this._grid.getCell(a.col, a.row);
76
+ await Tween.to(cell, { 'scale.x': 0, 'scale.y': 0, alpha: 0 }, a.duration, EASING_BY_NAME[a.easing ?? 'easeInBack']);
77
+ }
78
+ // settle data, then drop new cells in
79
+ this._grid.setGrid(step.settledGrid);
80
+ await Promise.all(
81
+ plan.filter((p) => p.phase === 'drop').map(async (a) => {
82
+ if (this._killed) return;
83
+ const cell = this._grid.getCell(a.col, a.row);
84
+ cell.alpha = 1; cell.scale.set(1);
85
+ cell.position.set(a.from!.x, a.from!.y);
86
+ if (a.delay) await Tween.delay(a.delay);
87
+ await Tween.to(cell, { 'position.y': a.to!.y }, a.duration, EASING_BY_NAME[a.easing ?? 'easeOutBounce']);
88
+ }),
89
+ );
90
+ }
91
+
92
+ private _killOwnTweens(): void {
93
+ for (let c = 0; c < this._grid.cols; c++) {
94
+ for (let r = 0; r < this._grid.rows; r++) {
95
+ Tween.killTweensOf(this._grid.getCell(c, r));
96
+ }
97
+ }
98
+ }
99
+
100
+ skip(): void { this._killOwnTweens(); }
101
+ kill(): void { this._killed = true; this._killOwnTweens(); }
102
+ }
@@ -0,0 +1,81 @@
1
+ // packages/game-engine/src/slot/anim/ReelSpinController.ts
2
+ import { Tween, Easing } from '../../animation';
3
+ import type { ReelGrid } from '../grid/ReelGrid';
4
+ import type { CellData } from '../grid/SymbolCell';
5
+
6
+ export interface ReelSpinData { targetGrid: CellData[][]; strip?: (reel: number) => string[]; }
7
+ export interface ReelSpinTimings { spinUp: number; hold: number; stopStagger: number; settle: number; }
8
+ export interface ReelStopPlan {
9
+ reel: number;
10
+ stopTime: number;
11
+ landing: CellData[];
12
+ settle: { amp: number; ms: number };
13
+ }
14
+
15
+ const DEFAULT_TIMINGS: ReelSpinTimings = { spinUp: 500, hold: 200, stopStagger: 120, settle: 240 };
16
+
17
+ export class ReelSpinController {
18
+ private _grid: ReelGrid;
19
+ private _t: ReelSpinTimings;
20
+ private _killed = false;
21
+
22
+ constructor(grid: ReelGrid, timings?: Partial<ReelSpinTimings>) {
23
+ this._grid = grid;
24
+ this._t = { ...DEFAULT_TIMINGS, ...(timings ?? {}) };
25
+ }
26
+
27
+ /** PURE: per-reel stop timing + landing window. No Pixi mutation. */
28
+ plan(data: ReelSpinData, opts?: { turbo?: boolean }): ReelStopPlan[] {
29
+ const f = opts?.turbo ? 0.5 : 1;
30
+ const out: ReelStopPlan[] = [];
31
+ for (let reel = 0; reel < this._grid.cols; reel++) {
32
+ out.push({
33
+ reel,
34
+ stopTime: this._t.spinUp * f + reel * this._t.stopStagger * f,
35
+ landing: data.targetGrid[reel] ?? [],
36
+ settle: { amp: 7, ms: this._t.settle * f },
37
+ });
38
+ }
39
+ return out;
40
+ }
41
+
42
+ /** Execute the spin: scroll each reel, decelerate, land on target, settle-bounce. Not unit-tested. */
43
+ async run(data: ReelSpinData, opts?: { turbo?: boolean }): Promise<void> {
44
+ this._killed = false;
45
+ const plan = this.plan(data, opts);
46
+ await Promise.all(
47
+ plan.map(async (p) => {
48
+ if (this._killed) return;
49
+ const strip = data.strip?.(p.reel) ?? p.landing.map((c) => c.symbol ?? '');
50
+ // texture-swap spin: cycle symbols quickly while decelerating, then land
51
+ const cells = Array.from({ length: this._grid.rows }, (_, r) => this._grid.getCell(p.reel, r));
52
+ const ticks = Math.max(6, Math.floor(p.stopTime / 60));
53
+ for (let i = 0; i < ticks; i++) {
54
+ if (this._killed) break;
55
+ for (let r = 0; r < cells.length; r++) {
56
+ const sym = strip[(i + r) % strip.length] || null;
57
+ cells[r].setData({ symbol: sym });
58
+ }
59
+ await Tween.delay(Math.min(60, p.stopTime / ticks));
60
+ }
61
+ // land on the real target
62
+ for (let r = 0; r < cells.length; r++) cells[r].setData(p.landing[r] ?? { symbol: null });
63
+ // settle bounce on the column parent
64
+ if (!this._killed && cells[0]?.parent) {
65
+ const colY = cells[0].parent.y;
66
+ await Tween.fromTo(cells[0].parent, { y: colY - p.settle.amp }, { y: colY }, p.settle.ms, Easing.easeOutBack);
67
+ }
68
+ }),
69
+ );
70
+ }
71
+
72
+ private _killOwnTweens(): void {
73
+ for (let c = 0; c < this._grid.cols; c++) {
74
+ for (let r = 0; r < this._grid.rows; r++) {
75
+ Tween.killTweensOf(this._grid.getCell(c, r));
76
+ }
77
+ }
78
+ }
79
+
80
+ skip(): void { this._killed = true; this._killOwnTweens(); }
81
+ }
@@ -0,0 +1,14 @@
1
+ // packages/game-engine/src/slot/anim/easing-map.ts
2
+ import { Easing } from '../../animation';
3
+ import type { EasingFunction } from '../../types';
4
+
5
+ /** Resolve a descriptor's string easing name to the engine's easing function. */
6
+ export const EASING_BY_NAME: Record<string, EasingFunction> = {
7
+ linear: Easing.linear,
8
+ easeOutQuad: Easing.easeOutQuad,
9
+ easeOutCubic: Easing.easeOutCubic,
10
+ easeOutBack: Easing.easeOutBack,
11
+ easeOutBounce: Easing.easeOutBounce,
12
+ easeInBack: Easing.easeInBack,
13
+ easeInOutCubic: Easing.easeInOutCubic,
14
+ };
@@ -0,0 +1,40 @@
1
+ import type { SlotSpinResultBase } from '@energy8platform/platform-core/slot-result';
2
+
3
+ export interface FreeSpinsSessionConfig {
4
+ initialSpins: number;
5
+ /** Optional: extra spins to award from a result (retrigger). Default: none. */
6
+ retrigger?: (result: SlotSpinResultBase) => number;
7
+ /** Optional hard exit (e.g. max-win reached). */
8
+ isMaxWin?: () => boolean;
9
+ }
10
+
11
+ /** Headless free-spins state machine. The scene drives it; rendering/HUD reflect it. */
12
+ export class FreeSpinsSession {
13
+ remaining: number;
14
+ total: number;
15
+ totalWin = 0;
16
+ private readonly cfg: FreeSpinsSessionConfig;
17
+
18
+ constructor(cfg: FreeSpinsSessionConfig) {
19
+ this.cfg = cfg;
20
+ this.remaining = cfg.initialSpins;
21
+ this.total = cfg.initialSpins;
22
+ }
23
+
24
+ award(extra: number): void {
25
+ if (extra > 0) { this.remaining += extra; this.total += extra; }
26
+ }
27
+
28
+ /** Convenience: award using the configured retrigger rule. */
29
+ applyRetrigger(result: SlotSpinResultBase): void {
30
+ this.award(this.cfg.retrigger?.(result) ?? 0);
31
+ }
32
+
33
+ addWin(amount: number): void { this.totalWin += amount; }
34
+
35
+ consume(): void { if (this.remaining > 0) this.remaining -= 1; }
36
+
37
+ get isComplete(): boolean {
38
+ return this.remaining <= 0 || (this.cfg.isMaxWin?.() ?? false);
39
+ }
40
+ }
@@ -0,0 +1,68 @@
1
+ import { Container, Sprite, AnimatedSprite, type Texture } from 'pixi.js';
2
+ import { SpriteAnimation } from '../../animation';
3
+ import type { SymbolView } from './SymbolView';
4
+
5
+ export interface SymbolTextures { base: Texture; idle?: Texture[]; win?: Texture[]; }
6
+ export interface AnimatedSymbolConfig { textures: SymbolTextures; size: number; fps?: number; }
7
+
8
+ /** Built-in SymbolView: a static base sprite with optional idle/win spritesheet frames. */
9
+ export class AnimatedSymbol extends Container implements SymbolView {
10
+ private _base: Sprite;
11
+ private _anim: AnimatedSprite | null = null;
12
+ private _textures: SymbolTextures;
13
+ private _size: number;
14
+ private _fps: number;
15
+
16
+ constructor(config: AnimatedSymbolConfig) {
17
+ super();
18
+ this._textures = config.textures;
19
+ this._size = config.size;
20
+ this._fps = config.fps ?? 24;
21
+ this._base = new Sprite(config.textures.base);
22
+ this._base.anchor.set(0.5);
23
+ this.addChild(this._base);
24
+ this.resize(this._size);
25
+ }
26
+
27
+ setTextures(t: SymbolTextures): void {
28
+ this._textures = t;
29
+ this._base.texture = t.base;
30
+ this.showStatic();
31
+ }
32
+
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; }
38
+ }
39
+
40
+ showStatic(): void {
41
+ if (this._anim) { this._anim.destroy(); this._anim = null; }
42
+ this._base.visible = true;
43
+ }
44
+
45
+ playIdle(): void {
46
+ if (!this._textures.idle?.length) return;
47
+ this._swap(this._textures.idle, true);
48
+ }
49
+
50
+ playWin(): Promise<void> {
51
+ if (!this._textures.win?.length) return Promise.resolve();
52
+ return new Promise<void>((resolve) => {
53
+ this._swap(this._textures.win!, false, () => { this.showStatic(); resolve(); });
54
+ });
55
+ }
56
+
57
+ private _swap(frames: Texture[], loop: boolean, onComplete?: () => void): void {
58
+ if (this._anim) { this._anim.destroy(); this._anim = null; }
59
+ this._base.visible = false;
60
+ const a = SpriteAnimation.create(frames, { loop, autoPlay: true, onComplete });
61
+ a.anchor.set(0.5);
62
+ a.width = this._size;
63
+ a.height = this._size;
64
+ a.animationSpeed = this._fps / 60;
65
+ this.addChild(a);
66
+ this._anim = a;
67
+ }
68
+ }
@@ -0,0 +1,92 @@
1
+ import { Container, Graphics, Sprite, type Texture } from 'pixi.js';
2
+ import { SymbolCell, type CellData, type CellFrameStyle } from './SymbolCell';
3
+ import type { SymbolResolver } from './SymbolView';
4
+
5
+ export interface DecorationConfig { texture?: Texture; padding?: number; }
6
+ export interface ReelGridConfig {
7
+ cols: number; rows: number; cellSize: number; gap?: number;
8
+ resolve: SymbolResolver; frameStyle?: CellFrameStyle;
9
+ decoration?: DecorationConfig;
10
+ mask?: boolean;
11
+ }
12
+
13
+ export class ReelGrid extends Container {
14
+ readonly __uiComponent = true as const;
15
+
16
+ private _cols: number;
17
+ private _rows: number;
18
+ private _cellSize: number;
19
+ private _gap: number;
20
+ private _cells: SymbolCell[][] = [];
21
+ private _cellLayer = new Container();
22
+
23
+ constructor(config: ReelGridConfig) {
24
+ super();
25
+ this._cols = config.cols;
26
+ this._rows = config.rows;
27
+ this._cellSize = config.cellSize;
28
+ this._gap = config.gap ?? 0;
29
+
30
+ if (config.decoration) {
31
+ const pad = config.decoration.padding ?? 0;
32
+ const w = this._cols * (this._cellSize + this._gap) - this._gap + pad * 2;
33
+ const h = this._rows * (this._cellSize + this._gap) - this._gap + pad * 2;
34
+ if (config.decoration.texture) {
35
+ const deco = new Sprite(config.decoration.texture);
36
+ deco.width = w; deco.height = h; deco.position.set(-pad - this._cellSize / 2, -pad - this._cellSize / 2);
37
+ this.addChild(deco);
38
+ }
39
+ }
40
+
41
+ this.addChild(this._cellLayer);
42
+
43
+ for (let c = 0; c < this._cols; c++) {
44
+ this._cells[c] = [];
45
+ for (let r = 0; r < this._rows; r++) {
46
+ const cell = new SymbolCell({ size: this._cellSize, resolve: config.resolve, frameStyle: config.frameStyle });
47
+ const { x, y } = this.cellPosition(c, r);
48
+ cell.position.set(x, y);
49
+ this._cellLayer.addChild(cell);
50
+ this._cells[c][r] = cell;
51
+ }
52
+ }
53
+
54
+ if (config.mask) {
55
+ const w = this._cols * (this._cellSize + this._gap) - this._gap;
56
+ const h = this._rows * (this._cellSize + this._gap) - this._gap;
57
+ const m = new Graphics()
58
+ .rect(-this._cellSize / 2, -this._cellSize / 2, w, h)
59
+ .fill(0xffffff);
60
+ this._cellLayer.mask = m;
61
+ this.addChild(m);
62
+ }
63
+ }
64
+
65
+ get cols(): number { return this._cols; }
66
+ get rows(): number { return this._rows; }
67
+
68
+ cellPosition(col: number, row: number): { x: number; y: number } {
69
+ const step = this._cellSize + this._gap;
70
+ return { x: col * step, y: row * step };
71
+ }
72
+
73
+ getCell(col: number, row: number): SymbolCell { return this._cells[col][row]; }
74
+
75
+ setGrid(cells: CellData[][]): void {
76
+ for (let c = 0; c < this._cols; c++) {
77
+ for (let r = 0; r < this._rows; r++) {
78
+ this._cells[c]?.[r]?.setData(cells[c]?.[r] ?? { symbol: null });
79
+ }
80
+ }
81
+ }
82
+
83
+ resize(cellSize: number): void {
84
+ this._cellSize = cellSize;
85
+ for (let c = 0; c < this._cols; c++) {
86
+ for (let r = 0; r < this._rows; r++) {
87
+ const { x, y } = this.cellPosition(c, r);
88
+ this._cells[c][r].position.set(x, y);
89
+ }
90
+ }
91
+ }
92
+ }
@@ -0,0 +1,127 @@
1
+ import { Container, Graphics, Text } from 'pixi.js';
2
+ import { Tween, Easing } from '../../animation';
3
+ import type { SymbolResolver, SymbolView } from './SymbolView';
4
+
5
+ export interface CellFrameStyle {
6
+ radius?: number;
7
+ idle?: { color: number; alpha: number };
8
+ winning?: { color: number; alpha: number };
9
+ removed?: { color: number; alpha: number };
10
+ fresh?: { color: number; alpha: number };
11
+ }
12
+ export interface CellData {
13
+ symbol: string | null;
14
+ multiplier?: number;
15
+ bonus?: number;
16
+ sticky?: { remaining: number };
17
+ }
18
+ export interface CellState { winning?: boolean; removed?: boolean; fresh?: boolean; }
19
+ export interface SymbolCellConfig { size: number; resolve: SymbolResolver; frameStyle?: CellFrameStyle; }
20
+
21
+ const DEFAULT_STYLE: Required<CellFrameStyle> = {
22
+ radius: 8,
23
+ idle: { color: 0x223047, alpha: 0.34 },
24
+ winning: { color: 0x00d4ff, alpha: 0.95 },
25
+ removed: { color: 0x223047, alpha: 0.12 },
26
+ fresh: { color: 0xffffff, alpha: 0.6 },
27
+ };
28
+
29
+ export class SymbolCell extends Container {
30
+ readonly __uiComponent = true as const;
31
+
32
+ private _size: number;
33
+ private _resolve: SymbolResolver;
34
+ private _style: Required<CellFrameStyle>;
35
+ private _frame: Graphics;
36
+ private _view: SymbolView | null = null;
37
+ private _badges = new Container();
38
+ private _multBadge: Container | null = null;
39
+ private _bonusBadge: Container | null = null;
40
+ /** Last applied state key — exposed for tests/inspection. */
41
+ frameStyleKey: 'idle' | 'winning' | 'removed' | 'fresh' = 'idle';
42
+
43
+ constructor(config: SymbolCellConfig) {
44
+ super();
45
+ this._size = config.size;
46
+ this._resolve = config.resolve;
47
+ this._style = { ...DEFAULT_STYLE, ...(config.frameStyle ?? {}) } as Required<CellFrameStyle>;
48
+ this._frame = new Graphics();
49
+ this.addChild(this._frame);
50
+ this.addChild(this._badges);
51
+ this._drawFrame('idle');
52
+ }
53
+
54
+ get view(): SymbolView | null { return this._view; }
55
+
56
+ setData(data: CellData): void {
57
+ // symbol view
58
+ if (data.symbol == null) {
59
+ if (this._view) { this._view.destroy(); this._view = null; }
60
+ } else {
61
+ if (this._view) { this._view.destroy(); this._view = null; }
62
+ const v = this._resolve(data.symbol);
63
+ if (v) {
64
+ v.resize?.(this._size);
65
+ this.addChildAt(v, 1); // above frame, below badges
66
+ this._view = v;
67
+ }
68
+ }
69
+ // badges
70
+ this._setMultiplier(data.multiplier);
71
+ this._setBonus(data.bonus);
72
+ }
73
+
74
+ setState(state: CellState): void {
75
+ const key = state.winning ? 'winning' : state.removed ? 'removed' : state.fresh ? 'fresh' : 'idle';
76
+ this.frameStyleKey = key;
77
+ this._drawFrame(key);
78
+ }
79
+
80
+ playWin(): Promise<void> {
81
+ if (this._view?.playWin) return this._view.playWin();
82
+ // default: scale pop
83
+ const target = this._view ?? this;
84
+ return Tween.to(target, { 'scale.x': 1.15, 'scale.y': 1.15 }, 160, Easing.easeOutBack)
85
+ .then(() => Tween.to(target, { 'scale.x': 1, 'scale.y': 1 }, 140, Easing.easeOutQuad));
86
+ }
87
+
88
+ playIdle(): void { this._view?.playIdle?.(); }
89
+
90
+ hasBadge(kind: 'multiplier' | 'bonus'): boolean {
91
+ return kind === 'multiplier' ? this._multBadge != null : this._bonusBadge != null;
92
+ }
93
+
94
+ private _drawFrame(key: 'idle' | 'winning' | 'removed' | 'fresh'): void {
95
+ const s = this._style[key];
96
+ this._frame.clear();
97
+ this._frame
98
+ .roundRect(-this._size / 2, -this._size / 2, this._size, this._size, this._style.radius)
99
+ .fill({ color: s.color, alpha: s.alpha });
100
+ // store the colour as tint for cheap inspection/testing
101
+ this._frame.tint = s.color;
102
+ }
103
+
104
+ private _setMultiplier(value?: number): void {
105
+ if (this._multBadge) { this._multBadge.destroy(); this._multBadge = null; }
106
+ if (!value || value <= 1) return;
107
+ this._multBadge = this._badge(`×${value}`, 0xffd24a);
108
+ this._multBadge.position.set(this._size / 2 - 12, -this._size / 2 + 12);
109
+ this._badges.addChild(this._multBadge);
110
+ }
111
+
112
+ private _setBonus(value?: number): void {
113
+ if (this._bonusBadge) { this._bonusBadge.destroy(); this._bonusBadge = null; }
114
+ if (!value || value <= 0) return;
115
+ this._bonusBadge = this._badge(`+${value}`, 0x7ad7ff);
116
+ this._bonusBadge.position.set(-this._size / 2 + 12, -this._size / 2 + 12);
117
+ this._badges.addChild(this._bonusBadge);
118
+ }
119
+
120
+ private _badge(label: string, color: number): Container {
121
+ const c = new Container();
122
+ const t = new Text({ text: label, style: { fontSize: 18, fill: color, fontWeight: '700' } });
123
+ t.anchor.set(0.5);
124
+ c.addChild(t);
125
+ return c;
126
+ }
127
+ }