@energy8platform/platform-core 0.26.1 → 0.28.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.
@@ -1,384 +0,0 @@
1
- import type { GameShell } from '../GameShell';
2
- import type { CellRef, GameInfoSection, GameMode, PaytableRow, PaylineDef, ShapeDef, WinSection } from '../types';
3
- import { createOverlay, twoLine } from './primitives';
4
- import { icon } from './icons';
5
- import { PACKAGE_VERSION } from '../version';
6
-
7
- /** Default order key for the auto-injected hotkeys section: just after `controls` (-1). */
8
- const HOTKEYS_DEFAULT_ORDER = -0.5;
9
-
10
- const SVG_NS = 'http://www.w3.org/2000/svg';
11
-
12
- /** Result of openGameInfoModal — the overlay root element plus a keyboard handler. */
13
- export interface GameInfoModal {
14
- root: HTMLElement;
15
- onKey: (e: KeyboardEvent) => boolean;
16
- }
17
-
18
- export function openGameInfoModal(shell: GameShell): GameInfoModal {
19
- const { root, body, scroll } = createOverlay({
20
- title: shell.t('Game info'),
21
- onClose: () => root.remove(),
22
- onBack: () => { root.remove(); shell.openSettings(); },
23
- });
24
- root.dataset.ge = 'info-modal';
25
-
26
- const rawSections = shell.config.gameInfo.sections ?? [];
27
- // Auto-inject a hotkeys section unless the game already provides one or features.hotkeys === false.
28
- const sectionsWithHotkeys: GameInfoSection[] = [...rawSections];
29
- if (shell.config.features.hotkeys !== false && !rawSections.some((s) => s.type === 'hotkeys')) {
30
- sectionsWithHotkeys.push({ type: 'hotkeys', order: HOTKEYS_DEFAULT_ORDER });
31
- }
32
- const sections = sectionsWithHotkeys;
33
- // Default placement: modes first, controls second, the rest in declaration order.
34
- // An explicit `order` overrides; ties keep declaration order (stable).
35
- const base = (s: GameInfoSection, i: number): number =>
36
- s.order ?? (s.type === 'modes' ? -2 : s.type === 'controls' ? -1 : i);
37
- sections
38
- .map((s, i) => ({ s, i, k: base(s, i) }))
39
- .sort((a, b) => a.k - b.k || a.i - b.i)
40
- .forEach(({ s }) => body.appendChild(renderSection(shell, s)));
41
-
42
- body.appendChild(versionFooter(shell));
43
-
44
- const LINE = 60;
45
- const PAGE = (): number => Math.floor(scroll.clientHeight * 0.9) || Math.floor(540 * 0.9);
46
- const onKey = (e: KeyboardEvent): boolean => {
47
- switch (e.code) {
48
- case 'ArrowDown': scroll.scrollTop += LINE; return true;
49
- case 'ArrowUp': scroll.scrollTop = Math.max(0, scroll.scrollTop - LINE); return true;
50
- case 'PageDown': scroll.scrollTop += PAGE(); return true;
51
- case 'PageUp': scroll.scrollTop = Math.max(0, scroll.scrollTop - PAGE()); return true;
52
- case 'Space':
53
- if (e.shiftKey) { scroll.scrollTop = Math.max(0, scroll.scrollTop - PAGE()); }
54
- else { scroll.scrollTop += PAGE(); }
55
- return true;
56
- case 'Home': scroll.scrollTop = 0; return true;
57
- case 'End': scroll.scrollTop = scroll.scrollHeight - scroll.clientHeight; return true;
58
- default: return false;
59
- }
60
- };
61
-
62
- return { root, onKey };
63
- }
64
-
65
- /** A muted version stamp pinned to the bottom of the game-info modal:
66
- * `${config.version ?? '1.0.0'}.${engine version without dots}` (e.g. '1.0.0.0246'). */
67
- function versionFooter(shell: GameShell): HTMLElement {
68
- const gameVersion = shell.config.version ?? '1.0.0';
69
- const el = document.createElement('div');
70
- el.dataset.ge = 'info-version';
71
- el.className = 'ge-gi-version';
72
- el.textContent = `${gameVersion}.${PACKAGE_VERSION.replaceAll('.', '')}`;
73
- return el;
74
- }
75
-
76
- function renderSection(shell: GameShell, s: GameInfoSection): HTMLElement {
77
- switch (s.type) {
78
- case 'modes': return sectionModes(shell, s.modes, sec('info-modes', s.title, shell.t('Modes')));
79
- case 'controls': return sectionControls(shell, sec('info-controls', s.title, shell.t('Controls')));
80
- case 'hotkeys': return sectionHotkeys(shell, sec('info-hotkeys', s.title, shell.t('Hotkeys')));
81
- case 'paytable': return sectionPaytable(s.rows, sec('info-paytable', s.title, shell.t('Paytable')));
82
- case 'wins': return sectionWins(s, sec('info-wins', s.title, shell.t(winFallbackTitle(s.kind))));
83
- // Translate the heading (e.g. the host-built DISCLAIMER title); the body stays verbatim.
84
- case 'custom': return sectionCustom(s, sec('info-custom', s.title != null ? shell.t(s.title) : undefined, ''));
85
- }
86
- }
87
-
88
- /** A titled glass-plaque section shell. */
89
- function sec(ge: string, title: string | undefined, fallback: string): HTMLElement {
90
- const el = document.createElement('section');
91
- el.dataset.ge = ge; el.className = 'ge-gi-sec';
92
- const t = title ?? fallback;
93
- if (t) { const h = document.createElement('h3'); h.textContent = t; el.appendChild(h); }
94
- return el;
95
- }
96
-
97
- // ── modes (rows — varying description lengths read better than fixed cards) ────
98
- function sectionModes(shell: GameShell, modes: GameMode[], el: HTMLElement): HTMLElement {
99
- const list = document.createElement('div'); list.className = 'ge-gi-modes';
100
- for (const m of modes) list.appendChild(modeRow(shell, m));
101
- el.appendChild(list);
102
- return el;
103
- }
104
- function modeRow(shell: GameShell, m: GameMode): HTMLElement {
105
- const row = document.createElement('div'); row.className = 'ge-gi-mode';
106
- const stat = (label: string, val: string) =>
107
- `<span class="ge-gi-mode-st"><span>${label}</span><b>${val}</b></span>`;
108
- let stats = '';
109
- if (m.price != null) stats += stat(shell.t('Price'), m.price);
110
- if (typeof m.rtp === 'number') stats += stat(shell.t('RTP'), `${m.rtp}%`);
111
- if (m.maxWin != null) stats += stat(shell.t('Max win'), m.maxWin);
112
- row.innerHTML =
113
- `<div class="ge-gi-mode-top"><span class="ge-gi-mode-h">${m.title}</span>` +
114
- (stats ? `<span class="ge-gi-mode-stats">${stats}</span>` : '') + '</div>' +
115
- (m.description ? `<p class="ge-gi-mode-desc">${m.description}</p>` : '');
116
- return row;
117
- }
118
-
119
- // ── controls (auto-generated, split into a gameplay block and a menu/overlay block) ──
120
- type CtlRow = { vis: string; name: string; desc: string; on: boolean };
121
-
122
- function sectionControls(shell: GameShell, el: HTMLElement): HTMLElement {
123
- const { features } = shell.config;
124
- const slot = (inner: string, cls = '') => `<span class="ge-gi-ctl-ic ${cls}">${inner}</span>`;
125
- const buyLabel = twoLine(shell.t('BUY BONUS'));
126
- const buyBadge = slot(`<span class="ge-shell-buybonus"><span>${buyLabel}</span></span>`);
127
-
128
- // Block 1 — gameplay. Bet is split into two rows: one to raise, one to lower.
129
- const game: CtlRow[] = [
130
- { vis: slot(icon('spin')), name: 'Spin', desc: 'Start a spin at the current bet.', on: true },
131
- { vis: slot(icon('plus')), name: 'Raise bet', desc: 'Increase your stake.', on: true },
132
- { vis: slot(icon('minus')), name: 'Lower bet', desc: 'Decrease your stake.', on: true },
133
- { vis: slot(icon('autoplay')), name: 'Autoplay', desc: 'Spin automatically a set number of times.', on: features.autoplay != null },
134
- { vis: slot(icon('turbo1')), name: 'Turbo', desc: 'Speed up spin animations.', on: features.turbo > 0 },
135
- { vis: buyBadge, name: 'Buy bonus', desc: 'Pay a fixed cost to enter a bonus feature.', on: features.buyBonus !== false },
136
- ];
137
- // Block 2 — menu & overlay chrome (always available).
138
- const menu: CtlRow[] = [
139
- { vis: slot(icon('menu')), name: 'Menu', desc: 'Open settings and game info.', on: true },
140
- { vis: slot(icon('soundOn')), name: 'Sound', desc: 'Mute or unmute the game.', on: true },
141
- { vis: slot(icon('info')), name: 'Game info', desc: 'Open the paytable and rules.', on: true },
142
- { vis: slot(icon('close')), name: 'Close', desc: 'Dismiss the current overlay.', on: true },
143
- ];
144
-
145
- el.appendChild(ctlBlock(shell, 'Game', game));
146
- el.appendChild(ctlBlock(shell, 'Menu & info', menu));
147
- return el;
148
- }
149
-
150
- function ctlBlock(shell: GameShell, label: string, rows: CtlRow[]): HTMLElement {
151
- const block = document.createElement('div'); block.className = 'ge-gi-ctl-block';
152
- const h = document.createElement('h4'); h.className = 'ge-gi-ctl-block-h'; h.textContent = shell.t(label);
153
- block.appendChild(h);
154
- for (const r of rows.filter((x) => x.on)) {
155
- const row = document.createElement('div'); row.className = 'ge-gi-ctl';
156
- row.innerHTML = `${r.vis}<div class="ge-gi-ctl-tx"><b>${shell.t(r.name)}</b><span>${shell.t(r.desc)}</span></div>`;
157
- block.appendChild(row);
158
- }
159
- return block;
160
- }
161
-
162
- // ── hotkeys (keycap chips → localized action name) ────────────────────────────
163
- type HkRow = { chips: string[]; name: string; on: boolean };
164
-
165
- function sectionHotkeys(shell: GameShell, el: HTMLElement): HTMLElement {
166
- const { features } = shell.config;
167
-
168
- /** Render one or more key names as keycap chips joined by " / ". */
169
- const chips = (...keys: string[]): string =>
170
- keys.map((k) => `<span class="ge-gi-hk-chip">${k}</span>`).join('<span class="ge-gi-hk-sep"> / </span>');
171
-
172
- const rows: HkRow[] = [
173
- { chips: ['Space'], name: 'Spin', on: true },
174
- { chips: ['Shift', '↑', 'Shift', '='], name: 'Raise bet', on: true },
175
- { chips: ['Shift', '↓', 'Shift', '-'], name: 'Lower bet', on: true },
176
- { chips: ['Shift', 'A'], name: 'Autoplay', on: features.autoplay != null },
177
- { chips: ['Shift', 'T'], name: 'Turbo', on: features.turbo > 0 },
178
- { chips: ['Shift', 'B'], name: 'Buy bonus', on: features.buyBonus !== false },
179
- { chips: ['Shift', 'I'], name: 'Game info', on: true },
180
- { chips: ['Shift', 'S'], name: 'Menu', on: true },
181
- { chips: ['Shift', 'M'], name: 'Mute', on: true },
182
- { chips: ['←', '→'], name: 'Navigate', on: true },
183
- { chips: ['Enter'], name: 'Confirm', on: true },
184
- { chips: ['Esc'], name: 'Close', on: true },
185
- ];
186
-
187
- const block = document.createElement('div');
188
- block.className = 'ge-gi-hk-block';
189
-
190
- for (const r of rows.filter((x) => x.on)) {
191
- const row = document.createElement('div');
192
- row.className = 'ge-gi-hk';
193
-
194
- // Build the chips column
195
- const chipsEl = document.createElement('div');
196
- chipsEl.className = 'ge-gi-hk-chips';
197
-
198
- if (r.name === 'Raise bet' || r.name === 'Lower bet') {
199
- // Two combos separated by " / ": Shift+↑ / Shift+= and Shift+↓ / Shift+-
200
- const [k1, k2, k3, k4] = r.chips;
201
- chipsEl.innerHTML =
202
- `<span class="ge-gi-hk-combo">${chips(k1, k2)}</span>` +
203
- `<span class="ge-gi-hk-sep2"> / </span>` +
204
- `<span class="ge-gi-hk-combo">${chips(k3, k4)}</span>`;
205
- } else if (r.chips.length > 1) {
206
- // Chord: Shift + X
207
- chipsEl.innerHTML = `<span class="ge-gi-hk-combo">${chips(...r.chips)}</span>`;
208
- } else {
209
- chipsEl.innerHTML = chips(...r.chips);
210
- }
211
-
212
- const tx = document.createElement('div');
213
- tx.className = 'ge-gi-hk-tx';
214
- tx.textContent = shell.t(r.name);
215
-
216
- row.appendChild(chipsEl);
217
- row.appendChild(tx);
218
- block.appendChild(row);
219
- }
220
-
221
- el.appendChild(block);
222
- return el;
223
- }
224
-
225
- // ── paytable (cards — image on top, name, then win tiers "<count> x<mult>") ────
226
- function sectionPaytable(rows: PaytableRow[], el: HTMLElement): HTMLElement {
227
- const grid = document.createElement('div'); grid.className = 'ge-gi-pt-grid';
228
- for (const r of rows) grid.appendChild(paytableCard(r));
229
- el.appendChild(grid);
230
- return el;
231
- }
232
- function paytableCard(r: PaytableRow): HTMLElement {
233
- const card = document.createElement('div'); card.className = 'ge-gi-pt-card';
234
- const sym = document.createElement('div'); sym.className = 'ge-gi-pt-sym';
235
- if (r.symbol.image) {
236
- const img = document.createElement('img'); img.src = r.symbol.image; img.alt = r.symbol.text ?? '';
237
- sym.appendChild(img);
238
- }
239
- if (r.symbol.text) {
240
- const t = document.createElement('span'); t.textContent = r.symbol.text; sym.appendChild(t);
241
- }
242
- const wins = document.createElement('div'); wins.className = 'ge-gi-pt-wins';
243
- for (const w of r.wins) {
244
- const wi = document.createElement('span'); wi.className = 'ge-gi-pt-win';
245
- wi.innerHTML = (w.count ? `<i>${w.count}</i> ` : '') + `<b>x${w.multiplier}</b>`;
246
- wins.appendChild(wi);
247
- }
248
- card.append(sym, wins);
249
- return card;
250
- }
251
-
252
- // ── wins (one section = one pay type; cells filled in the accent colour, no line) ──
253
- function winFallbackTitle(kind: WinSection['kind']): string {
254
- return { classic: 'Paylines', cluster: 'Cluster pays', anywhere: 'Pays anywhere', ways: 'Ways to win', shapes: 'Winning shapes' }[kind];
255
- }
256
-
257
- function sectionWins(s: WinSection, el: HTMLElement): HTMLElement {
258
- if (s.kind === 'classic') {
259
- if (s.description) el.appendChild(winDesc(s.description));
260
- const wrap = document.createElement('div'); wrap.className = 'ge-gi-pl-grid';
261
- s.lines.forEach((line, i) => {
262
- const def: PaylineDef = Array.isArray(line) ? { pattern: line } : line;
263
- wrap.appendChild(lineItem(s.grid, def, i + 1));
264
- });
265
- el.appendChild(wrap);
266
- } else if (s.kind === 'cluster' || s.kind === 'anywhere') {
267
- badge(el, `min ${s.minCount}`);
268
- const row = document.createElement('div'); row.className = 'ge-gi-win-row';
269
- const example = s.example ?? (s.kind === 'cluster' ? clusterExample(s.grid, s.minCount) : anywhereExample(s.grid, s.minCount));
270
- row.appendChild(gridSvg(s.grid, example));
271
- if (s.description) row.appendChild(winDesc(s.description));
272
- el.appendChild(row);
273
- } else if (s.kind === 'shapes') {
274
- if (s.description) el.appendChild(winDesc(s.description));
275
- const list = document.createElement('div'); list.className = 'ge-gi-shapes';
276
- for (const sh of s.shapes) list.appendChild(shapeRow(s.grid, sh));
277
- el.appendChild(list);
278
- } else {
279
- if (s.description) el.appendChild(winDesc(s.description));
280
- const two = document.createElement('div'); two.className = 'ge-gi-win-two';
281
- two.append(
282
- waysCol('✓ wins', 'ge-gi-win-ok', s.grid, s.winExample ?? waysWin(s.grid)),
283
- waysCol('✗ no win', 'ge-gi-win-no', s.grid, s.loseExample ?? waysLose(s.grid)),
284
- );
285
- el.appendChild(two);
286
- }
287
- return el;
288
- }
289
-
290
- /** One named shape: grid illustration (left) + name and optional description (right) — modes-style. */
291
- function shapeRow(grid: { cols: number; rows: number }, sh: ShapeDef): HTMLElement {
292
- const row = document.createElement('div'); row.className = 'ge-gi-shape';
293
- const tx = document.createElement('div'); tx.className = 'ge-gi-shape-tx';
294
- const h = document.createElement('b'); h.className = 'ge-gi-mode-h'; h.textContent = sh.name;
295
- tx.appendChild(h);
296
- if (sh.description) {
297
- const p = document.createElement('p'); p.className = 'ge-gi-mode-desc'; p.textContent = sh.description;
298
- tx.appendChild(p);
299
- }
300
- row.append(gridSvg(grid, sh.cells), tx);
301
- return row;
302
- }
303
-
304
- function winDesc(text: string): HTMLElement {
305
- const p = document.createElement('p'); p.className = 'ge-gi-win-desc'; p.textContent = text;
306
- return p;
307
- }
308
- /** Append a "min N" pill to the section header. */
309
- function badge(el: HTMLElement, text: string): void {
310
- const h = el.querySelector('h3');
311
- if (!h) return;
312
- const b = document.createElement('span'); b.className = 'ge-gi-win-badge'; b.textContent = text;
313
- h.appendChild(b);
314
- }
315
-
316
- /** A cols×rows grid SVG; `on` cells are filled in the accent colour, the rest are faint. */
317
- function gridSvg(grid: { cols: number; rows: number }, on: CellRef[]): SVGSVGElement {
318
- const { cols, rows } = grid;
319
- const W = 100, H = Math.round((rows / cols) * 100);
320
- const cw = W / cols, ch = H / rows;
321
- const svg = document.createElementNS(SVG_NS, 'svg');
322
- svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
323
- svg.setAttribute('class', 'ge-gi-pl-svg');
324
- const onSet = new Set(on.map(([c, r]) => `${c},${r}`));
325
- for (let y = 0; y < rows; y++) for (let x = 0; x < cols; x++) {
326
- const r = document.createElementNS(SVG_NS, 'rect');
327
- r.setAttribute('x', String(x * cw + 1)); r.setAttribute('y', String(y * ch + 1));
328
- r.setAttribute('width', String(cw - 2)); r.setAttribute('height', String(ch - 2));
329
- r.setAttribute('rx', '2'); r.setAttribute('class', onSet.has(`${x},${y}`) ? 'ge-gi-pl-on' : 'ge-gi-pl-cell');
330
- svg.appendChild(r);
331
- }
332
- return svg;
333
- }
334
-
335
- /** A classic payline: number caption on top, filled cells (no connecting line). */
336
- function lineItem(grid: { cols: number; rows: number }, def: PaylineDef, n: number): HTMLElement {
337
- const item = document.createElement('div'); item.className = 'ge-gi-pl-item';
338
- const cap = document.createElement('span'); cap.className = 'ge-gi-pl-cap'; cap.textContent = String(n);
339
- const on: CellRef[] = def.pattern.map((rowIdx, col) => [col, rowIdx]);
340
- item.append(cap, gridSvg(grid, on)); // caption first → renders above the grid
341
- return item;
342
- }
343
-
344
- function waysCol(tag: string, tagCls: string, grid: { cols: number; rows: number }, cells: CellRef[]): HTMLElement {
345
- const col = document.createElement('div'); col.className = 'ge-gi-win-col';
346
- const t = document.createElement('span'); t.className = `ge-gi-win-tag ${tagCls}`; t.textContent = tag;
347
- col.append(t, gridSvg(grid, cells));
348
- return col;
349
- }
350
-
351
- // Default illustrations (used when the section omits an explicit example).
352
- function clusterExample(grid: { cols: number; rows: number }, n: number): CellRef[] {
353
- const w = Math.min(grid.cols, Math.max(1, Math.ceil(Math.sqrt(n))));
354
- const cells: CellRef[] = [];
355
- for (let y = 0; y < grid.rows && cells.length < n; y++)
356
- for (let x = 0; x < w && cells.length < n; x++) cells.push([x, y]);
357
- return cells;
358
- }
359
- function anywhereExample(grid: { cols: number; rows: number }, n: number): CellRef[] {
360
- const count = Math.min(n, grid.cols * grid.rows);
361
- const cells: CellRef[] = [];
362
- for (let i = 0; i < count; i++) cells.push([Math.floor((i * grid.cols) / count), (i * 2 + 1) % grid.rows]);
363
- return cells;
364
- }
365
- function waysWin(grid: { cols: number; rows: number }): CellRef[] {
366
- const cells: CellRef[] = [];
367
- for (let c = 0; c < grid.cols; c++) cells.push([c, c % grid.rows]); // one symbol on every reel
368
- return cells;
369
- }
370
- function waysLose(grid: { cols: number; rows: number }): CellRef[] {
371
- const gap = Math.floor(grid.cols / 2);
372
- return waysWin(grid).filter(([c]) => c !== gap); // a broken chain (reel `gap` empty)
373
- }
374
-
375
- // ── custom ───────────────────────────────────────────────────────────────────
376
- function sectionCustom(s: Extract<GameInfoSection, { type: 'custom' }>, el: HTMLElement): HTMLElement {
377
- if (s.node) {
378
- el.appendChild(s.node);
379
- } else if (s.html) {
380
- const d = document.createElement('div'); d.className = 'ge-gi-custom'; d.innerHTML = s.html;
381
- el.appendChild(d);
382
- }
383
- return el;
384
- }
@@ -1,36 +0,0 @@
1
- import type { ModalOptions } from '../types';
2
- import { contrastText } from '../colors';
3
- import { createCardModal } from './primitives';
4
-
5
- /** Build a generic, externally-triggered modal (title + body text + optional action buttons),
6
- * on the shared card-modal chrome. Each action runs its `on` then closes; the ✕ (if
7
- * `availableClose`) and the actions are the only ways to dismiss. See GameShell.openModal. */
8
- export function buildModal(opts: ModalOptions): HTMLElement {
9
- const ui = createCardModal({
10
- ge: 'modal',
11
- title: opts.title,
12
- closable: opts.availableClose,
13
- blur: opts.blurLevel,
14
- onClose: () => ui.root.remove(),
15
- });
16
-
17
- const text = document.createElement('p');
18
- text.className = 'ge-modal-text'; text.dataset.ge = 'modal-body';
19
- text.textContent = opts.body;
20
- ui.body.appendChild(text);
21
-
22
- if (opts.actions?.length) {
23
- const actions = document.createElement('div'); actions.className = 'ge-modal-actions';
24
- for (const a of opts.actions) {
25
- const btn = document.createElement('button');
26
- btn.className = 'ge-modal-btn'; btn.dataset.ge = 'modal-action';
27
- btn.textContent = a.title;
28
- if (a.color) { btn.style.background = a.color; btn.style.color = contrastText(a.color); }
29
- else btn.classList.add('ge-modal-btn--ghost');
30
- btn.addEventListener('click', () => { a.on?.(); ui.root.remove(); });
31
- actions.appendChild(btn);
32
- }
33
- ui.card.appendChild(actions);
34
- }
35
- return ui.root;
36
- }
@@ -1,57 +0,0 @@
1
- import type { GameShell } from '../GameShell';
2
- import type { ReplayModalOptions } from '../types';
3
- import { formatCurrency } from '../format';
4
- import { createCardModal } from './primitives';
5
-
6
- /** The replay summary modal — built on the shared card chrome, but NOT dismissable: no ✕,
7
- * and the backdrop never closes it. The only way out is START REPLAY, which closes the
8
- * modal, runs `onReplay`, then reopens it (whether the handler resolves OR rejects, so a
9
- * failed replay can't strand the user). */
10
- export function buildReplayModal(shell: GameShell, opts: ReplayModalOptions): HTMLElement {
11
- const { bonusId, bet, payoutMultiplier } = opts;
12
- const fmt = (n: number) => formatCurrency(n, shell.config.currency);
13
- const fmtWin = (n: number) => formatCurrency(n, shell.config.currency, true); // total win: variable decimals
14
- const bonus = Array.isArray(shell.config.features.buyBonus)
15
- ? shell.config.features.buyBonus.find((b) => b.id === bonusId)
16
- : undefined;
17
- const mode = bonus?.title ?? bonusId;
18
- const costMultiplier = bonus?.priceMultiplier ?? 1;
19
-
20
- const ui = createCardModal({
21
- ge: 'replay-modal',
22
- title: shell.t('Replay'),
23
- closable: false, // no ✕; the backdrop never dismisses it either
24
- onClose: () => {}, // unused — there is no close affordance
25
- });
26
-
27
- const rows = document.createElement('div'); rows.className = 'ge-replay-rows';
28
- const row = (label: string, value: string, total = false): void => {
29
- const r = document.createElement('div'); r.className = `ge-replay-row${total ? ' ge-replay-total' : ''}`;
30
- const l = document.createElement('span'); l.textContent = shell.t(label);
31
- const v = document.createElement('b'); v.textContent = value;
32
- r.append(l, v); rows.appendChild(r);
33
- };
34
- row('Mode', mode);
35
- row('Base bet', fmt(bet));
36
- row('Cost multiplier', `${costMultiplier}×`);
37
- row('Total cost', fmt(bet * costMultiplier));
38
- row('Win multiplier', `${payoutMultiplier}×`);
39
- row('Total win', fmtWin(payoutMultiplier * bet), true);
40
- ui.body.appendChild(rows);
41
-
42
- const actions = document.createElement('div'); actions.className = 'ge-modal-actions';
43
- const btn = document.createElement('button');
44
- btn.className = 'ge-modal-btn ge-modal-btn--accent'; btn.dataset.ge = 'replay-start';
45
- btn.textContent = shell.t('Start replay');
46
- btn.addEventListener('click', () => {
47
- ui.root.remove(); // close immediately
48
- // Reopen after the handler settles. On rejection we still reopen — this modal is the only
49
- // way out of replay mode, so a failed play must not strand the user on an empty screen.
50
- const reopen = (): void => { shell.openReplay(opts); };
51
- Promise.resolve(opts.onReplay()).then(reopen, reopen);
52
- });
53
- actions.appendChild(btn);
54
- ui.card.appendChild(actions);
55
-
56
- return ui.root;
57
- }
@@ -1,59 +0,0 @@
1
- import type { GameShell } from '../GameShell';
2
- import { createOverlay } from './primitives';
3
- import { icon } from './icons';
4
-
5
- export function openSettingsModal(shell: GameShell): HTMLElement {
6
- const { root, body } = createOverlay({ title: shell.t('Settings'), onClose: () => root.remove() });
7
- root.dataset.ge = 'settings-modal';
8
-
9
- // Sound on/off — backed by the shell's shared `soundOn` state so this toggle and the Shift+M
10
- // hotkey stay in sync; `setSound` emits `settingChange({ key: 'sound' })` and refreshes the icon.
11
- const sound = (() => {
12
- const btn = document.createElement('button');
13
- btn.className = 'ge-snd'; btn.dataset.ge = 'setting-sound';
14
- btn.setAttribute('aria-label', shell.t('Sound'));
15
- const paint = (on: boolean) => {
16
- btn.innerHTML = icon(on ? 'soundOn' : 'soundOff');
17
- btn.classList.toggle('ge-active', on);
18
- btn.setAttribute('aria-pressed', String(on));
19
- };
20
- paint(shell.soundOn);
21
- btn.addEventListener('click', () => shell.setSound(!shell.soundOn));
22
- // Live-update the icon when sound changes from here OR via Shift+M (shell clears on close).
23
- shell.setSoundRefresh(paint);
24
- const row = document.createElement('div'); row.className = 'ge-ov-row';
25
- row.innerHTML = `<span class="ge-grow">${shell.t('Sound')}</span>`; row.appendChild(btn);
26
- return row;
27
- })();
28
- body.appendChild(sound);
29
-
30
- // Volume sliders — full-width column rows with a live value readout
31
- const slider = (key: string, label: string) => {
32
- const row = document.createElement('div'); row.className = 'ge-ov-row ge-col';
33
- const head = document.createElement('div'); head.className = 'ge-row-head';
34
- const val = document.createElement('span'); val.className = 'ge-val'; val.textContent = '100%';
35
- head.innerHTML = `<span>${label}</span>`; head.appendChild(val);
36
- const input = document.createElement('input');
37
- input.type = 'range'; input.min = '0'; input.max = '1'; input.step = '0.05'; input.value = '1';
38
- input.className = 'ge-slider'; input.dataset.ge = `setting-${key}`;
39
- input.addEventListener('input', () => {
40
- val.textContent = `${Math.round(Number(input.value) * 100)}%`;
41
- shell.emit('settingChange', { key, value: Number(input.value) });
42
- });
43
- row.append(head, input);
44
- return row;
45
- };
46
- body.appendChild(slider('master', shell.t('Master volume')));
47
- body.appendChild(slider('music', shell.t('Music')));
48
- body.appendChild(slider('sfx', shell.t('SFX')));
49
-
50
- // Game info — full-width row button that opens its own overlay
51
- const gameInfo = document.createElement('button');
52
- gameInfo.className = 'ge-ov-row'; gameInfo.dataset.ge = 'game-info-btn';
53
- gameInfo.style.marginTop = '6px';
54
- gameInfo.innerHTML = `<span style="width:22px;font-size:22px">${icon('info')}</span><span class="ge-grow">${shell.t('Game info')}</span><span style="width:20px;font-size:20px;color:var(--shell-muted)">${icon('chevronRight')}</span>`;
55
- gameInfo.addEventListener('click', () => { root.remove(); shell.openInfo(); });
56
- body.appendChild(gameInfo);
57
-
58
- return root;
59
- }
@@ -1,40 +0,0 @@
1
- // Sharp monochrome icon set, traced from the brand sheet. Every glyph uses
2
- // currentColor so the caller's color cascades (active states recolour via CSS);
3
- // hollow shapes set fill-rule="evenodd". All return an inline <svg> sized 1em.
4
- const SVGS: Record<string, string> = {
5
- // sharp angular set, traced from the brand sheet — monochrome currentColor
6
- // (glow via CSS drop-shadow). Hollow shapes rely on fill-rule="evenodd".
7
- // hollow loop ring + two arrowheads (renders dark-on-bright inside SPIN)
8
- spin: `<g fill="currentColor" fill-rule="evenodd"><path d="M17.92 4.68 L13.91 2.81 L10.55 1.6 L12.05 3.47 L11.95 3.56 L10.93 3.65 L9.25 4.12 L7.94 4.77 L7.01 5.42 L5.61 6.82 L4.58 8.41 L4.03 9.53 L3.28 11.67 L2.91 13.91 L4.49 16.52 L4.68 16.62 L6.54 14.94 L6.17 14.01 L6.08 13.17 L5.98 13.07 L5.98 11.49 L6.08 10.93 L6.45 9.71 L6.82 8.97 L7.48 8.04 L8.22 7.29 L9.25 6.54 L9.99 6.17 L11.21 5.8 L11.77 5.7 L13.73 5.7 L13.82 5.8 L14.57 5.89 L15.87 6.45 L16.06 6.45 L17.92 4.86Z"/><path d="M19.6 7.66 L19.42 7.57 L17.83 9.16 L18.11 9.9 L18.2 10.74 L18.3 10.83 L18.3 12.7 L18.2 12.79 L18.11 13.54 L17.46 15.03 L16.9 15.87 L15.87 16.9 L14.94 17.55 L14.1 17.92 L13.17 18.2 L12.05 18.39 L11.02 18.39 L10.93 18.3 L9.9 18.2 L9.06 17.92 L8.41 17.55 L8.22 17.55 L6.73 18.76 L6.26 19.32 L11.21 21.56 L12.7 22.03 L13.45 22.4 L13.73 22.4 L12.23 20.53 L12.33 20.44 L13.54 20.35 L15.31 19.79 L16.8 18.95 L17.64 18.3 L18.76 17.08 L19.51 15.96 L20.16 14.66 L20.25 14.19 L20.53 13.63 L20.72 12.98 L20.72 12.61 L20.91 12.14 L21.09 10.37 L19.97 8.5Z"/></g>`,
9
- turbo: `<g fill="currentColor" fill-rule="evenodd"><path d="M19.16 1.71 L16.47 1.71 L16.36 1.6 L13.23 1.6 L12.78 1.71 L11.78 3.39 L10.55 5.85 L10.32 6.07 L8.31 9.99 L8.09 10.21 L7.19 12.11 L12 12.34 L14.46 9.88 L14.24 9.65 L12.89 9.54 L12.89 9.2 L17.03 4.4Z"/><path d="M19.83 6.97 L18.93 7.64 L16.36 9.99 L9.65 16.47 L4.17 21.62 L4.17 22.4 L5.4 21.39 L13.34 13.9 L13.45 14.12 L10.43 20.16 L10.21 20.83 L9.65 21.95 L9.76 21.95 L19.27 10.32 L19.27 10.21 L17.59 10.1 L17.48 9.88 L18.71 8.53Z"/></g>`,
10
- // bolt with 1/2/3 speed lines — escalating turbo level
11
- turbo1: `<g fill="currentColor" fill-rule="evenodd"><path d="M21.82 1.6 L21.59 1.72 L21.01 1.72 L20.44 1.95 L18.7 2.29 L18.12 2.29 L18.01 2.41 L16.39 2.64 L14.77 3.1 L13.73 5.41 L13.73 5.64 L13.27 6.45 L13.27 6.68 L12.81 7.49 L12.81 7.72 L12.35 8.53 L12.35 8.76 L11.77 9.8 L11.42 10.84 L10.15 13.5 L10.15 13.73 L14.54 13.73 L14.66 13.85 L13.39 16.16 L12.81 16.97 L11.54 19.4 L11.31 19.63 L11.08 20.2 L10.04 21.82 L9.8 22.4 L9.92 22.4 L20.9 11.19 L22.05 10.15 L22.05 10.04 L16.62 10.15 L16.51 10.27 L15.7 10.27 L15.58 10.15 L15.81 9.69 L16.28 9.23 L21.48 2.29Z"/><path d="M8.53 12.46 L7.72 12.58 L7.26 12.81 L6.92 12.81 L6.45 13.04 L6.11 13.04 L5.64 13.27 L3.68 13.73 L1.95 14.31 L1.95 14.43 L7.61 14.43Z"/></g>`,
12
- turbo2: `<g fill="currentColor" fill-rule="evenodd"><path d="M22.4 2.68 L18.38 3.61 L15.4 4.43 L14.68 5.98 L13.96 7.93 L13.65 8.45 L13.65 8.65 L13.34 9.17 L13.34 9.37 L13.03 9.89 L13.03 10.1 L12.72 10.61 L12.72 10.82 L12.41 11.33 L12.41 11.54 L12.1 12.05 L12.1 12.26 L11.79 12.77 L11.49 13.7 L15.3 13.7 L15.5 13.9 L14.47 15.55 L14.16 16.27 L13.96 16.48 L11.28 21.32 L11.9 20.91 L20.65 11.95 L22.09 10.61 L22.09 10.51 L20.55 10.51 L20.44 10.61 L16.32 10.71 L16.22 10.51 L19.83 6.08Z"/><path d="M10.04 15.04 L9.43 15.14 L8.81 14.93 L7.98 14.93 L7.88 15.04 L6.34 15.14 L6.23 15.24 L5.62 15.24 L4.28 15.55 L2.84 15.76 L1.6 16.17 L10.04 16.17Z"/><path d="M10.56 9.99 L9.43 10.1 L9.32 10.2 L7.37 10.51 L6.85 10.71 L6.44 10.71 L5.51 11.02 L3.04 11.54 L2.94 11.74 L5.1 11.85 L5.2 11.95 L6.65 11.95 L6.75 12.05 L9.01 12.05 L9.12 12.15 L9.73 12.15 L10.56 10.3Z"/></g>`,
13
- turbo3: `<g fill="currentColor" fill-rule="evenodd"><path d="M22.4 3 L22 3 L20.3 3.5 L19.9 3.5 L19.5 3.7 L19.1 3.7 L15.9 4.6 L13.9 9.1 L13.9 9.3 L13.6 10 L13.2 10.7 L13.2 10.9 L12.6 12.1 L12.3 13 L12 13.6 L15.8 13.6 L15.9 13.8 L14.4 16.3 L13.7 17.7 L13.2 18.4 L12.8 19.1 L12.5 19.8 L12 20.5 L11.9 21 L22.2 10.6 L22.2 10.5 L16.8 10.6 L16.7 10.4 L22.3 3.3Z"/><path d="M11 11.3 L9.9 11.3 L9.8 11.4 L7.8 11.6 L6 11.9 L5.4 12.1 L3.9 12.3 L3.1 12.5 L3.1 12.6 L5.6 12.7 L5.7 12.8 L7.9 12.8 L8 12.9 L10.4 12.9 L11 11.6Z"/><path d="M12.7 7.3 L12 7.3 L11.9 7.4 L10.7 7.5 L10.1 7.7 L9.6 7.7 L7.2 8.2 L6.7 8.4 L5.5 8.6 L5.6 8.8 L7 8.8 L7.1 8.9 L8.5 8.9 L8.6 9 L12 9.1Z"/><path d="M10.9 15.4 L10.3 15.7 L10 15.7 L9.1 15.4 L7.8 15.5 L7.7 15.6 L6.9 15.6 L6.8 15.7 L6 15.7 L5.9 15.8 L4.1 16 L3.5 16.2 L2.5 16.3 L1.6 16.6 L2.6 16.6 L2.7 16.7 L10.9 16.7Z"/></g>`,
14
- autoplay: `<g fill="currentColor" fill-rule="evenodd"><path d="M19.48 5.36 L15.02 1.6 L14.82 1.6 L15.62 3.28 L15.52 3.78 L14.82 3.78 L14.72 3.88 L12.64 3.88 L12.54 3.78 L5.71 3.78 L3.23 6.35 L3.23 16.26 L3.33 16.26 L4.82 14.48 L4.82 12.5 L4.72 12.4 L4.72 7.25 L4.82 7.05 L6.4 5.56 L8.29 5.56 L8.38 5.46 L19.48 5.46Z"/><path d="M9.87 8.83 L9.87 15.27 L9.97 15.27 L11.26 14.38 L15.12 12.1 L15.12 12 L11.16 9.62 L10.66 9.23Z"/><path d="M20.67 7.94 L20.17 8.34 L19.18 9.52 L19.18 16.85 L17.5 18.44 L17.1 18.54 L4.42 18.54 L4.42 18.64 L7.3 21.21 L8.78 22.4 L8.78 22 L8.09 20.42 L8.19 20.22 L18.29 20.22 L20.77 17.65 L20.67 17.35Z"/></g>`,
15
- // hollow square — stop autoplay / halt
16
- stop: `<g fill="currentColor" fill-rule="evenodd"><path d="M22.4 3.04 L21.1 4.82 L21.1 9.47 L21.03 9.54 L21.03 12.41 L21.1 12.96 L21.03 18.84 L21.1 19.18 L20.35 20.01 L19.39 20.89 L17.27 20.89 L16.72 20.96 L4.75 20.96 L4.68 20.89 L3.04 22.33 L4.13 22.33 L4.2 22.4 L6.66 22.4 L6.73 22.33 L9.13 22.33 L9.19 22.4 L20.48 22.4 L22.4 20.48Z"/><path d="M20.96 1.6 L3.38 1.6 L1.6 3.45 L1.6 20.96 L2.9 19.12 L2.9 4.75 L4.54 3.11 L19.12 3.11 L19.46 2.97Z"/></g>`,
17
- menu: `<g fill="currentColor" fill-rule="evenodd"><path d="M1.6 13.52 L19.61 13.52 L22.27 10.73 L22.4 10.35 L4.39 10.35 L2.36 12.51Z"/><path d="M1.6 6.04 L19.61 6.04 L22.15 3.38 L22.4 2.87 L4.39 2.87 L2.36 5.02Z"/><path d="M1.6 21 L3.25 21 L3.38 21.13 L4.9 21.13 L5.02 21 L19.61 21 L22.4 17.96 L4.52 17.83 L4.26 17.96 L2.36 19.99Z"/></g>`,
18
- betUp: `<g fill="currentColor" fill-rule="evenodd"><path d="M21.01 15.43 L19.18 13.07 L14.68 6.75 L12.32 3.64 L12 3.42 L8.57 7.93 L6.42 11.04 L2.89 15.75 L2.78 16.07 L12 8.14 L12.21 8.14 L20.68 15.43Z"/><path d="M22.4 20.26 L20.04 17.79 L12.21 10.28 L12 10.28 L6.53 15.43 L6.42 15.65 L1.81 20.15 L1.6 20.58 L12 14.04 L12.21 14.04 L12.75 14.47 L18.11 17.68 L18.65 18.11 L19.51 18.54 L22.29 20.36Z"/></g>`,
19
- betDown: `<g fill="currentColor" fill-rule="evenodd"><path d="M1.6 2.74 L1.84 3.22 L5.57 7.43 L11.7 14.04 L12.06 14.28 L12.42 14.04 L18.79 7.19 L22.4 2.98 L22.28 2.86 L18.55 5.51 L12.06 10.44 L5.81 5.63Z"/><path d="M20.84 9.35 L18.07 11.52 L12.18 16.57 L11.94 16.57 L4.49 10.2 L3.52 9.48 L3.4 9.6 L8.57 16.57 L8.81 17.05 L10.26 18.85 L10.5 19.33 L11.34 20.3 L11.58 20.78 L12.18 21.26 L18.43 12.84Z"/></g>`,
20
- minus: `<g fill="currentColor" fill-rule="evenodd"><path d="M1.6 13.26 L12.42 13.26 L12.5 13.18 L19.81 13.26 L22.4 10.82 L4.27 10.74Z"/></g>`,
21
- plus: `<g fill="currentColor" fill-rule="evenodd"><path d="M13.16 1.6 L12.26 3.17 L10.77 5.42 L10.77 10.73 L10.69 10.8 L4.33 10.8 L1.79 13.2 L10.69 13.2 L10.77 13.27 L10.77 22.4 L12.41 19.71 L13.16 18.66 L13.16 13.27 L13.23 13.2 L19.59 13.2 L22.21 10.8 L13.23 10.8 L13.16 10.73Z"/></g>`,
22
- gift: `<rect x="4" y="9" width="16" height="11" rx="2" fill="currentColor"/><path d="M9 9a2.5 2.5 0 1 1 3-3 2.5 2.5 0 1 1 3 3z" fill="currentColor"/><rect x="11" y="9" width="2" height="11" fill="rgba(0,0,0,.35)"/>`,
23
- info: `<g fill="currentColor" fill-rule="evenodd"><path d="M14.22 7.78 L11.13 7.86 L9.4 9.21 L9.17 9.29 L10.15 9.29 L10.23 9.59 L9.32 13.73 L8.87 17.12 L8.57 18.78 L8.5 19.69 L8.27 20.74 L8.12 22.32 L8.04 22.4 L13.62 17.95 L11.96 17.95 L11.89 17.58 L12.41 15.69 L12.49 15.01 L12.87 13.73 L12.87 13.43 L13.24 12.15 L13.77 9.97 L14 8.68 L14.22 8.08Z"/><path d="M15.96 1.6 L12.94 1.6 L11.74 3.18 L11.51 3.63 L10.08 5.67 L12.79 5.59 L14.37 3.71Z"/></g>`,
24
- soundOn: `<g fill="currentColor" fill-rule="evenodd"><path d="M16.61 6.68 L17.16 8.4 L17.4 9.97 L17.4 13.88 L17.32 13.95 L17.16 15.44 L16.61 17.16 L18.88 13.72 L18.88 12.86 L18.96 12.78 L18.88 10.12Z"/><path d="M19.27 4.34 L19.58 4.88 L19.66 5.28 L20.05 6.14 L20.6 7.93 L20.91 9.5 L20.91 10.44 L20.99 10.51 L20.99 13.25 L20.91 13.33 L20.91 14.19 L20.76 15.13 L20.05 17.63 L19.27 19.43 L20.29 18.02 L21.15 16.61 L22.4 14.19 L22.4 9.58 L21.31 7.39 L20.29 5.74Z"/><path d="M13.17 1.76 L10.28 4.34 L6.29 8.17 L1.6 8.25 L1.6 15.6 L2.15 15.75 L6.29 16.3 L9.97 19.58 L13.17 22.24Z"/><path d="M11.69 5.59 L11.92 5.67 L11.92 19.04 L11.69 19.12 L7.07 14.82 L4.57 14.5 L4.42 14.35 L4.42 10.51 L4.57 10.36 L7.07 10.28Z"/></g>`,
25
- soundOff: `<g fill="currentColor" fill-rule="evenodd"><path d="M13.68 13.18 L12.53 14.4 L12.53 18.44 L12.3 18.51 L10.4 16.76 L9.33 17.83 L12.84 20.88 L13.75 21.56 L13.75 13.18Z"/><path d="M17.71 8.69 L17.49 8.91 L17.56 9.9 L17.64 9.98 L17.64 13.1 L17.33 14.7 L17.03 15.39 L18.78 12.88 L18.78 11.66 L18.86 11.58 L18.78 10.29Z"/><path d="M19.47 6.63 L19.85 7.47 L20.46 9.6 L20.53 10.13 L20.53 13.03 L20.08 15.01 L19.31 16.91 L20.61 14.93 L21.3 13.49 L21.52 13.18 L21.52 9.9 L20.76 8.53Z"/><path d="M20.91 2.21 L16.5 6.63 L13.98 9.3 L13.75 9.22 L13.75 1.6 L10.86 4.19 L7.05 7.85 L2.4 7.92 L2.48 8 L2.48 15.16 L6.51 15.7 L7.73 14.32 L5.3 14.02 L5.22 13.94 L5.22 10.13 L5.75 9.98 L7.81 9.9 L12.3 5.33 L12.53 5.41 L12.53 10.9 L10.32 13.26 L6.9 17.14 L2.55 22.4 L6.9 18.36 L9.79 15.47 L13.52 11.5 L18.25 5.71Z"/></g>`,
26
- close: `<g fill="currentColor" fill-rule="evenodd"><path d="M22.16 1.6 L21.3 2.09 L20.44 2.82 L16.89 5.27 L12.12 10.29 L10.53 8.82 L7.35 5.39 L2.21 2.09 L5.88 7.23 L8.21 9.55 L10.41 12 L5.76 16.89 L2.33 21.54 L1.84 22.4 L2.58 22.03 L2.95 21.67 L4.17 20.93 L5.88 19.59 L7.23 18.73 L12 13.71 L12.12 13.71 L17.02 18.73 L22.16 22.16 L22.16 21.91 L20.2 19.46 L18.48 17.02 L13.71 12.12 L13.71 11.88 L18.48 6.86 L20.32 4.17 L21.67 2.46Z"/></g>`,
27
- back: `<path d="M15 6l-6 6 6 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>`,
28
- chevronRight: `<path d="M9 6l6 6-6 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>`,
29
- star: `<path d="M12 3l2.6 5.6 6.1.7-4.5 4.2 1.2 6L12 16.9 6.6 19.5l1.2-6L3.3 9.3l6.1-.7z" fill="currentColor"/>`,
30
- // volatility bolt (buy-bonus cards) — supplied art, scaled from its 1254 viewBox into 24×24
31
- lightning: `<path transform="scale(0.019139)" d="M747,205L433,629L622,633L497,986L801,550L614,547Z" fill="currentColor"/>`,
32
- };
33
-
34
- export type IconName = keyof typeof SVGS;
35
- export const ICON_NAMES = Object.keys(SVGS) as IconName[];
36
-
37
- /** Inline SVG string for an icon, sized to 1em (scale via font-size/width). */
38
- export function icon(name: IconName): string {
39
- return `<svg viewBox="0 0 24 24" width="1em" height="1em" aria-hidden="true">${SVGS[name]}</svg>`;
40
- }