@energy8platform/game-engine 0.20.0 → 0.22.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 (93) hide show
  1. package/README.md +121 -0
  2. package/dist/animation.cjs.js +18 -3
  3. package/dist/animation.cjs.js.map +1 -1
  4. package/dist/animation.esm.js +18 -3
  5. package/dist/animation.esm.js.map +1 -1
  6. package/dist/core.cjs.js +18 -3
  7. package/dist/core.cjs.js.map +1 -1
  8. package/dist/core.esm.js +18 -3
  9. package/dist/core.esm.js.map +1 -1
  10. package/dist/host.cjs.js +18 -3
  11. package/dist/host.cjs.js.map +1 -1
  12. package/dist/host.d.ts +3 -5
  13. package/dist/host.esm.js +18 -3
  14. package/dist/host.esm.js.map +1 -1
  15. package/dist/index.cjs.js +18 -2535
  16. package/dist/index.cjs.js.map +1 -1
  17. package/dist/index.d.ts +3 -1020
  18. package/dist/index.esm.js +20 -2525
  19. package/dist/index.esm.js.map +1 -1
  20. package/dist/slot.cjs.js +1872 -61
  21. package/dist/slot.cjs.js.map +1 -1
  22. package/dist/slot.d.ts +555 -28
  23. package/dist/slot.esm.js +1857 -62
  24. package/dist/slot.esm.js.map +1 -1
  25. package/dist/vite.cjs.js +0 -5
  26. package/dist/vite.cjs.js.map +1 -1
  27. package/dist/vite.esm.js +0 -5
  28. package/dist/vite.esm.js.map +1 -1
  29. package/package.json +2 -35
  30. package/src/animation/Tween.ts +14 -3
  31. package/src/host/index.ts +0 -1
  32. package/src/host/types.ts +0 -3
  33. package/src/index.ts +0 -28
  34. package/src/slot/anim/easing-map.ts +16 -2
  35. package/src/slot/cascade/TumbleController.ts +230 -0
  36. package/src/slot/config/ReelSystemConfig.ts +559 -0
  37. package/src/slot/config/presets.ts +222 -0
  38. package/src/slot/features/extra.ts +189 -0
  39. package/src/slot/features/index.ts +44 -0
  40. package/src/slot/features/symbols.ts +183 -0
  41. package/src/slot/features/types.ts +136 -0
  42. package/src/slot/features/wilds.ts +151 -0
  43. package/src/slot/grid/ReelGrid.ts +93 -24
  44. package/src/slot/grid/SymbolCell.ts +45 -11
  45. package/src/slot/index.ts +61 -2
  46. package/src/slot/motion/AnticipationController.ts +96 -0
  47. package/src/slot/motion/SpinEngine.ts +384 -0
  48. package/src/slot/system/ReelSystem.ts +295 -0
  49. package/src/vite/index.ts +0 -5
  50. package/dist/react-jsx.cjs.js +0 -3
  51. package/dist/react-jsx.cjs.js.map +0 -1
  52. package/dist/react-jsx.d.ts +0 -602
  53. package/dist/react-jsx.esm.js +0 -2
  54. package/dist/react-jsx.esm.js.map +0 -1
  55. package/dist/react.cjs.js +0 -3787
  56. package/dist/react.cjs.js.map +0 -1
  57. package/dist/react.d.ts +0 -1467
  58. package/dist/react.esm.js +0 -3771
  59. package/dist/react.esm.js.map +0 -1
  60. package/dist/ui.cjs.js +0 -2999
  61. package/dist/ui.cjs.js.map +0 -1
  62. package/dist/ui.d.ts +0 -1116
  63. package/dist/ui.esm.js +0 -2983
  64. package/dist/ui.esm.js.map +0 -1
  65. package/src/react/EngineContext.ts +0 -26
  66. package/src/react/ReactScene.ts +0 -88
  67. package/src/react/applyProps.ts +0 -271
  68. package/src/react/catalogue.ts +0 -17
  69. package/src/react/createPixiRoot.ts +0 -31
  70. package/src/react/extendAll.ts +0 -74
  71. package/src/react/hooks.ts +0 -46
  72. package/src/react/index.ts +0 -29
  73. package/src/react/jsx-runtime.ts +0 -346
  74. package/src/react/reconciler.ts +0 -338
  75. package/src/slot/freeSpins/FreeSpinsSession.ts +0 -40
  76. package/src/state/StateMachine.ts +0 -231
  77. package/src/state/index.ts +0 -1
  78. package/src/ui/BalanceDisplay.ts +0 -159
  79. package/src/ui/Button.ts +0 -304
  80. package/src/ui/FlexContainer.ts +0 -775
  81. package/src/ui/Label.ts +0 -124
  82. package/src/ui/LabelValue.ts +0 -122
  83. package/src/ui/Layout.ts +0 -291
  84. package/src/ui/Modal.ts +0 -170
  85. package/src/ui/Panel.ts +0 -204
  86. package/src/ui/ProgressBar.ts +0 -170
  87. package/src/ui/ScrollContainer.ts +0 -478
  88. package/src/ui/Slider.ts +0 -241
  89. package/src/ui/Toast.ts +0 -150
  90. package/src/ui/Toggle.ts +0 -201
  91. package/src/ui/WinDisplay.ts +0 -145
  92. package/src/ui/index.ts +0 -33
  93. package/src/ui/view.ts +0 -28
@@ -0,0 +1,222 @@
1
+ // packages/game-engine/src/slot/config/presets.ts
2
+ //
3
+ // Reel presets distilled from our shipped games (see docs/reels-analysis-and-design.md §2).
4
+ // Each is a DeepPartial override on DEFAULT_REEL_CONFIG.
5
+
6
+ import type { DeepPartial, ReelSystemConfig } from './ReelSystemConfig';
7
+
8
+ export type PresetId =
9
+ | 'classic'
10
+ | 'kitsune-wrath'
11
+ | 'moon-spice'
12
+ | 'stone-rush'
13
+ | 'hot-ross'
14
+ | 'magnus'
15
+ | 'megaways';
16
+
17
+ export interface ReelPreset {
18
+ id: PresetId;
19
+ name: string;
20
+ note: string;
21
+ config: DeepPartial<ReelSystemConfig>;
22
+ }
23
+
24
+ export const PRESETS: Record<PresetId, ReelPreset> = {
25
+ classic: {
26
+ id: 'classic',
27
+ name: 'Classic 5×3 lines',
28
+ note: 'Plain spinning reels, sequential stops, settle bounce.',
29
+ config: {
30
+ grid: { cols: 5, rows: 3, evaluation: 'lines', cellSize: 96, gap: 6 },
31
+ motion: {
32
+ style: 'strip',
33
+ spinUp: 600,
34
+ stopStagger: 140,
35
+ blur: { enabled: true, alpha: 0.85, strength: 6, streaks: false },
36
+ },
37
+ },
38
+ },
39
+ 'kitsune-wrath': {
40
+ id: 'kitsune-wrath',
41
+ name: 'Kitsune Wrath 7×7 cluster',
42
+ note: 'Tumble grid, sticky wilds, position multipliers, scatter anticipation.',
43
+ config: {
44
+ grid: { cols: 7, rows: 7, evaluation: 'cluster', cellSize: 66, gap: 0 },
45
+ motion: {
46
+ style: 'cascade-drop',
47
+ spinUp: 400,
48
+ squash: { enabled: true, scaleX: 1.12, scaleY: 0.88, ms: 90 },
49
+ },
50
+ cascade: {
51
+ enabled: true,
52
+ gravity: true,
53
+ dimNonWinners: true,
54
+ multiplier: {
55
+ enabled: true,
56
+ start: 1,
57
+ mode: 'mul',
58
+ step: 2,
59
+ cap: 128,
60
+ persistInFreeSpins: false,
61
+ },
62
+ },
63
+ anticipation: { enabled: true, triggerSymbols: ['scatter'], threshold: 4 },
64
+ features: {
65
+ sticky: { enabled: true, durationSpins: 3 },
66
+ multiplier: { enabled: true, scope: 'perSymbol', combine: 'multiplicative', max: 128 },
67
+ },
68
+ },
69
+ },
70
+ 'moon-spice': {
71
+ id: 'moon-spice',
72
+ name: 'Moon Spice 5×4 ways',
73
+ note: 'Spin + cascade refill, frame shake on wild/scatter, 1024 ways.',
74
+ config: {
75
+ grid: { cols: 5, rows: 4, evaluation: 'ways', cellSize: 90, gap: 4 },
76
+ motion: {
77
+ style: 'strip',
78
+ spinUp: 1200,
79
+ stopStagger: 120,
80
+ settle: { amp: 6, ms: 220, easing: 'easeOutQuad' },
81
+ },
82
+ cascade: {
83
+ enabled: true,
84
+ gravity: true,
85
+ easings: { highlight: 'easeOutQuad', remove: 'easeInBack', drop: 'easeOutBounce' },
86
+ },
87
+ win: { frameShake: { enabled: true, amp: 2.2, ms: 180, onlyOnSymbols: ['wild', 'scatter'] } },
88
+ features: { multiplier: { enabled: true, scope: 'perSymbol', combine: 'additive' } },
89
+ },
90
+ },
91
+ 'stone-rush': {
92
+ id: 'stone-rush',
93
+ name: 'Stone Rush 7×7 tumble',
94
+ note: 'Weighty gravity with squash/stretch, per-step deceleration, multiplier orbs.',
95
+ config: {
96
+ grid: { cols: 7, rows: 7, evaluation: 'cluster', cellSize: 66, gap: 8 },
97
+ motion: {
98
+ style: 'cascade-drop',
99
+ spinUp: 320,
100
+ squash: { enabled: true, scaleX: 1.3, scaleY: 0.7, ms: 110 },
101
+ },
102
+ cascade: {
103
+ enabled: true,
104
+ gravity: true,
105
+ perStepDecel: 0.08,
106
+ perStepDecelCap: 1.5,
107
+ multiplier: {
108
+ enabled: true,
109
+ start: 1,
110
+ mode: 'add',
111
+ step: 1,
112
+ cap: 100,
113
+ persistInFreeSpins: true,
114
+ },
115
+ },
116
+ features: {
117
+ multiplier: { enabled: true, scope: 'global', combine: 'additive', max: 100 },
118
+ expandingWild: { enabled: true, symbol: 'wild', toFullReel: false },
119
+ },
120
+ },
121
+ },
122
+ 'hot-ross': {
123
+ id: 'hot-ross',
124
+ name: 'Hot Ross 5×5 lines',
125
+ note: 'Classic strip spin with motion blur, scatter tease, expanding wilds, sticky reels.',
126
+ config: {
127
+ grid: { cols: 5, rows: 5, evaluation: 'lines', cellSize: 88, gap: 4 },
128
+ motion: {
129
+ style: 'strip',
130
+ spinUp: 1650,
131
+ stopStagger: 210,
132
+ blur: { enabled: true, alpha: 0.82, strength: 8, streaks: true },
133
+ settle: { amp: 7, ms: 240, easing: 'easeOutBack' },
134
+ },
135
+ anticipation: {
136
+ enabled: true,
137
+ triggerSymbols: ['scatter'],
138
+ threshold: 2,
139
+ slowdownFactor: 0.28,
140
+ holdMs: 450,
141
+ },
142
+ features: {
143
+ expandingWild: { enabled: true, symbol: 'wild', reels: [1, 2, 3], toFullReel: true },
144
+ sticky: { enabled: true, durationSpins: 0 },
145
+ },
146
+ },
147
+ },
148
+ magnus: {
149
+ id: 'magnus',
150
+ name: 'Magnus 6×6 transmute',
151
+ note: 'Cascade with idle-breathing symbols, scatter anticipation + reel zoom, transform.',
152
+ config: {
153
+ grid: { cols: 6, rows: 6, evaluation: 'cluster', cellSize: 74, gap: 2 },
154
+ motion: {
155
+ style: 'cascade-drop',
156
+ spinUp: 400,
157
+ squash: { enabled: true, scaleX: 1.14, scaleY: 0.86, ms: 110 },
158
+ },
159
+ cascade: {
160
+ enabled: true,
161
+ gravity: true,
162
+ multiplier: {
163
+ enabled: true,
164
+ start: 1,
165
+ mode: 'add',
166
+ step: 1,
167
+ cap: null,
168
+ persistInFreeSpins: true,
169
+ },
170
+ },
171
+ anticipation: {
172
+ enabled: true,
173
+ triggerSymbols: ['scatter'],
174
+ threshold: 2,
175
+ holdMs: 280,
176
+ zoom: { enabled: true, scale: 1.3, ms: 600 },
177
+ },
178
+ features: {
179
+ transform: {
180
+ enabled: true,
181
+ source: 'randomLow',
182
+ target: 'h1',
183
+ allInstances: true,
184
+ upgradeOnly: true,
185
+ },
186
+ },
187
+ },
188
+ },
189
+ megaways: {
190
+ id: 'megaways',
191
+ name: 'Megaways 6× (2–7)',
192
+ note: 'Variable per-reel heights, tumble + climbing multiplier, anticipation.',
193
+ config: {
194
+ grid: {
195
+ cols: 6,
196
+ rows: 4,
197
+ rowsPerReel: [4, 6, 3, 7, 5, 2],
198
+ evaluation: 'megaways',
199
+ cellSize: 74,
200
+ gap: 4,
201
+ minRows: 2,
202
+ maxRows: 7,
203
+ },
204
+ motion: { style: 'strip', spinUp: 900, stopStagger: 130 },
205
+ cascade: {
206
+ enabled: true,
207
+ gravity: true,
208
+ multiplier: {
209
+ enabled: true,
210
+ start: 1,
211
+ mode: 'add',
212
+ step: 1,
213
+ cap: null,
214
+ persistInFreeSpins: true,
215
+ },
216
+ },
217
+ anticipation: { enabled: true, triggerSymbols: ['scatter'], threshold: 3 },
218
+ },
219
+ },
220
+ };
221
+
222
+ export const PRESET_LIST: ReelPreset[] = Object.values(PRESETS);
@@ -0,0 +1,189 @@
1
+ // packages/game-engine/src/slot/features/extra.ts
2
+ import { Tween } from '../../animation';
3
+ import { easingByName } from '../anim/easing-map';
4
+ import {
5
+ type FeatureContext,
6
+ type ReelFeature,
7
+ cellCenter,
8
+ dropCell,
9
+ floatLabel,
10
+ glowRing,
11
+ morphSymbol,
12
+ pickFromBoard,
13
+ pulseCell,
14
+ } from './types';
15
+
16
+ const seeded = (a: number, b: number, seed: number) =>
17
+ Math.min(b, a + Math.floor(((Math.sin(seed * 53.17) + 1) / 2) * (b - a + 1)));
18
+
19
+ /** Multiplier symbols: marked cells carry values that combine into the win. */
20
+ export const MultiplierSymbols: ReelFeature = {
21
+ key: 'multiplier',
22
+ label: 'Multiplier symbols',
23
+ enabled: (c) => c.features.multiplier.enabled,
24
+ async demo(ctx: FeatureContext) {
25
+ const f = ctx.cfg.features.multiplier;
26
+ const spots = pickFromBoard(ctx.board, () => true)
27
+ .filter((_, i) => i % 4 === 0)
28
+ .slice(0, 3);
29
+ const values = [2, 3, 5].slice(0, spots.length);
30
+ spots.forEach((p, i) =>
31
+ ctx.grid.getCell(p.col, p.row).setData({ symbol: f.symbol ?? 'wild', multiplier: values[i] }),
32
+ );
33
+ await Promise.all(spots.map((p) => pulseCell(ctx.grid.getCell(p.col, p.row), 1.2, 240)));
34
+ const total =
35
+ f.combine === 'additive'
36
+ ? values.reduce((a, b) => a + b, 0)
37
+ : values.reduce((a, b) => a * b, 1);
38
+ const capped = Math.min(total, f.max);
39
+ ctx.log?.(
40
+ `Multiplier (${f.combine}, ${f.scope}): ${values.join(f.combine === 'additive' ? ' + ' : ' × ')} = ×${capped}`,
41
+ );
42
+ // fly each value toward the centre, then show the combined total
43
+ await Promise.all(
44
+ spots.map(async (p) => {
45
+ const { x, y } = cellCenter(ctx.grid, p.col, p.row);
46
+ await floatLabel(ctx.fx, x, y, `×${values[spots.indexOf(p)]}`, 0xffd24a, 500);
47
+ }),
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;
51
+ await floatLabel(ctx.fx, cx, cy, `×${capped}`, 0xffe24a, 900);
52
+ },
53
+ };
54
+
55
+ /** Nudge / xNudge: a reel nudges one position (xNudge fills a stacked wild, +1 mult per nudge). */
56
+ export const NudgeReels: ReelFeature = {
57
+ key: 'nudge',
58
+ label: 'Nudge / xNudge',
59
+ enabled: (c) => c.features.nudge.enabled,
60
+ async demo(ctx: FeatureContext) {
61
+ const f = ctx.cfg.features.nudge;
62
+ const cols = ctx.grid.cols;
63
+ const reel = f.reels.length ? f.reels[0] : Math.floor(cols / 2);
64
+ const rows = ctx.grid.rowsOf(reel);
65
+ ctx.log?.(
66
+ f.toFullReel ? `xNudge: filling reel ${reel} with wild` : `Nudge reel ${reel} by ${f.step}`,
67
+ );
68
+ if (f.toFullReel) {
69
+ let mult = f.multiplierStart;
70
+ for (let r = 0; r < rows; r++) {
71
+ const cell = ctx.grid.getCell(reel, r);
72
+ await morphSymbol(cell, { symbol: 'wild', multiplier: mult > 1 ? mult : undefined }, 160);
73
+ mult += f.multiplierPerNudge;
74
+ }
75
+ const { x, y } = cellCenter(ctx.grid, reel, Math.floor(rows / 2));
76
+ await floatLabel(ctx.fx, x, y, `×${mult - f.multiplierPerNudge}`, 0xff7a3c, 800);
77
+ } else {
78
+ // shift the whole column down by `step` cells, then settle
79
+ const step = ctx.grid.cellPosition(reel, 1).y - ctx.grid.cellPosition(reel, 0).y;
80
+ const cells = Array.from({ length: rows }, (_, r) => ctx.grid.getCell(reel, r));
81
+ await Promise.all(
82
+ cells.map((c) =>
83
+ Tween.to(c, { 'position.y': c.y + step * f.step }, 240, easingByName('easeOutBack')),
84
+ ),
85
+ );
86
+ cells.forEach((c, r) =>
87
+ c.position.set(ctx.grid.cellPosition(reel, r).x, ctx.grid.cellPosition(reel, r).y),
88
+ );
89
+ }
90
+ },
91
+ };
92
+
93
+ /** Hold-and-spin / Hold & Win: special symbols lock and respins reset on each new lock. */
94
+ export const HoldAndSpin: ReelFeature = {
95
+ key: 'holdAndSpin',
96
+ label: 'Hold & Spin (respin)',
97
+ enabled: (c) => c.features.holdAndSpin.enabled,
98
+ async demo(ctx: FeatureContext) {
99
+ const f = ctx.cfg.features.holdAndSpin;
100
+ const all = pickFromBoard(ctx.board, () => true);
101
+ const sym = f.lockSymbols[0] ?? 'coin';
102
+ const locked = new Set<string>();
103
+ const lock = async (p: { col: number; row: number }, value: string) => {
104
+ const cell = ctx.grid.getCell(p.col, p.row);
105
+ cell.setData({ symbol: sym });
106
+ await dropCell(ctx.grid, cell, p.col, p.row, 240);
107
+ glowRing(ctx.fx, ctx.grid, p.col, p.row, 0xffcf5c);
108
+ const { x, y } = cellCenter(ctx.grid, p.col, p.row);
109
+ await floatLabel(ctx.fx, x, y, value, 0xffe24a, 500);
110
+ locked.add(`${p.col}:${p.row}`);
111
+ };
112
+ // trigger
113
+ const trigger = all.slice(0, f.triggerThreshold);
114
+ ctx.log?.(`Hold & Spin triggered with ${trigger.length} ${sym}`);
115
+ await Promise.all(trigger.map((p, i) => lock(p, `${(i + 1) * 5}`)));
116
+ let respins = f.respinsAwarded;
117
+ let round = 0;
118
+ while (respins > 0) {
119
+ round++;
120
+ const free = all.filter((p) => !locked.has(`${p.col}:${p.row}`));
121
+ const landed = free.filter((_, i) => seeded(0, 3, i + round * 3) === 0).slice(0, 2);
122
+ ctx.log?.(
123
+ `Respin ${round}: ${respins} left` + (landed.length ? ` — ${landed.length} new lock` : ''),
124
+ );
125
+ if (landed.length && f.resetOnNewSymbol) {
126
+ respins = f.respinsAwarded;
127
+ await Promise.all(landed.map((p) => lock(p, 'JP')));
128
+ } else respins--;
129
+ if (locked.size >= all.length) {
130
+ ctx.log?.(f.fullGridAwardsGrand ? 'Full grid — GRAND!' : 'Full grid');
131
+ break;
132
+ }
133
+ await Tween.delay(120);
134
+ }
135
+ ctx.log?.(`Hold & Spin: ${locked.size} locked`);
136
+ },
137
+ };
138
+
139
+ /** Random pre-spin reel modifiers (add rows, inject wilds, set giant, guaranteed wilds). */
140
+ export const ReelModifier: ReelFeature = {
141
+ key: 'reelModifier',
142
+ label: 'Random reel modifier',
143
+ enabled: (c) => c.features.reelModifier.enabled,
144
+ async demo(ctx: FeatureContext) {
145
+ const f = ctx.cfg.features.reelModifier;
146
+ const pool = f.pool.length
147
+ ? f.pool
148
+ : [{ effect: 'addWilds' as const, magnitude: 3, weight: 1 }];
149
+ const totalW = pool.reduce((a, m) => a + m.weight, 0);
150
+ let roll = ((Math.sin(ctx.grid.cols * 7.7) + 1) / 2) * totalW;
151
+ let mod = pool[0];
152
+ for (const m of pool) {
153
+ roll -= m.weight;
154
+ if (roll <= 0) {
155
+ mod = m;
156
+ break;
157
+ }
158
+ }
159
+ ctx.log?.(`Reel modifier: ${mod.effect} (+${mod.magnitude})`);
160
+ if (mod.effect === 'addRows') {
161
+ const next = ctx.grid.rowsPerReel.map((r) => r + mod.magnitude);
162
+ // grow the board (new cells on top) and keep the system config in sync so the new rows
163
+ // aren't blank and a later spin/rebuild doesn't revert the shape
164
+ for (let c = 0; c < ctx.board.length; c++) {
165
+ const col = ctx.board[c] ?? (ctx.board[c] = []);
166
+ const fill = col[0]?.symbol ?? 'h1';
167
+ for (let i = 0; i < mod.magnitude; i++) col.unshift({ symbol: fill });
168
+ }
169
+ ctx.cfg.grid.rowsPerReel = next;
170
+ ctx.grid.reshape(next);
171
+ ctx.grid.setGrid(ctx.board);
172
+ } else if (mod.effect === 'addWilds' || mod.effect === 'guaranteedWilds') {
173
+ const spots = pickFromBoard(ctx.board, () => true);
174
+ const chosen: { col: number; row: number }[] = [];
175
+ for (let i = 0; i < mod.magnitude && spots.length; i++)
176
+ chosen.push(spots.splice(seeded(0, spots.length - 1, i), 1)[0]);
177
+ await Promise.all(
178
+ chosen.map(async (p) => {
179
+ const cell = ctx.grid.getCell(p.col, p.row);
180
+ cell.setData({ symbol: 'wild' });
181
+ await dropCell(ctx.grid, cell, p.col, p.row, 280);
182
+ }),
183
+ );
184
+ } else if (mod.effect === 'setGiant') {
185
+ const cell = ctx.grid.getCell(0, 0);
186
+ await pulseCell(cell, 1.3, 300);
187
+ }
188
+ },
189
+ };
@@ -0,0 +1,44 @@
1
+ // packages/game-engine/src/slot/features/index.ts
2
+ import type { FeatureKey } from '../config/ReelSystemConfig';
3
+ import { FEATURE_KEYS } from '../config/ReelSystemConfig';
4
+ import type { ReelFeature } from './types';
5
+ import { ExpandingWild, StickySymbols, WalkingWild, RandomWild } from './wilds';
6
+ import {
7
+ MysterySymbols,
8
+ SymbolTransform,
9
+ GiantSymbol,
10
+ SplitSymbol,
11
+ StackedSymbols,
12
+ } from './symbols';
13
+ import { MultiplierSymbols, NudgeReels, HoldAndSpin, ReelModifier } from './extra';
14
+
15
+ export * from './types';
16
+ export { ExpandingWild, StickySymbols, WalkingWild, RandomWild } from './wilds';
17
+ export {
18
+ MysterySymbols,
19
+ SymbolTransform,
20
+ GiantSymbol,
21
+ SplitSymbol,
22
+ StackedSymbols,
23
+ } from './symbols';
24
+ export { MultiplierSymbols, NudgeReels, HoldAndSpin, ReelModifier } from './extra';
25
+
26
+ /** All feature modules keyed by their FeatureKey. */
27
+ export const FEATURES: Record<FeatureKey, ReelFeature> = {
28
+ expandingWild: ExpandingWild,
29
+ sticky: StickySymbols,
30
+ walkingWild: WalkingWild,
31
+ randomWild: RandomWild,
32
+ mystery: MysterySymbols,
33
+ transform: SymbolTransform,
34
+ giant: GiantSymbol,
35
+ split: SplitSymbol,
36
+ stacked: StackedSymbols,
37
+ multiplier: MultiplierSymbols,
38
+ nudge: NudgeReels,
39
+ holdAndSpin: HoldAndSpin,
40
+ reelModifier: ReelModifier,
41
+ };
42
+
43
+ /** Features in canonical resolve order (see docs §3.4). */
44
+ export const FEATURE_LIST: ReelFeature[] = FEATURE_KEYS.map((k) => FEATURES[k]);
@@ -0,0 +1,183 @@
1
+ // packages/game-engine/src/slot/features/symbols.ts
2
+ import { Container } from 'pixi.js';
3
+ import { Tween } from '../../animation';
4
+ import { easingByName } from '../anim/easing-map';
5
+ import {
6
+ type FeatureContext,
7
+ type ReelFeature,
8
+ cellCenter,
9
+ floatLabel,
10
+ morphSymbol,
11
+ pickFromBoard,
12
+ pulseCell,
13
+ rowStepOf,
14
+ } from './types';
15
+
16
+ const pick = <T>(arr: T[], seed: number): T =>
17
+ arr[Math.floor(((Math.sin(seed * 12.9898) + 1) / 2) * arr.length) % arr.length];
18
+
19
+ /** Mystery symbols: all mystery tiles reveal the SAME single random symbol per spin. */
20
+ export const MysterySymbols: ReelFeature = {
21
+ key: 'mystery',
22
+ label: 'Mystery symbols',
23
+ enabled: (c) => c.features.mystery.enabled,
24
+ async demo(ctx: FeatureContext) {
25
+ const f = ctx.cfg.features.mystery;
26
+ const spots = pickFromBoard(ctx.board, () => true)
27
+ .filter((_, i) => i % 3 === 0)
28
+ .slice(0, 5);
29
+ spots.forEach((p) => ctx.grid.getCell(p.col, p.row).setData({ symbol: f.symbol }));
30
+ await Promise.all(spots.map((p) => pulseCell(ctx.grid.getCell(p.col, p.row), 1.1, 200)));
31
+ const pool = f.revealPool.length ? f.revealPool : ['h1', 'h2', 'h3'];
32
+ const reveal = pick(pool, ctx.grid.cols + spots.length);
33
+ ctx.log?.(`Mystery: ${spots.length} tiles reveal "${reveal}"`);
34
+ await Promise.all(
35
+ spots.map((p) => morphSymbol(ctx.grid.getCell(p.col, p.row), { symbol: reveal }, f.ms)),
36
+ );
37
+ spots.forEach((p) => {
38
+ if (ctx.board[p.col]) ctx.board[p.col][p.row] = { symbol: reveal };
39
+ });
40
+ },
41
+ };
42
+
43
+ /** Symbol transform / upgrade: every instance of one symbol type becomes another. */
44
+ export const SymbolTransform: ReelFeature = {
45
+ key: 'transform',
46
+ label: 'Symbol transform / upgrade',
47
+ enabled: (c) => c.features.transform.enabled,
48
+ async demo(ctx: FeatureContext) {
49
+ const f = ctx.cfg.features.transform;
50
+ const present = Array.from(
51
+ new Set(
52
+ pickFromBoard(ctx.board, (c) => !!c.symbol).map((p) => ctx.board[p.col][p.row].symbol!),
53
+ ),
54
+ );
55
+ const lows = present.filter((s) => /^(l|o|a|k|q|j|t|10)/i.test(s));
56
+ const source =
57
+ f.source === 'randomLow'
58
+ ? (pick(lows.length ? lows : present, ctx.grid.cols) ?? present[0])
59
+ : f.source;
60
+ if (!source) {
61
+ ctx.log?.('Transform: nothing to convert');
62
+ return;
63
+ }
64
+ const matches = pickFromBoard(ctx.board, (c) => c.symbol === source);
65
+ const targets = f.allInstances ? matches : matches.slice(0, 1);
66
+ ctx.log?.(`Transform: ${targets.length}× "${source}" → "${f.target}"`);
67
+ await Promise.all(
68
+ targets.map(async (p, i) => {
69
+ await Tween.delay(i * 40);
70
+ await morphSymbol(ctx.grid.getCell(p.col, p.row), { symbol: f.target }, f.ms);
71
+ if (ctx.board[p.col]) ctx.board[p.col][p.row] = { symbol: f.target };
72
+ }),
73
+ );
74
+ },
75
+ };
76
+
77
+ /** Giant / colossal symbol spanning width×height cells. */
78
+ export const GiantSymbol: ReelFeature = {
79
+ key: 'giant',
80
+ label: 'Giant / colossal symbol',
81
+ enabled: (c) => c.features.giant.enabled,
82
+ async demo(ctx: FeatureContext) {
83
+ const f = ctx.cfg.features.giant;
84
+ if (f.onlyInFreeSpins && !ctx.freeSpins) {
85
+ ctx.log?.('Giant: free-spins only');
86
+ return;
87
+ }
88
+ const cols = ctx.grid.cols;
89
+ const w = Math.min(f.width, cols);
90
+ const anchorCol = Math.max(0, Math.floor((cols - w) / 2));
91
+ // span only as tall as the SHORTEST covered reel so every covered cell exists (Megaways-safe)
92
+ let minRows = ctx.grid.rowsOf(anchorCol);
93
+ for (let c = anchorCol; c < anchorCol + w; c++) minRows = Math.min(minRows, ctx.grid.rowsOf(c));
94
+ const h = Math.min(f.height, minRows);
95
+ const sym = f.symbols.length ? pick(f.symbols, cols) : 'h1';
96
+ ctx.log?.(`Giant ${w}×${h} "${sym}"`);
97
+ const view = ctx.resolve(sym);
98
+ if (!view) return;
99
+ const step = rowStepOf(ctx.grid);
100
+ const tl = cellCenter(ctx.grid, anchorCol, 0);
101
+ const giant = new Container();
102
+ 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);
110
+ // hide covered cells
111
+ for (let c = anchorCol; c < anchorCol + w; c++)
112
+ for (let r = 0; r < h; r++) ctx.grid.getCell(c, r).visible = false;
113
+ giant.scale.set(0.2);
114
+ giant.alpha = 0;
115
+ ctx.fx.addChild(giant);
116
+ await Tween.to(
117
+ giant,
118
+ { 'scale.x': 1, 'scale.y': 1, alpha: 1 },
119
+ 420,
120
+ easingByName('easeOutBack'),
121
+ );
122
+ await Tween.delay(600);
123
+ giant.destroy();
124
+ for (let c = anchorCol; c < anchorCol + w; c++)
125
+ for (let r = 0; r < h; r++) ctx.grid.getCell(c, r).visible = true;
126
+ },
127
+ };
128
+
129
+ /** Split symbol (xSplit): doubles every symbol to its left. */
130
+ export const SplitSymbol: ReelFeature = {
131
+ key: 'split',
132
+ label: 'Split symbol (xSplit)',
133
+ enabled: (c) => c.features.split.enabled,
134
+ async demo(ctx: FeatureContext) {
135
+ const f = ctx.cfg.features.split;
136
+ const cols = ctx.grid.cols;
137
+ const splitReel = f.reels.length ? f.reels[0] : cols - 1;
138
+ const row = Math.floor(ctx.grid.rowsOf(splitReel) / 2);
139
+ await morphSymbol(ctx.grid.getCell(splitReel, row), { symbol: f.symbol }, 240);
140
+ ctx.log?.(`Split ×${f.factor}: doubling symbols left of reel ${splitReel}`);
141
+ const left = pickFromBoard(ctx.board, (_c, col) => col < splitReel);
142
+ await Promise.all(
143
+ left.map(async (p, i) => {
144
+ await Tween.delay((i % 6) * 30);
145
+ const { x, y } = cellCenter(ctx.grid, p.col, p.row);
146
+ await pulseCell(ctx.grid.getCell(p.col, p.row), 1.12, 200);
147
+ await floatLabel(
148
+ ctx.fx,
149
+ x + ctx.grid.cellSize / 3,
150
+ y - ctx.grid.cellSize / 3,
151
+ `×${f.factor}`,
152
+ 0x9b5cff,
153
+ 600,
154
+ );
155
+ }),
156
+ );
157
+ },
158
+ };
159
+
160
+ /** Stacked symbols: a reel shows a full stack of one symbol. */
161
+ export const StackedSymbols: ReelFeature = {
162
+ key: 'stacked',
163
+ label: 'Stacked symbols',
164
+ enabled: (c) => c.features.stacked.enabled,
165
+ async demo(ctx: FeatureContext) {
166
+ const f = ctx.cfg.features.stacked;
167
+ const cols = ctx.grid.cols;
168
+ const reel = Math.floor(cols / 2);
169
+ const rows = ctx.grid.rowsOf(reel);
170
+ const sym = f.symbols.length ? pick(f.symbols, reel) : 'h1';
171
+ const height = Math.min(f.height, rows);
172
+ ctx.log?.(`Stacked "${sym}" ×${height} on reel ${reel}`);
173
+ const start = Math.floor((rows - height) / 2);
174
+ await Promise.all(
175
+ Array.from({ length: height }, (_, i) => start + i).map(async (r, i) => {
176
+ await Tween.delay(i * 50);
177
+ const cell = ctx.grid.getCell(reel, r);
178
+ await morphSymbol(cell, { symbol: sym }, 220);
179
+ if (ctx.board[reel]) ctx.board[reel][r] = { symbol: sym };
180
+ }),
181
+ );
182
+ },
183
+ };