@energy8platform/platform-core 0.20.0 → 0.21.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 +194 -0
- package/dist/index.cjs.js +1940 -0
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +302 -2
- package/dist/index.esm.js +1938 -1
- package/dist/index.esm.js.map +1 -1
- package/dist/shell.cjs.js +1993 -0
- package/dist/shell.cjs.js.map +1 -0
- package/dist/shell.d.ts +320 -0
- package/dist/shell.esm.js +1989 -0
- package/dist/shell.esm.js.map +1 -0
- package/package.json +6 -1
- package/scripts/build-shell-font.mjs +64 -0
- package/src/index.ts +16 -0
- package/src/shell/GameShell.ts +294 -0
- package/src/shell/INTER-LICENSE.txt +93 -0
- package/src/shell/colors.ts +32 -0
- package/src/shell/components/BottomBar.ts +217 -0
- package/src/shell/components/BuyBonus.ts +163 -0
- package/src/shell/components/GameInfo.ts +253 -0
- package/src/shell/components/Modal.ts +36 -0
- package/src/shell/components/ReplayModal.ts +56 -0
- package/src/shell/components/Settings.ts +60 -0
- package/src/shell/components/icons.ts +40 -0
- package/src/shell/components/pickers.ts +76 -0
- package/src/shell/components/primitives.ts +84 -0
- package/src/shell/fonts.ts +13 -0
- package/src/shell/format.ts +36 -0
- package/src/shell/i18n.ts +67 -0
- package/src/shell/index.ts +20 -0
- package/src/shell/motion.ts +43 -0
- package/src/shell/shell.css.ts +371 -0
- package/src/shell/state.ts +30 -0
- package/src/shell/theme.ts +56 -0
- package/src/shell/types.ts +191 -0
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import type { GameShell } from '../GameShell';
|
|
2
|
+
import type { CellRef, GameInfoSection, GameMode, PaytableRow, PaylineDef, WinSection } from '../types';
|
|
3
|
+
import { createOverlay, twoLine } from './primitives';
|
|
4
|
+
import { icon } from './icons';
|
|
5
|
+
|
|
6
|
+
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
7
|
+
|
|
8
|
+
export function openGameInfoModal(shell: GameShell): HTMLElement {
|
|
9
|
+
const { root, body } = createOverlay({
|
|
10
|
+
title: shell.t('Game info'),
|
|
11
|
+
onClose: () => root.remove(),
|
|
12
|
+
onBack: () => { root.remove(); shell.openSettings(); },
|
|
13
|
+
});
|
|
14
|
+
root.dataset.ge = 'info-modal';
|
|
15
|
+
|
|
16
|
+
const sections = shell.config.gameInfo.sections ?? [];
|
|
17
|
+
// Default placement: modes first, controls second, the rest in declaration order.
|
|
18
|
+
// An explicit `order` overrides; ties keep declaration order (stable).
|
|
19
|
+
const base = (s: GameInfoSection, i: number): number =>
|
|
20
|
+
s.order ?? (s.type === 'modes' ? -2 : s.type === 'controls' ? -1 : i);
|
|
21
|
+
sections
|
|
22
|
+
.map((s, i) => ({ s, i, k: base(s, i) }))
|
|
23
|
+
.sort((a, b) => a.k - b.k || a.i - b.i)
|
|
24
|
+
.forEach(({ s }) => body.appendChild(renderSection(shell, s)));
|
|
25
|
+
|
|
26
|
+
return root;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function renderSection(shell: GameShell, s: GameInfoSection): HTMLElement {
|
|
30
|
+
switch (s.type) {
|
|
31
|
+
case 'modes': return sectionModes(s.modes, sec('info-modes', s.title, shell.t('Modes')));
|
|
32
|
+
case 'controls': return sectionControls(shell, sec('info-controls', s.title, shell.t('Controls')));
|
|
33
|
+
case 'paytable': return sectionPaytable(s.rows, sec('info-paytable', s.title, shell.t('Paytable')));
|
|
34
|
+
case 'wins': return sectionWins(s, sec('info-wins', s.title, shell.t(winFallbackTitle(s.kind))));
|
|
35
|
+
case 'custom': return sectionCustom(s, sec('info-custom', s.title, ''));
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A titled glass-plaque section shell. */
|
|
40
|
+
function sec(ge: string, title: string | undefined, fallback: string): HTMLElement {
|
|
41
|
+
const el = document.createElement('section');
|
|
42
|
+
el.dataset.ge = ge; el.className = 'ge-gi-sec';
|
|
43
|
+
const t = title ?? fallback;
|
|
44
|
+
if (t) { const h = document.createElement('h3'); h.textContent = t; el.appendChild(h); }
|
|
45
|
+
return el;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── modes (rows — varying description lengths read better than fixed cards) ────
|
|
49
|
+
function sectionModes(modes: GameMode[], el: HTMLElement): HTMLElement {
|
|
50
|
+
const list = document.createElement('div'); list.className = 'ge-gi-modes';
|
|
51
|
+
for (const m of modes) list.appendChild(modeRow(m));
|
|
52
|
+
el.appendChild(list);
|
|
53
|
+
return el;
|
|
54
|
+
}
|
|
55
|
+
function modeRow(m: GameMode): HTMLElement {
|
|
56
|
+
const row = document.createElement('div'); row.className = 'ge-gi-mode';
|
|
57
|
+
const stat = (label: string, val: string) =>
|
|
58
|
+
`<span class="ge-gi-mode-st"><span>${label}</span><b>${val}</b></span>`;
|
|
59
|
+
let stats = '';
|
|
60
|
+
if (m.price != null) stats += stat('Price', m.price);
|
|
61
|
+
if (typeof m.rtp === 'number') stats += stat('RTP', `${m.rtp}%`);
|
|
62
|
+
if (m.maxWin != null) stats += stat('Max win', m.maxWin);
|
|
63
|
+
row.innerHTML =
|
|
64
|
+
`<div class="ge-gi-mode-top"><span class="ge-gi-mode-h">${m.title}</span>` +
|
|
65
|
+
(stats ? `<span class="ge-gi-mode-stats">${stats}</span>` : '') + '</div>' +
|
|
66
|
+
(m.description ? `<p class="ge-gi-mode-desc">${m.description}</p>` : '');
|
|
67
|
+
return row;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── controls (auto-generated, split into a gameplay block and a menu/overlay block) ──
|
|
71
|
+
type CtlRow = { vis: string; name: string; desc: string; on: boolean };
|
|
72
|
+
|
|
73
|
+
function sectionControls(shell: GameShell, el: HTMLElement): HTMLElement {
|
|
74
|
+
const { features } = shell.config;
|
|
75
|
+
const slot = (inner: string, cls = '') => `<span class="ge-gi-ctl-ic ${cls}">${inner}</span>`;
|
|
76
|
+
const buyLabel = twoLine(shell.t('BUY BONUS'));
|
|
77
|
+
const buyBadge = slot(`<span class="ge-shell-buybonus"><span>${buyLabel}</span></span>`);
|
|
78
|
+
|
|
79
|
+
// Block 1 — gameplay. Bet is split into two rows: one to raise, one to lower.
|
|
80
|
+
const game: CtlRow[] = [
|
|
81
|
+
{ vis: slot(icon('spin')), name: 'Spin', desc: 'Start a spin at the current bet.', on: true },
|
|
82
|
+
{ vis: slot(icon('plus')), name: 'Raise bet', desc: 'Increase your stake.', on: true },
|
|
83
|
+
{ vis: slot(icon('minus')), name: 'Lower bet', desc: 'Decrease your stake.', on: true },
|
|
84
|
+
{ vis: slot(icon('autoplay')), name: 'Autoplay', desc: 'Spin automatically a set number of times.', on: features.autoplay },
|
|
85
|
+
{ vis: slot(icon('turbo1')), name: 'Turbo', desc: 'Speed up spin animations.', on: features.turbo > 0 },
|
|
86
|
+
{ vis: buyBadge, name: 'Buy bonus', desc: 'Pay a fixed cost to enter a bonus feature.', on: features.buyBonus !== false },
|
|
87
|
+
];
|
|
88
|
+
// Block 2 — menu & overlay chrome (always available).
|
|
89
|
+
const menu: CtlRow[] = [
|
|
90
|
+
{ vis: slot(icon('menu')), name: 'Menu', desc: 'Open settings and game info.', on: true },
|
|
91
|
+
{ vis: slot(icon('soundOn')), name: 'Sound', desc: 'Mute or unmute the game.', on: true },
|
|
92
|
+
{ vis: slot(icon('info')), name: 'Game info', desc: 'Open the paytable and rules.', on: true },
|
|
93
|
+
{ vis: slot(icon('close')), name: 'Close', desc: 'Dismiss the current overlay.', on: true },
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
el.appendChild(ctlBlock(shell, 'Game', game));
|
|
97
|
+
el.appendChild(ctlBlock(shell, 'Menu & info', menu));
|
|
98
|
+
return el;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function ctlBlock(shell: GameShell, label: string, rows: CtlRow[]): HTMLElement {
|
|
102
|
+
const block = document.createElement('div'); block.className = 'ge-gi-ctl-block';
|
|
103
|
+
const h = document.createElement('h4'); h.className = 'ge-gi-ctl-block-h'; h.textContent = shell.t(label);
|
|
104
|
+
block.appendChild(h);
|
|
105
|
+
for (const r of rows.filter((x) => x.on)) {
|
|
106
|
+
const row = document.createElement('div'); row.className = 'ge-gi-ctl';
|
|
107
|
+
row.innerHTML = `${r.vis}<div class="ge-gi-ctl-tx"><b>${shell.t(r.name)}</b><span>${shell.t(r.desc)}</span></div>`;
|
|
108
|
+
block.appendChild(row);
|
|
109
|
+
}
|
|
110
|
+
return block;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ── paytable (cards — image on top, name, then win tiers "<count> x<mult>") ────
|
|
114
|
+
function sectionPaytable(rows: PaytableRow[], el: HTMLElement): HTMLElement {
|
|
115
|
+
const grid = document.createElement('div'); grid.className = 'ge-gi-pt-grid';
|
|
116
|
+
for (const r of rows) grid.appendChild(paytableCard(r));
|
|
117
|
+
el.appendChild(grid);
|
|
118
|
+
return el;
|
|
119
|
+
}
|
|
120
|
+
function paytableCard(r: PaytableRow): HTMLElement {
|
|
121
|
+
const card = document.createElement('div'); card.className = 'ge-gi-pt-card';
|
|
122
|
+
const sym = document.createElement('div'); sym.className = 'ge-gi-pt-sym';
|
|
123
|
+
if (r.symbol.image) {
|
|
124
|
+
const img = document.createElement('img'); img.src = r.symbol.image; img.alt = r.symbol.text ?? '';
|
|
125
|
+
sym.appendChild(img);
|
|
126
|
+
}
|
|
127
|
+
if (r.symbol.text) {
|
|
128
|
+
const t = document.createElement('span'); t.textContent = r.symbol.text; sym.appendChild(t);
|
|
129
|
+
}
|
|
130
|
+
const wins = document.createElement('div'); wins.className = 'ge-gi-pt-wins';
|
|
131
|
+
for (const w of r.wins) {
|
|
132
|
+
const wi = document.createElement('span'); wi.className = 'ge-gi-pt-win';
|
|
133
|
+
wi.innerHTML = (w.count ? `<i>${w.count}</i> ` : '') + `<b>x${w.multiplier}</b>`;
|
|
134
|
+
wins.appendChild(wi);
|
|
135
|
+
}
|
|
136
|
+
card.append(sym, wins);
|
|
137
|
+
return card;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ── wins (one section = one pay type; cells filled in the accent colour, no line) ──
|
|
141
|
+
function winFallbackTitle(kind: WinSection['kind']): string {
|
|
142
|
+
return { classic: 'Paylines', cluster: 'Cluster pays', anywhere: 'Pays anywhere', ways: 'Ways to win' }[kind];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function sectionWins(s: WinSection, el: HTMLElement): HTMLElement {
|
|
146
|
+
if (s.kind === 'classic') {
|
|
147
|
+
if (s.description) el.appendChild(winDesc(s.description));
|
|
148
|
+
const wrap = document.createElement('div'); wrap.className = 'ge-gi-pl-grid';
|
|
149
|
+
s.lines.forEach((line, i) => {
|
|
150
|
+
const def: PaylineDef = Array.isArray(line) ? { pattern: line } : line;
|
|
151
|
+
wrap.appendChild(lineItem(s.grid, def, i + 1));
|
|
152
|
+
});
|
|
153
|
+
el.appendChild(wrap);
|
|
154
|
+
} else if (s.kind === 'cluster' || s.kind === 'anywhere') {
|
|
155
|
+
badge(el, `min ${s.minCount}`);
|
|
156
|
+
const row = document.createElement('div'); row.className = 'ge-gi-win-row';
|
|
157
|
+
const example = s.example ?? (s.kind === 'cluster' ? clusterExample(s.grid, s.minCount) : anywhereExample(s.grid, s.minCount));
|
|
158
|
+
row.appendChild(gridSvg(s.grid, example));
|
|
159
|
+
if (s.description) row.appendChild(winDesc(s.description));
|
|
160
|
+
el.appendChild(row);
|
|
161
|
+
} else {
|
|
162
|
+
if (s.description) el.appendChild(winDesc(s.description));
|
|
163
|
+
const two = document.createElement('div'); two.className = 'ge-gi-win-two';
|
|
164
|
+
two.append(
|
|
165
|
+
waysCol('✓ wins', 'ge-gi-win-ok', s.grid, s.winExample ?? waysWin(s.grid)),
|
|
166
|
+
waysCol('✗ no win', 'ge-gi-win-no', s.grid, s.loseExample ?? waysLose(s.grid)),
|
|
167
|
+
);
|
|
168
|
+
el.appendChild(two);
|
|
169
|
+
}
|
|
170
|
+
return el;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function winDesc(text: string): HTMLElement {
|
|
174
|
+
const p = document.createElement('p'); p.className = 'ge-gi-win-desc'; p.textContent = text;
|
|
175
|
+
return p;
|
|
176
|
+
}
|
|
177
|
+
/** Append a "min N" pill to the section header. */
|
|
178
|
+
function badge(el: HTMLElement, text: string): void {
|
|
179
|
+
const h = el.querySelector('h3');
|
|
180
|
+
if (!h) return;
|
|
181
|
+
const b = document.createElement('span'); b.className = 'ge-gi-win-badge'; b.textContent = text;
|
|
182
|
+
h.appendChild(b);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** A cols×rows grid SVG; `on` cells are filled in the accent colour, the rest are faint. */
|
|
186
|
+
function gridSvg(grid: { cols: number; rows: number }, on: CellRef[]): SVGSVGElement {
|
|
187
|
+
const { cols, rows } = grid;
|
|
188
|
+
const W = 100, H = Math.round((rows / cols) * 100);
|
|
189
|
+
const cw = W / cols, ch = H / rows;
|
|
190
|
+
const svg = document.createElementNS(SVG_NS, 'svg');
|
|
191
|
+
svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
|
|
192
|
+
svg.setAttribute('class', 'ge-gi-pl-svg');
|
|
193
|
+
const onSet = new Set(on.map(([c, r]) => `${c},${r}`));
|
|
194
|
+
for (let y = 0; y < rows; y++) for (let x = 0; x < cols; x++) {
|
|
195
|
+
const r = document.createElementNS(SVG_NS, 'rect');
|
|
196
|
+
r.setAttribute('x', String(x * cw + 1)); r.setAttribute('y', String(y * ch + 1));
|
|
197
|
+
r.setAttribute('width', String(cw - 2)); r.setAttribute('height', String(ch - 2));
|
|
198
|
+
r.setAttribute('rx', '2'); r.setAttribute('class', onSet.has(`${x},${y}`) ? 'ge-gi-pl-on' : 'ge-gi-pl-cell');
|
|
199
|
+
svg.appendChild(r);
|
|
200
|
+
}
|
|
201
|
+
return svg;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** A classic payline: number caption on top, filled cells (no connecting line). */
|
|
205
|
+
function lineItem(grid: { cols: number; rows: number }, def: PaylineDef, n: number): HTMLElement {
|
|
206
|
+
const item = document.createElement('div'); item.className = 'ge-gi-pl-item';
|
|
207
|
+
const cap = document.createElement('span'); cap.className = 'ge-gi-pl-cap'; cap.textContent = String(n);
|
|
208
|
+
const on: CellRef[] = def.pattern.map((rowIdx, col) => [col, rowIdx]);
|
|
209
|
+
item.append(cap, gridSvg(grid, on)); // caption first → renders above the grid
|
|
210
|
+
return item;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function waysCol(tag: string, tagCls: string, grid: { cols: number; rows: number }, cells: CellRef[]): HTMLElement {
|
|
214
|
+
const col = document.createElement('div'); col.className = 'ge-gi-win-col';
|
|
215
|
+
const t = document.createElement('span'); t.className = `ge-gi-win-tag ${tagCls}`; t.textContent = tag;
|
|
216
|
+
col.append(t, gridSvg(grid, cells));
|
|
217
|
+
return col;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Default illustrations (used when the section omits an explicit example).
|
|
221
|
+
function clusterExample(grid: { cols: number; rows: number }, n: number): CellRef[] {
|
|
222
|
+
const w = Math.min(grid.cols, Math.max(1, Math.ceil(Math.sqrt(n))));
|
|
223
|
+
const cells: CellRef[] = [];
|
|
224
|
+
for (let y = 0; y < grid.rows && cells.length < n; y++)
|
|
225
|
+
for (let x = 0; x < w && cells.length < n; x++) cells.push([x, y]);
|
|
226
|
+
return cells;
|
|
227
|
+
}
|
|
228
|
+
function anywhereExample(grid: { cols: number; rows: number }, n: number): CellRef[] {
|
|
229
|
+
const count = Math.min(n, grid.cols * grid.rows);
|
|
230
|
+
const cells: CellRef[] = [];
|
|
231
|
+
for (let i = 0; i < count; i++) cells.push([Math.floor((i * grid.cols) / count), (i * 2 + 1) % grid.rows]);
|
|
232
|
+
return cells;
|
|
233
|
+
}
|
|
234
|
+
function waysWin(grid: { cols: number; rows: number }): CellRef[] {
|
|
235
|
+
const cells: CellRef[] = [];
|
|
236
|
+
for (let c = 0; c < grid.cols; c++) cells.push([c, c % grid.rows]); // one symbol on every reel
|
|
237
|
+
return cells;
|
|
238
|
+
}
|
|
239
|
+
function waysLose(grid: { cols: number; rows: number }): CellRef[] {
|
|
240
|
+
const gap = Math.floor(grid.cols / 2);
|
|
241
|
+
return waysWin(grid).filter(([c]) => c !== gap); // a broken chain (reel `gap` empty)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ── custom ───────────────────────────────────────────────────────────────────
|
|
245
|
+
function sectionCustom(s: Extract<GameInfoSection, { type: 'custom' }>, el: HTMLElement): HTMLElement {
|
|
246
|
+
if (s.node) {
|
|
247
|
+
el.appendChild(s.node);
|
|
248
|
+
} else if (s.html) {
|
|
249
|
+
const d = document.createElement('div'); d.className = 'ge-gi-custom'; d.innerHTML = s.html;
|
|
250
|
+
el.appendChild(d);
|
|
251
|
+
}
|
|
252
|
+
return el;
|
|
253
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
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 bonus = Array.isArray(shell.config.features.buyBonus)
|
|
14
|
+
? shell.config.features.buyBonus.find((b) => b.id === bonusId)
|
|
15
|
+
: undefined;
|
|
16
|
+
const mode = bonus?.title ?? bonusId;
|
|
17
|
+
const costMultiplier = bonus?.priceMultiplier ?? 1;
|
|
18
|
+
|
|
19
|
+
const ui = createCardModal({
|
|
20
|
+
ge: 'replay-modal',
|
|
21
|
+
title: shell.t('Replay'),
|
|
22
|
+
closable: false, // no ✕; the backdrop never dismisses it either
|
|
23
|
+
onClose: () => {}, // unused — there is no close affordance
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const rows = document.createElement('div'); rows.className = 'ge-replay-rows';
|
|
27
|
+
const row = (label: string, value: string, total = false): void => {
|
|
28
|
+
const r = document.createElement('div'); r.className = `ge-replay-row${total ? ' ge-replay-total' : ''}`;
|
|
29
|
+
const l = document.createElement('span'); l.textContent = shell.t(label);
|
|
30
|
+
const v = document.createElement('b'); v.textContent = value;
|
|
31
|
+
r.append(l, v); rows.appendChild(r);
|
|
32
|
+
};
|
|
33
|
+
row('Mode', mode);
|
|
34
|
+
row('Base bet', fmt(bet));
|
|
35
|
+
row('Cost multiplier', `${costMultiplier}×`);
|
|
36
|
+
row('Total cost bet', fmt(bet * costMultiplier));
|
|
37
|
+
row('Payout multiplier', `${payoutMultiplier}×`);
|
|
38
|
+
row('Total win', fmt(payoutMultiplier * bet), true);
|
|
39
|
+
ui.body.appendChild(rows);
|
|
40
|
+
|
|
41
|
+
const actions = document.createElement('div'); actions.className = 'ge-modal-actions';
|
|
42
|
+
const btn = document.createElement('button');
|
|
43
|
+
btn.className = 'ge-modal-btn ge-modal-btn--accent'; btn.dataset.ge = 'replay-start';
|
|
44
|
+
btn.textContent = shell.t('Start replay');
|
|
45
|
+
btn.addEventListener('click', () => {
|
|
46
|
+
ui.root.remove(); // close immediately
|
|
47
|
+
// Reopen after the handler settles. On rejection we still reopen — this modal is the only
|
|
48
|
+
// way out of replay mode, so a failed play must not strand the user on an empty screen.
|
|
49
|
+
const reopen = (): void => { shell.openReplay(opts); };
|
|
50
|
+
Promise.resolve(opts.onReplay()).then(reopen, reopen);
|
|
51
|
+
});
|
|
52
|
+
actions.appendChild(btn);
|
|
53
|
+
ui.card.appendChild(actions);
|
|
54
|
+
|
|
55
|
+
return ui.root;
|
|
56
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
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 (starts on) — full-width row with a speaker icon button
|
|
10
|
+
const sound = (() => {
|
|
11
|
+
let on = true;
|
|
12
|
+
const btn = document.createElement('button');
|
|
13
|
+
btn.className = 'ge-snd ge-active'; btn.dataset.ge = 'setting-sound';
|
|
14
|
+
btn.setAttribute('aria-label', 'Sound');
|
|
15
|
+
const paint = () => {
|
|
16
|
+
btn.innerHTML = icon(on ? 'soundOn' : 'soundOff');
|
|
17
|
+
btn.classList.toggle('ge-active', on);
|
|
18
|
+
btn.setAttribute('aria-pressed', String(on));
|
|
19
|
+
};
|
|
20
|
+
paint();
|
|
21
|
+
btn.addEventListener('click', () => {
|
|
22
|
+
on = !on; paint();
|
|
23
|
+
shell.emit('settingChange', { key: 'sound', value: on });
|
|
24
|
+
});
|
|
25
|
+
const row = document.createElement('div'); row.className = 'ge-ov-row';
|
|
26
|
+
row.innerHTML = `<span class="ge-grow">${shell.t('Sound')}</span>`; row.appendChild(btn);
|
|
27
|
+
return row;
|
|
28
|
+
})();
|
|
29
|
+
body.appendChild(sound);
|
|
30
|
+
|
|
31
|
+
// Volume sliders — full-width column rows with a live value readout
|
|
32
|
+
const slider = (key: string, label: string) => {
|
|
33
|
+
const row = document.createElement('div'); row.className = 'ge-ov-row ge-col';
|
|
34
|
+
const head = document.createElement('div'); head.className = 'ge-row-head';
|
|
35
|
+
const val = document.createElement('span'); val.className = 'ge-val'; val.textContent = '100%';
|
|
36
|
+
head.innerHTML = `<span>${label}</span>`; head.appendChild(val);
|
|
37
|
+
const input = document.createElement('input');
|
|
38
|
+
input.type = 'range'; input.min = '0'; input.max = '1'; input.step = '0.05'; input.value = '1';
|
|
39
|
+
input.className = 'ge-slider'; input.dataset.ge = `setting-${key}`;
|
|
40
|
+
input.addEventListener('input', () => {
|
|
41
|
+
val.textContent = `${Math.round(Number(input.value) * 100)}%`;
|
|
42
|
+
shell.emit('settingChange', { key, value: Number(input.value) });
|
|
43
|
+
});
|
|
44
|
+
row.append(head, input);
|
|
45
|
+
return row;
|
|
46
|
+
};
|
|
47
|
+
body.appendChild(slider('master', shell.t('Master volume')));
|
|
48
|
+
body.appendChild(slider('music', shell.t('Music')));
|
|
49
|
+
body.appendChild(slider('sfx', shell.t('SFX')));
|
|
50
|
+
|
|
51
|
+
// Game info — full-width row button that opens its own overlay
|
|
52
|
+
const gameInfo = document.createElement('button');
|
|
53
|
+
gameInfo.className = 'ge-ov-row'; gameInfo.dataset.ge = 'game-info-btn';
|
|
54
|
+
gameInfo.style.marginTop = '6px';
|
|
55
|
+
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>`;
|
|
56
|
+
gameInfo.addEventListener('click', () => { root.remove(); shell.openInfo(); });
|
|
57
|
+
body.appendChild(gameInfo);
|
|
58
|
+
|
|
59
|
+
return root;
|
|
60
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { GameShell } from '../GameShell';
|
|
2
|
+
import { formatCurrency } from '../format';
|
|
3
|
+
import { createCardModal } from './primitives';
|
|
4
|
+
|
|
5
|
+
interface Choice { id: string; label: string }
|
|
6
|
+
|
|
7
|
+
interface SheetOpts {
|
|
8
|
+
ge: string;
|
|
9
|
+
title: string;
|
|
10
|
+
choices: Choice[];
|
|
11
|
+
selected: string;
|
|
12
|
+
columns: number;
|
|
13
|
+
confirmLabel: string;
|
|
14
|
+
onConfirm: (id: string) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** A centred picker (chips grid + accent Confirm) on the shared card modal. */
|
|
18
|
+
function buildSheet(opts: SheetOpts): HTMLElement {
|
|
19
|
+
const ui = createCardModal({ ge: opts.ge, title: opts.title, onClose: () => ui.root.remove() });
|
|
20
|
+
|
|
21
|
+
const grid = document.createElement('div'); grid.className = 'ge-sheet-grid';
|
|
22
|
+
grid.style.gridTemplateColumns = `repeat(${opts.columns}, 1fr)`;
|
|
23
|
+
let selected = opts.selected;
|
|
24
|
+
const chips: HTMLButtonElement[] = [];
|
|
25
|
+
for (const c of opts.choices) {
|
|
26
|
+
const chip = document.createElement('button');
|
|
27
|
+
chip.className = 'ge-chip' + (c.id === selected ? ' ge-on' : '');
|
|
28
|
+
chip.dataset.id = c.id; chip.textContent = c.label;
|
|
29
|
+
chip.addEventListener('click', () => {
|
|
30
|
+
selected = c.id;
|
|
31
|
+
for (const x of chips) x.classList.toggle('ge-on', x.dataset.id === selected);
|
|
32
|
+
});
|
|
33
|
+
chips.push(chip); grid.appendChild(chip);
|
|
34
|
+
}
|
|
35
|
+
ui.body.appendChild(grid);
|
|
36
|
+
|
|
37
|
+
// Single full-bleed Confirm; dismissal is the ✕ (top-right). No Cancel button.
|
|
38
|
+
const confirm = document.createElement('button');
|
|
39
|
+
confirm.className = 'ge-modal-btn ge-modal-btn--accent'; confirm.dataset.ge = 'sheet-confirm';
|
|
40
|
+
confirm.textContent = opts.confirmLabel;
|
|
41
|
+
confirm.addEventListener('click', () => { opts.onConfirm(selected); ui.root.remove(); });
|
|
42
|
+
ui.card.appendChild(confirm);
|
|
43
|
+
|
|
44
|
+
return ui.root;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Bet picker — all available bets as chips (3 per row), accent Confirm applies it. */
|
|
48
|
+
export function openBetModal(shell: GameShell): HTMLElement {
|
|
49
|
+
return buildSheet({
|
|
50
|
+
ge: 'bet-modal', title: shell.t('Bet'), columns: 3, confirmLabel: shell.t('Confirm'),
|
|
51
|
+
choices: shell.state.availableBets.map((b) => ({ id: String(b), label: formatCurrency(b, shell.config.currency) })),
|
|
52
|
+
selected: String(shell.state.bet),
|
|
53
|
+
onConfirm: (id) => {
|
|
54
|
+
const v = Number(id);
|
|
55
|
+
if (v !== shell.state.bet) { shell.state.bet = v; shell.emit('betChange', v); }
|
|
56
|
+
shell.render();
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const AUTOPLAY_COUNTS = [10, 25, 50, 100, 250, 500, 1000, 2000, Infinity];
|
|
62
|
+
|
|
63
|
+
/** Autoplay picker — spin counts incl. ∞; Confirm starts autoplay. */
|
|
64
|
+
export function openAutoplayModal(shell: GameShell): HTMLElement {
|
|
65
|
+
return buildSheet({
|
|
66
|
+
ge: 'autoplay-modal', title: shell.t('Autoplay'), columns: 3, confirmLabel: shell.t('Start'),
|
|
67
|
+
choices: AUTOPLAY_COUNTS.map((n) => ({ id: String(n), label: Number.isFinite(n) ? String(n) : '∞' })),
|
|
68
|
+
selected: String(shell.state.autoplay.remaining || 10),
|
|
69
|
+
onConfirm: (id) => {
|
|
70
|
+
const remaining = Number(id); // "Infinity" → Infinity
|
|
71
|
+
shell.state.autoplay = { active: true, remaining };
|
|
72
|
+
shell.emit('autoplayStart', { active: true, remaining });
|
|
73
|
+
shell.render();
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { icon } from './icons';
|
|
2
|
+
|
|
3
|
+
/** Render a (possibly socialised) two-word label across two lines — the BUY BONUS badge.
|
|
4
|
+
* Shared so the bottom-bar button and the Game-info control legend break identically. */
|
|
5
|
+
export function twoLine(label: string): string {
|
|
6
|
+
return label.split(/\s+/).join('<br>');
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface CardModalOpts {
|
|
10
|
+
ge: string;
|
|
11
|
+
title: string;
|
|
12
|
+
/** Accent for the title heading + accent footer button (defaults to the shell accent). */
|
|
13
|
+
accent?: string;
|
|
14
|
+
onClose: () => void;
|
|
15
|
+
/** Render the ✕ in the overlay's top-right corner (default true). */
|
|
16
|
+
closable?: boolean;
|
|
17
|
+
/** Backdrop blur in px; omit to use the stylesheet's default blur. */
|
|
18
|
+
blur?: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A centred CARD modal — frosted backdrop + opaque card with an accent title heading and an
|
|
22
|
+
* overlay ✕ in the top-right. The shared chrome for every centred modal (buy-bonus confirm,
|
|
23
|
+
* bet, autoplay, generic openModal). Append content to `body`; append full-bleed footer
|
|
24
|
+
* button(s) directly to `card`. Closes only via the ✕ / footer buttons — the backdrop does NOT. */
|
|
25
|
+
export function createCardModal(
|
|
26
|
+
opts: CardModalOpts,
|
|
27
|
+
): { root: HTMLDivElement; card: HTMLDivElement; body: HTMLDivElement } {
|
|
28
|
+
const root = document.createElement('div');
|
|
29
|
+
root.className = 'ge-sheet'; root.dataset.ge = opts.ge;
|
|
30
|
+
if (opts.blur != null) root.style.setProperty('--ge-sheet-blur', `${opts.blur}px`);
|
|
31
|
+
const card = document.createElement('div'); card.className = 'ge-modal-card';
|
|
32
|
+
if (opts.accent) card.style.setProperty('--card-acc', opts.accent);
|
|
33
|
+
const body = document.createElement('div'); body.className = 'ge-modal-body';
|
|
34
|
+
const h = document.createElement('h4'); h.className = 'ge-modal-title'; h.textContent = opts.title;
|
|
35
|
+
body.appendChild(h);
|
|
36
|
+
card.appendChild(body);
|
|
37
|
+
root.appendChild(card);
|
|
38
|
+
// ✕ lives on the overlay itself (top-right of the screen), not on the card
|
|
39
|
+
if (opts.closable !== false) {
|
|
40
|
+
const close = document.createElement('button');
|
|
41
|
+
close.className = 'ge-modal-close'; close.dataset.ge = 'modal-close';
|
|
42
|
+
close.setAttribute('aria-label', 'Close'); close.innerHTML = icon('close');
|
|
43
|
+
close.addEventListener('click', opts.onClose);
|
|
44
|
+
root.appendChild(close);
|
|
45
|
+
}
|
|
46
|
+
return { root, card, body };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface OverlayOpts {
|
|
50
|
+
title: string;
|
|
51
|
+
onClose: () => void;
|
|
52
|
+
onBack?: () => void;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Full-screen overlay. Returns { root, body }; append content to body. */
|
|
56
|
+
export function createOverlay(opts: OverlayOpts): { root: HTMLDivElement; body: HTMLDivElement } {
|
|
57
|
+
const root = document.createElement('div');
|
|
58
|
+
root.className = 'ge-shell-overlay';
|
|
59
|
+
const head = document.createElement('div');
|
|
60
|
+
head.className = 'ge-ov-head';
|
|
61
|
+
if (opts.onBack) {
|
|
62
|
+
const back = document.createElement('button');
|
|
63
|
+
back.className = 'ge-ov-nav'; back.dataset.ge = 'info-back'; back.innerHTML = icon('back');
|
|
64
|
+
back.addEventListener('click', opts.onBack);
|
|
65
|
+
head.appendChild(back);
|
|
66
|
+
} else {
|
|
67
|
+
// reserve a slot equal to the close button so the title stays centred
|
|
68
|
+
const spacer = document.createElement('div');
|
|
69
|
+
spacer.className = 'ge-ov-spacer';
|
|
70
|
+
head.appendChild(spacer);
|
|
71
|
+
}
|
|
72
|
+
const h = document.createElement('h4'); h.className = 'ge-ov-title'; h.textContent = opts.title; head.appendChild(h);
|
|
73
|
+
const close = document.createElement('button');
|
|
74
|
+
close.className = 'ge-ov-nav'; close.setAttribute('aria-label', 'Close'); close.innerHTML = icon('close');
|
|
75
|
+
close.addEventListener('click', opts.onClose);
|
|
76
|
+
head.appendChild(close);
|
|
77
|
+
// Header stays fixed; only this wrapper scrolls — the X never scrolls away,
|
|
78
|
+
// and vh-clamped padding keeps it usable on small popouts (e.g. 400×225).
|
|
79
|
+
const scroll = document.createElement('div'); scroll.className = 'ge-ov-scroll';
|
|
80
|
+
const body = document.createElement('div'); body.className = 'ge-ov-body';
|
|
81
|
+
scroll.appendChild(body);
|
|
82
|
+
root.append(head, scroll);
|
|
83
|
+
return { root, body };
|
|
84
|
+
}
|