@energy8platform/shell 0.2.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/dist/html.cjs.js +3894 -0
- package/dist/html.cjs.js.map +1 -0
- package/dist/html.d.ts +616 -0
- package/dist/html.esm.js +3879 -0
- package/dist/html.esm.js.map +1 -0
- package/dist/index.cjs.js +1593 -0
- package/dist/index.cjs.js.map +1 -0
- package/dist/index.d.ts +554 -0
- package/dist/index.esm.js +1582 -0
- package/dist/index.esm.js.map +1 -0
- package/dist/pixi.cjs.js +5740 -0
- package/dist/pixi.cjs.js.map +1 -0
- package/dist/pixi.d.ts +682 -0
- package/dist/pixi.esm.js +5726 -0
- package/dist/pixi.esm.js.map +1 -0
- package/package.json +79 -0
- package/src/core/EventEmitter.ts +55 -0
- package/src/core/ShellController.ts +234 -0
- package/src/core/colors.ts +32 -0
- package/src/core/fonts-digits.ts +12 -0
- package/src/core/fonts.ts +13 -0
- package/src/core/format.ts +39 -0
- package/src/core/i18n.ts +96 -0
- package/src/core/index.ts +35 -0
- package/src/core/keyboard.ts +229 -0
- package/src/core/locales.ts +864 -0
- package/src/core/motion.ts +10 -0
- package/src/core/renderer.ts +121 -0
- package/src/core/state.ts +31 -0
- package/src/core/theme.ts +81 -0
- package/src/core/types.ts +269 -0
- package/src/core/version.ts +3 -0
- package/src/ui/html/HtmlRenderer.ts +292 -0
- package/src/ui/html/components/BottomBar.ts +240 -0
- package/src/ui/html/components/BuyBonus.ts +326 -0
- package/src/ui/html/components/GameInfo.ts +384 -0
- package/src/ui/html/components/Modal.ts +36 -0
- package/src/ui/html/components/ReplayModal.ts +58 -0
- package/src/ui/html/components/Settings.ts +59 -0
- package/src/ui/html/components/pickers.ts +146 -0
- package/src/ui/html/icons-preview.svg +224 -0
- package/src/ui/html/icons.ts +31 -0
- package/src/ui/html/index.ts +36 -0
- package/src/ui/html/motion-dom.ts +29 -0
- package/src/ui/html/primitives.ts +85 -0
- package/src/ui/html/shell.css.ts +528 -0
- package/src/ui/html/theme-css.ts +21 -0
- package/src/ui/pixi/PixiRenderer.ts +350 -0
- package/src/ui/pixi/components/BottomBar.ts +477 -0
- package/src/ui/pixi/components/BuyBonus.ts +763 -0
- package/src/ui/pixi/components/GameInfo.ts +559 -0
- package/src/ui/pixi/components/Modal.ts +40 -0
- package/src/ui/pixi/components/ReplayModal.ts +80 -0
- package/src/ui/pixi/components/Settings.ts +117 -0
- package/src/ui/pixi/components/pickers.ts +170 -0
- package/src/ui/pixi/context.ts +52 -0
- package/src/ui/pixi/icons.ts +45 -0
- package/src/ui/pixi/index.ts +56 -0
- package/src/ui/pixi/motion-pixi.ts +58 -0
- package/src/ui/pixi/pixi-icon.ts +119 -0
- package/src/ui/pixi/primitives/card.ts +227 -0
- package/src/ui/pixi/primitives/controls.ts +243 -0
- package/src/ui/pixi/primitives/flex.ts +335 -0
- package/src/ui/pixi/primitives/overlay.ts +181 -0
- package/src/ui/pixi/primitives/scroll.ts +117 -0
- package/src/ui/pixi/primitives/widgets.ts +680 -0
- package/src/ui/pixi/text.ts +131 -0
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
import type { ShellHost } from '@/core/renderer';
|
|
2
|
+
import type { CellRef, GameInfoSection, GameMode, PaytableRow, PaylineDef, ShapeDef, WinSection } from '@/core/types';
|
|
3
|
+
import { createOverlay, twoLine } from '../primitives';
|
|
4
|
+
import { icon } from '../icons';
|
|
5
|
+
import { PACKAGE_VERSION } from '@/core/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(host: ShellHost): GameInfoModal {
|
|
19
|
+
const { root, body, scroll } = createOverlay({
|
|
20
|
+
title: host.t('Game info'),
|
|
21
|
+
onClose: () => root.remove(),
|
|
22
|
+
onBack: () => { root.remove(); host.actions.openSettings(); },
|
|
23
|
+
});
|
|
24
|
+
root.dataset.ge = 'info-modal';
|
|
25
|
+
|
|
26
|
+
const rawSections = host.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 (host.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(host, s)));
|
|
41
|
+
|
|
42
|
+
body.appendChild(versionFooter(host));
|
|
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(host: ShellHost): HTMLElement {
|
|
68
|
+
const gameVersion = host.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(host: ShellHost, s: GameInfoSection): HTMLElement {
|
|
77
|
+
switch (s.type) {
|
|
78
|
+
case 'modes': return sectionModes(host, s.modes, sec('info-modes', s.title, host.t('Modes')));
|
|
79
|
+
case 'controls': return sectionControls(host, sec('info-controls', s.title, host.t('Controls')));
|
|
80
|
+
case 'hotkeys': return sectionHotkeys(host, sec('info-hotkeys', s.title, host.t('Hotkeys')));
|
|
81
|
+
case 'paytable': return sectionPaytable(s.rows, sec('info-paytable', s.title, host.t('Paytable')));
|
|
82
|
+
case 'wins': return sectionWins(s, sec('info-wins', s.title, host.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 ? host.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(host: ShellHost, 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(host, m));
|
|
101
|
+
el.appendChild(list);
|
|
102
|
+
return el;
|
|
103
|
+
}
|
|
104
|
+
function modeRow(host: ShellHost, 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(host.t('Price'), m.price);
|
|
110
|
+
if (typeof m.rtp === 'number') stats += stat(host.t('RTP'), `${m.rtp}%`);
|
|
111
|
+
if (m.maxWin != null) stats += stat(host.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(host: ShellHost, el: HTMLElement): HTMLElement {
|
|
123
|
+
const { features } = host.config;
|
|
124
|
+
const slot = (inner: string, cls = '') => `<span class="ge-gi-ctl-ic ${cls}">${inner}</span>`;
|
|
125
|
+
const buyLabel = twoLine(host.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(host, 'Game', game));
|
|
146
|
+
el.appendChild(ctlBlock(host, 'Menu & info', menu));
|
|
147
|
+
return el;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function ctlBlock(host: ShellHost, 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 = host.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>${host.t(r.name)}</b><span>${host.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(host: ShellHost, el: HTMLElement): HTMLElement {
|
|
166
|
+
const { features } = host.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 = host.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 as 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
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { ModalOptions } from '@/core/types';
|
|
2
|
+
import { contrastText } from '@/core/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,58 @@
|
|
|
1
|
+
import type { ShellHost } from '@/core/renderer';
|
|
2
|
+
import type { ReplayModalOptions } from '@/core/types';
|
|
3
|
+
import { createCardModal } from '../primitives';
|
|
4
|
+
|
|
5
|
+
/** The replay summary modal — built on the shared card chrome, but NOT dismissable: no ✕,
|
|
6
|
+
* and the backdrop never closes it. The only way out is START REPLAY, which closes the
|
|
7
|
+
* modal, runs `onReplay`, then reopens it (whether the handler resolves OR rejects, so a
|
|
8
|
+
* failed replay can't strand the user).
|
|
9
|
+
*
|
|
10
|
+
* `reopen` is called after `opts.onReplay()` settles and rebuilds+shows this modal; the
|
|
11
|
+
* HtmlRenderer supplies it (e.g. `() => renderer.openOverlay({ kind: 'replay', opts })`). */
|
|
12
|
+
export function buildReplayModal(host: ShellHost, opts: ReplayModalOptions, reopen: () => void): HTMLElement {
|
|
13
|
+
const { bonusId, bet, payoutMultiplier } = opts;
|
|
14
|
+
const fmt = (n: number) => host.formatCurrency(n);
|
|
15
|
+
const fmtWin = (n: number) => host.formatCurrency(n, true); // total win: variable decimals
|
|
16
|
+
const bonus = Array.isArray(host.config.features.buyBonus)
|
|
17
|
+
? host.config.features.buyBonus.find((b) => b.id === bonusId)
|
|
18
|
+
: undefined;
|
|
19
|
+
const mode = bonus?.title ?? bonusId;
|
|
20
|
+
const costMultiplier = bonus?.priceMultiplier ?? 1;
|
|
21
|
+
|
|
22
|
+
const ui = createCardModal({
|
|
23
|
+
ge: 'replay-modal',
|
|
24
|
+
title: host.t('Replay'),
|
|
25
|
+
closable: false, // no ✕; the backdrop never dismisses it either
|
|
26
|
+
onClose: () => {}, // unused — there is no close affordance
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const rows = document.createElement('div'); rows.className = 'ge-replay-rows';
|
|
30
|
+
const row = (label: string, value: string, total = false): void => {
|
|
31
|
+
const r = document.createElement('div'); r.className = `ge-replay-row${total ? ' ge-replay-total' : ''}`;
|
|
32
|
+
const l = document.createElement('span'); l.textContent = host.t(label);
|
|
33
|
+
const v = document.createElement('b'); v.textContent = value;
|
|
34
|
+
r.append(l, v); rows.appendChild(r);
|
|
35
|
+
};
|
|
36
|
+
row('Mode', mode);
|
|
37
|
+
row('Base bet', fmt(bet));
|
|
38
|
+
row('Cost multiplier', `${costMultiplier}×`);
|
|
39
|
+
row('Total cost', fmt(bet * costMultiplier));
|
|
40
|
+
row('Win multiplier', `${payoutMultiplier}×`);
|
|
41
|
+
row('Total win', fmtWin(payoutMultiplier * bet), true);
|
|
42
|
+
ui.body.appendChild(rows);
|
|
43
|
+
|
|
44
|
+
const actions = document.createElement('div'); actions.className = 'ge-modal-actions';
|
|
45
|
+
const btn = document.createElement('button');
|
|
46
|
+
btn.className = 'ge-modal-btn ge-modal-btn--accent'; btn.dataset.ge = 'replay-start';
|
|
47
|
+
btn.textContent = host.t('Start replay');
|
|
48
|
+
btn.addEventListener('click', () => {
|
|
49
|
+
ui.root.remove(); // close immediately
|
|
50
|
+
// Reopen after the handler settles. On rejection we still reopen — this modal is the only
|
|
51
|
+
// way out of replay mode, so a failed play must not strand the user on an empty screen.
|
|
52
|
+
Promise.resolve(opts.onReplay()).then(reopen, reopen);
|
|
53
|
+
});
|
|
54
|
+
actions.appendChild(btn);
|
|
55
|
+
ui.card.appendChild(actions);
|
|
56
|
+
|
|
57
|
+
return ui.root;
|
|
58
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { ShellHost } from '@/core/renderer';
|
|
2
|
+
import { createOverlay } from '../primitives';
|
|
3
|
+
import { icon } from '../icons';
|
|
4
|
+
|
|
5
|
+
export function openSettingsModal(host: ShellHost): HTMLElement {
|
|
6
|
+
const { root, body } = createOverlay({ title: host.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', host.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(host.soundOn);
|
|
21
|
+
btn.addEventListener('click', () => host.setSound(!host.soundOn));
|
|
22
|
+
// Live-update the icon when sound changes from here OR via Shift+M (shell clears on close).
|
|
23
|
+
host.setSoundRefresh(paint);
|
|
24
|
+
const row = document.createElement('div'); row.className = 'ge-ov-row';
|
|
25
|
+
row.innerHTML = `<span class="ge-grow">${host.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
|
+
host.emit('settingChange', { key, value: Number(input.value) });
|
|
42
|
+
});
|
|
43
|
+
row.append(head, input);
|
|
44
|
+
return row;
|
|
45
|
+
};
|
|
46
|
+
body.appendChild(slider('master', host.t('Master volume')));
|
|
47
|
+
body.appendChild(slider('music', host.t('Music')));
|
|
48
|
+
body.appendChild(slider('sfx', host.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">${host.t('Game info')}</span><span style="width:20px;font-size:20px;color:var(--shell-muted)">${icon('chevronRight')}</span>`;
|
|
55
|
+
gameInfo.addEventListener('click', () => { root.remove(); host.actions.openInfo(); });
|
|
56
|
+
body.appendChild(gameInfo);
|
|
57
|
+
|
|
58
|
+
return root;
|
|
59
|
+
}
|