@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.
- package/README.md +121 -0
- package/dist/animation.cjs.js +18 -3
- package/dist/animation.cjs.js.map +1 -1
- package/dist/animation.esm.js +18 -3
- package/dist/animation.esm.js.map +1 -1
- package/dist/core.cjs.js +18 -3
- package/dist/core.cjs.js.map +1 -1
- package/dist/core.esm.js +18 -3
- package/dist/core.esm.js.map +1 -1
- package/dist/host.cjs.js +18 -3
- package/dist/host.cjs.js.map +1 -1
- package/dist/host.d.ts +3 -5
- package/dist/host.esm.js +18 -3
- package/dist/host.esm.js.map +1 -1
- package/dist/index.cjs.js +18 -2535
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +3 -1020
- package/dist/index.esm.js +20 -2525
- package/dist/index.esm.js.map +1 -1
- package/dist/slot.cjs.js +1872 -61
- package/dist/slot.cjs.js.map +1 -1
- package/dist/slot.d.ts +555 -28
- package/dist/slot.esm.js +1857 -62
- package/dist/slot.esm.js.map +1 -1
- package/dist/vite.cjs.js +0 -5
- package/dist/vite.cjs.js.map +1 -1
- package/dist/vite.esm.js +0 -5
- package/dist/vite.esm.js.map +1 -1
- package/package.json +2 -35
- package/src/animation/Tween.ts +14 -3
- package/src/host/index.ts +0 -1
- package/src/host/types.ts +0 -3
- package/src/index.ts +0 -28
- package/src/slot/anim/easing-map.ts +16 -2
- package/src/slot/cascade/TumbleController.ts +230 -0
- package/src/slot/config/ReelSystemConfig.ts +559 -0
- package/src/slot/config/presets.ts +222 -0
- package/src/slot/features/extra.ts +189 -0
- package/src/slot/features/index.ts +44 -0
- package/src/slot/features/symbols.ts +183 -0
- package/src/slot/features/types.ts +136 -0
- package/src/slot/features/wilds.ts +151 -0
- package/src/slot/grid/ReelGrid.ts +93 -24
- package/src/slot/grid/SymbolCell.ts +45 -11
- package/src/slot/index.ts +61 -2
- package/src/slot/motion/AnticipationController.ts +96 -0
- package/src/slot/motion/SpinEngine.ts +384 -0
- package/src/slot/system/ReelSystem.ts +295 -0
- package/src/vite/index.ts +0 -5
- package/dist/react-jsx.cjs.js +0 -3
- package/dist/react-jsx.cjs.js.map +0 -1
- package/dist/react-jsx.d.ts +0 -602
- package/dist/react-jsx.esm.js +0 -2
- package/dist/react-jsx.esm.js.map +0 -1
- package/dist/react.cjs.js +0 -3787
- package/dist/react.cjs.js.map +0 -1
- package/dist/react.d.ts +0 -1467
- package/dist/react.esm.js +0 -3771
- package/dist/react.esm.js.map +0 -1
- package/dist/ui.cjs.js +0 -2999
- package/dist/ui.cjs.js.map +0 -1
- package/dist/ui.d.ts +0 -1116
- package/dist/ui.esm.js +0 -2983
- package/dist/ui.esm.js.map +0 -1
- package/src/react/EngineContext.ts +0 -26
- package/src/react/ReactScene.ts +0 -88
- package/src/react/applyProps.ts +0 -271
- package/src/react/catalogue.ts +0 -17
- package/src/react/createPixiRoot.ts +0 -31
- package/src/react/extendAll.ts +0 -74
- package/src/react/hooks.ts +0 -46
- package/src/react/index.ts +0 -29
- package/src/react/jsx-runtime.ts +0 -346
- package/src/react/reconciler.ts +0 -338
- package/src/slot/freeSpins/FreeSpinsSession.ts +0 -40
- package/src/state/StateMachine.ts +0 -231
- package/src/state/index.ts +0 -1
- package/src/ui/BalanceDisplay.ts +0 -159
- package/src/ui/Button.ts +0 -304
- package/src/ui/FlexContainer.ts +0 -775
- package/src/ui/Label.ts +0 -124
- package/src/ui/LabelValue.ts +0 -122
- package/src/ui/Layout.ts +0 -291
- package/src/ui/Modal.ts +0 -170
- package/src/ui/Panel.ts +0 -204
- package/src/ui/ProgressBar.ts +0 -170
- package/src/ui/ScrollContainer.ts +0 -478
- package/src/ui/Slider.ts +0 -241
- package/src/ui/Toast.ts +0 -150
- package/src/ui/Toggle.ts +0 -201
- package/src/ui/WinDisplay.ts +0 -145
- package/src/ui/index.ts +0 -33
- 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
|
+
};
|