@energy8platform/platform-core 0.25.4 → 0.26.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/game-spec.d.ts +3 -0
- package/dist/index.cjs.js +1749 -173
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +77 -9
- package/dist/index.esm.js +1749 -173
- package/dist/index.esm.js.map +1 -1
- package/dist/loading.cjs.js +237 -90
- package/dist/loading.cjs.js.map +1 -1
- package/dist/loading.d.ts +52 -2
- package/dist/loading.esm.js +235 -90
- package/dist/loading.esm.js.map +1 -1
- package/dist/shell.cjs.js +1516 -83
- package/dist/shell.cjs.js.map +1 -1
- package/dist/shell.d.ts +43 -11
- package/dist/shell.esm.js +1515 -84
- package/dist/shell.esm.js.map +1 -1
- package/package.json +1 -1
- package/src/game-spec/types.ts +3 -0
- package/src/loading/CSSPreloader.ts +21 -115
- package/src/loading/index.ts +6 -0
- package/src/loading/variants/energy8.ts +105 -0
- package/src/loading/variants/index.ts +19 -0
- package/src/loading/variants/types.ts +36 -0
- package/src/loading/variants/voidmoon.ts +134 -0
- package/src/shell/GameShell.ts +87 -41
- package/src/shell/components/BuyBonus.ts +157 -14
- package/src/shell/components/GameInfo.ts +104 -5
- package/src/shell/components/Settings.ts +9 -10
- package/src/shell/components/pickers.ts +66 -10
- package/src/shell/components/primitives.ts +4 -3
- package/src/shell/i18n.ts +23 -0
- package/src/shell/index.ts +2 -1
- package/src/shell/keyboard.ts +229 -0
- package/src/shell/locales.ts +864 -0
- package/src/shell/shell.css.ts +17 -0
- package/src/shell/types.ts +8 -0
- package/src/shell/version.ts +1 -1
- package/src/types.ts +8 -0
package/src/shell/GameShell.ts
CHANGED
|
@@ -11,7 +11,8 @@ import type {
|
|
|
11
11
|
ShellState,
|
|
12
12
|
ThemeConfig,
|
|
13
13
|
} from './types';
|
|
14
|
-
import {
|
|
14
|
+
import { KeyboardController, type KeyboardHost } from './keyboard';
|
|
15
|
+
import { createInitialState, nextTurbo, stepBet } from './state';
|
|
15
16
|
import { buildThemeVars } from './theme';
|
|
16
17
|
import { SHELL_CSS, SHELL_ROOT_ID } from './shell.css';
|
|
17
18
|
import { renderBottomBar } from './components/BottomBar';
|
|
@@ -23,7 +24,7 @@ import { buildModal } from './components/Modal';
|
|
|
23
24
|
import { buildReplayModal } from './components/ReplayModal';
|
|
24
25
|
import { countUp } from './motion';
|
|
25
26
|
import { formatCurrency } from './format';
|
|
26
|
-
import {
|
|
27
|
+
import { createI18n, type I18n } from './i18n';
|
|
27
28
|
|
|
28
29
|
const REMOVE_FADE_MS = 300;
|
|
29
30
|
|
|
@@ -40,11 +41,20 @@ export class GameShell extends EventEmitter<ShellEvents> {
|
|
|
40
41
|
private prevBalance = 0;
|
|
41
42
|
private prevWin = 0;
|
|
42
43
|
private moneyAnims: Array<() => void> = [];
|
|
43
|
-
private
|
|
44
|
+
private kbd!: KeyboardController;
|
|
45
|
+
private i18n!: I18n;
|
|
46
|
+
/** onKey handler of the currently open modal/overlay, if any (set in showModal, cleared in closeModal). */
|
|
47
|
+
private modalOnKey: ((e: KeyboardEvent) => boolean) | undefined = undefined;
|
|
48
|
+
/** Shared sound on/off state — Settings speaker toggle and the Shift+M hotkey stay in sync. The
|
|
49
|
+
* game listens to `settingChange({ key: 'sound' })` to (un)mute audio. */
|
|
50
|
+
soundOn = true;
|
|
51
|
+
/** Set by the open Settings modal so Shift+M live-updates its speaker icon; cleared on close. */
|
|
52
|
+
private soundRefresh: ((on: boolean) => void) | null = null;
|
|
44
53
|
|
|
45
54
|
constructor(config: ShellConfig) {
|
|
46
55
|
super();
|
|
47
56
|
this.config = config;
|
|
57
|
+
this.i18n = createI18n({ language: config.language, isSocial: config.isSocial });
|
|
48
58
|
this.state = createInitialState(config);
|
|
49
59
|
|
|
50
60
|
this.styleEl = document.createElement('style');
|
|
@@ -63,12 +73,47 @@ export class GameShell extends EventEmitter<ShellEvents> {
|
|
|
63
73
|
this.prevWin = this.state.win;
|
|
64
74
|
this.observeLayout();
|
|
65
75
|
if (typeof document !== 'undefined') {
|
|
66
|
-
|
|
76
|
+
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
|
77
|
+
const shell = this;
|
|
78
|
+
const host: KeyboardHost = {
|
|
79
|
+
get state() { return shell.state; },
|
|
80
|
+
get hotkeysEnabled() { return shell.config.features.hotkeys !== false; },
|
|
81
|
+
get spacebarEnabled() { return shell.config.features.spacebar !== false; },
|
|
82
|
+
get turboLevels() { return shell.config.features.turbo; },
|
|
83
|
+
get autoplayEnabled() { return shell.config.features.autoplay != null; },
|
|
84
|
+
get buyBonusEnabled() { return shell.config.features.buyBonus !== false; },
|
|
85
|
+
hasOpenLayer: () => shell.modalHost.childElementCount > 0,
|
|
86
|
+
routeToLayer: (e) => shell.modalOnKey?.(e) ?? false,
|
|
87
|
+
spin: () => shell.emit('spin'),
|
|
88
|
+
stepBet: (dir) => {
|
|
89
|
+
const next = stepBet(shell.state, dir);
|
|
90
|
+
if (next === shell.state.bet) return;
|
|
91
|
+
shell.state.bet = next; shell.emit('betChange', next); shell.render();
|
|
92
|
+
},
|
|
93
|
+
toggleAutoplay: () => {
|
|
94
|
+
if (shell.state.autoplay.active) {
|
|
95
|
+
shell.state.autoplay = { active: false, remaining: 0 };
|
|
96
|
+
shell.emit('autoplayStop'); shell.render();
|
|
97
|
+
} else {
|
|
98
|
+
shell.openAutoplayPicker();
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
cycleTurbo: () => {
|
|
102
|
+
const next = nextTurbo(shell.state.turbo, shell.config.features.turbo);
|
|
103
|
+
shell.state.turbo = next; shell.emit('turboChange', next); shell.render();
|
|
104
|
+
},
|
|
105
|
+
openBuyBonus: () => shell.openBuyBonus(),
|
|
106
|
+
openInfo: () => shell.openInfo(),
|
|
107
|
+
openMenu: () => shell.openMenu(),
|
|
108
|
+
toggleMute: () => shell.setSound(!shell.soundOn),
|
|
109
|
+
closeLayer: () => shell.closeModal(),
|
|
110
|
+
};
|
|
111
|
+
this.kbd = new KeyboardController(host);
|
|
112
|
+
this.kbd.attach();
|
|
67
113
|
// Stake serves the game in an iframe; on first paint focus is on the HOST page, so a `document`
|
|
68
114
|
// keydown never fires and Space scrolls the parent. Pull window focus into the iframe on the
|
|
69
115
|
// first pointer interaction so the spacebar shortcut works. Harmless on full-page Energy8.
|
|
70
116
|
document.addEventListener('pointerdown', this.pullFocus, true);
|
|
71
|
-
this.keysBound = true;
|
|
72
117
|
}
|
|
73
118
|
this.render();
|
|
74
119
|
// re-fit once the bundled webfont swaps in (text metrics change → row width changes)
|
|
@@ -161,41 +206,32 @@ export class GameShell extends EventEmitter<ShellEvents> {
|
|
|
161
206
|
}
|
|
162
207
|
}
|
|
163
208
|
|
|
164
|
-
/** Spacebar starts a spin — same path as the spin disc. Ignored when `features.spacebar` is
|
|
165
|
-
* false, while a spin is running, while autoplay is active, outside base mode, when an
|
|
166
|
-
* overlay/modal is open, or when an editable element is focused. `repeat` (held key) is
|
|
167
|
-
* ignored so it can't spam. */
|
|
168
209
|
/** Pull window focus into the iframe on first pointer interaction so `document` keydown (the
|
|
169
210
|
* spacebar shortcut) fires. No-op / harmless when already focused or full-page. */
|
|
170
211
|
private pullFocus = (): void => { try { window.focus(); } catch { /* cross-origin / non-browser */ } };
|
|
171
212
|
|
|
172
|
-
private handleKeyDown = (e: KeyboardEvent): void => {
|
|
173
|
-
if (this.destroyed || e.code !== 'Space' || e.repeat) return;
|
|
174
|
-
if (this.config.features.spacebar === false) return; // shortcut disabled (e.g. jurisdiction)
|
|
175
|
-
const t = e.target as HTMLElement | null;
|
|
176
|
-
if (t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName))) return;
|
|
177
|
-
// Space is ours now — swallow the browser default before any no-op bail. Otherwise the
|
|
178
|
-
// native "Space activates the focused button" still fires and re-clicks whichever shell
|
|
179
|
-
// <button> (menu/buy/auto) opened the overlay, tearing down + rebuilding the modal: a
|
|
180
|
-
// visible flicker. (Also stops the page from scrolling on Space.)
|
|
181
|
-
e.preventDefault();
|
|
182
|
-
if (this.modalHost.childElementCount > 0) return; // an overlay/modal is open
|
|
183
|
-
if (this.state.mode !== 'base' || this.state.busy || this.state.autoplay.active) return;
|
|
184
|
-
this.emit('spin');
|
|
185
|
-
};
|
|
186
|
-
|
|
187
213
|
setLayout(layout: 'wide' | 'mobile'): void {
|
|
188
214
|
if (layout === this.layout) return;
|
|
189
215
|
this.layout = layout;
|
|
190
216
|
this.render();
|
|
191
217
|
}
|
|
192
218
|
|
|
193
|
-
/** Resolve a built-in shell string
|
|
194
|
-
|
|
195
|
-
t(text: string): string { return this.config.isSocial ? socialize(text) : text; }
|
|
219
|
+
/** Resolve a built-in shell string through the i18n resolver (translation + optional socialize). */
|
|
220
|
+
t(text: string): string { return this.i18n.t(text); }
|
|
196
221
|
|
|
197
|
-
/** Toggle the social vocabulary at runtime (re-renders
|
|
198
|
-
setSocial(isSocial: boolean): void {
|
|
222
|
+
/** Toggle the social vocabulary at runtime (rebuilds resolver, re-renders bar). */
|
|
223
|
+
setSocial(isSocial: boolean): void {
|
|
224
|
+
this.config.isSocial = isSocial;
|
|
225
|
+
this.i18n = createI18n({ language: this.config.language, isSocial });
|
|
226
|
+
this.render();
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Swap the active language at runtime (rebuilds resolver, re-renders bar). */
|
|
230
|
+
setLanguage(lang: string): void {
|
|
231
|
+
this.config.language = lang;
|
|
232
|
+
this.i18n = createI18n({ language: lang, isSocial: this.config.isSocial });
|
|
233
|
+
this.render();
|
|
234
|
+
}
|
|
199
235
|
|
|
200
236
|
/** Recolour the shell at runtime (e.g. switch dark/light scheme). */
|
|
201
237
|
setTheme(theme: ThemeConfig): void {
|
|
@@ -236,7 +272,7 @@ export class GameShell extends EventEmitter<ShellEvents> {
|
|
|
236
272
|
this.state.mode = mode;
|
|
237
273
|
this.render();
|
|
238
274
|
}
|
|
239
|
-
setBusy(busy: boolean): void { this.state.busy = busy; this.render(); }
|
|
275
|
+
setBusy(busy: boolean): void { this.state.busy = busy; this.render(); this.kbd?.notifyBusyChanged(busy); }
|
|
240
276
|
setAutoplay(a: AutoplayOptions): void { this.state.autoplay = a; this.render(); }
|
|
241
277
|
setTurbo(level: number): void { this.state.turbo = level; this.render(); }
|
|
242
278
|
/** Currency-aware money formatter for WIN amounts (variable decimals: 0.0041 stays 0.0041, not
|
|
@@ -245,7 +281,7 @@ export class GameShell extends EventEmitter<ShellEvents> {
|
|
|
245
281
|
setBuyBonusEnabled(enabled: boolean): void { this.state.buyBonusEnabled = enabled; this.render(); }
|
|
246
282
|
setFreeSpins(fs: FreeSpinsState): void { this.state.freeSpins = fs; this.render(); }
|
|
247
283
|
|
|
248
|
-
private showModal(el: HTMLElement): void {
|
|
284
|
+
private showModal(el: HTMLElement, onKey?: (e: KeyboardEvent) => boolean): void {
|
|
249
285
|
// The control that opened this overlay (menu/buy/auto) keeps DOM focus. Drop it, or a
|
|
250
286
|
// stray Space/Enter would natively re-activate that <button> and rebuild the modal — a
|
|
251
287
|
// visible flicker. Only relinquish focus we own (a shell control), never the host page's.
|
|
@@ -253,6 +289,7 @@ export class GameShell extends EventEmitter<ShellEvents> {
|
|
|
253
289
|
if (active && this.root.contains(active)) active.blur();
|
|
254
290
|
this.modalHost.innerHTML = '';
|
|
255
291
|
this.modalHost.appendChild(el);
|
|
292
|
+
this.modalOnKey = onKey;
|
|
256
293
|
this.fitModals();
|
|
257
294
|
}
|
|
258
295
|
|
|
@@ -307,18 +344,28 @@ export class GameShell extends EventEmitter<ShellEvents> {
|
|
|
307
344
|
|
|
308
345
|
openMenu(): void { this.emit('menuOpen'); this.openSettings(); }
|
|
309
346
|
openSettings(): void { this.emit('settingsOpen'); this.showModal(openSettingsModal(this)); }
|
|
310
|
-
openInfo(): void { this.emit('infoOpen'); this.showModal(
|
|
347
|
+
openInfo(): void { this.emit('infoOpen'); const { root, onKey } = openGameInfoModal(this); this.showModal(root, onKey); }
|
|
311
348
|
openBuyBonus(): void {
|
|
312
349
|
if (this.config.onBonusBuy) { this.config.onBonusBuy(); return; } // game handles it (own UI)
|
|
313
|
-
const
|
|
314
|
-
if (
|
|
350
|
+
const result = openBuyBonusOverlay(this);
|
|
351
|
+
if (result) this.showModal(result.root, result.onKey);
|
|
315
352
|
}
|
|
316
353
|
/** Open a generic, externally-driven modal (title + body + optional action buttons).
|
|
317
354
|
* Each action runs its `on` then closes; the ✕ shows when `availableClose` is true. */
|
|
318
|
-
openModal(opts: ModalOptions): void { this.showModal(buildModal(opts)); }
|
|
355
|
+
openModal(opts: ModalOptions): void { this.showModal(buildModal(opts), opts.onKey); }
|
|
319
356
|
/** Programmatically dismiss whatever modal/overlay is currently shown (e.g. auto-close the
|
|
320
357
|
* reconnect overlay once the link is restored). No-op when nothing is open. */
|
|
321
|
-
closeModal(): void { this.modalHost.innerHTML = ''; }
|
|
358
|
+
closeModal(): void { this.modalOnKey = undefined; this.soundRefresh = null; this.modalHost.innerHTML = ''; }
|
|
359
|
+
|
|
360
|
+
/** Flip the shared sound state, notify the game (`settingChange({ key: 'sound' })`), and live-update
|
|
361
|
+
* the Settings speaker icon if that modal is open. Used by both the Settings toggle and Shift+M. */
|
|
362
|
+
setSound(on: boolean): void {
|
|
363
|
+
this.soundOn = on;
|
|
364
|
+
this.emit('settingChange', { key: 'sound', value: on });
|
|
365
|
+
this.soundRefresh?.(on);
|
|
366
|
+
}
|
|
367
|
+
/** The Settings modal registers an icon-updater while open (cleared on close). */
|
|
368
|
+
setSoundRefresh(fn: ((on: boolean) => void) | null): void { this.soundRefresh = fn; }
|
|
322
369
|
/** Open the non-dismissable replay summary modal (START REPLAY → onReplay → reopen). */
|
|
323
370
|
openReplay(opts: ReplayModalOptions): void {
|
|
324
371
|
if (this.destroyed) return;
|
|
@@ -326,19 +373,18 @@ export class GameShell extends EventEmitter<ShellEvents> {
|
|
|
326
373
|
}
|
|
327
374
|
|
|
328
375
|
/** Bet picker — list of available bets with an accent Confirm. */
|
|
329
|
-
openBetPicker(): void { this.showModal(
|
|
376
|
+
openBetPicker(): void { const { root, onKey } = openBetModal(this); this.showModal(root, onKey); }
|
|
330
377
|
/** Autoplay picker — spin-count list; Confirm starts autoplay. */
|
|
331
|
-
openAutoplayPicker(): void { this.showModal(
|
|
378
|
+
openAutoplayPicker(): void { const { root, onKey } = openAutoplayModal(this); this.showModal(root, onKey); }
|
|
332
379
|
|
|
333
380
|
destroy(): Promise<void> {
|
|
334
381
|
if (this.destroyed) return Promise.resolve();
|
|
335
382
|
this.destroyed = true;
|
|
336
383
|
this.ro?.disconnect();
|
|
337
384
|
this.ro = null;
|
|
338
|
-
if (
|
|
339
|
-
|
|
385
|
+
if (typeof document !== 'undefined') {
|
|
386
|
+
this.kbd?.detach();
|
|
340
387
|
document.removeEventListener('pointerdown', this.pullFocus, true);
|
|
341
|
-
this.keysBound = false;
|
|
342
388
|
}
|
|
343
389
|
this.cancelMoneyAnims();
|
|
344
390
|
this.removeAllListeners();
|
|
@@ -2,27 +2,172 @@ import type { GameShell } from '../GameShell';
|
|
|
2
2
|
import type { BonusOption } from '../types';
|
|
3
3
|
import { formatCurrency } from '../format';
|
|
4
4
|
import { stepBet } from '../state';
|
|
5
|
+
import { betDir } from '../keyboard';
|
|
5
6
|
import { effectiveAccent, contrastText } from '../colors';
|
|
6
7
|
import { createOverlay, createCardModal } from './primitives';
|
|
7
8
|
import { icon, type IconName } from './icons';
|
|
8
9
|
|
|
9
|
-
/**
|
|
10
|
-
|
|
10
|
+
/** Mutable state shared between the overlay DOM and the onKey handler. */
|
|
11
|
+
interface OverlayState {
|
|
12
|
+
/** Index into the affordable-card subset; -1 = none (no affordable cards). */
|
|
13
|
+
focusIndex: number;
|
|
14
|
+
/** The bonus whose confirm dialog is currently open, or undefined. */
|
|
15
|
+
confirmBonus: BonusOption | undefined;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Buy-bonus overlay — a grid of art-forward cards, one per option.
|
|
19
|
+
* Returns the overlay element + a keyboard handler for the shell's `showModal`. */
|
|
20
|
+
export function openBuyBonusOverlay(shell: GameShell): { root: HTMLElement; onKey: (e: KeyboardEvent) => boolean } | null {
|
|
11
21
|
const bonuses = shell.config.features.buyBonus;
|
|
12
22
|
if (bonuses === false || bonuses.length === 0) return null;
|
|
13
23
|
|
|
14
|
-
const
|
|
24
|
+
const st: OverlayState = { focusIndex: -1, confirmBonus: undefined };
|
|
25
|
+
|
|
26
|
+
const { root, body } = createOverlay({ title: shell.t('Buy bonus'), onClose: () => shell.closeModal() });
|
|
15
27
|
root.dataset.ge = 'buybonus-overlay';
|
|
28
|
+
|
|
16
29
|
// Re-render the grid whenever the bet changes so every card's price stays live.
|
|
17
|
-
const renderGrid = () => {
|
|
30
|
+
const renderGrid = (): void => {
|
|
18
31
|
body.innerHTML = '';
|
|
19
32
|
const grid = document.createElement('div'); grid.className = 'ge-bb-grid';
|
|
20
|
-
|
|
33
|
+
const affordable: BonusOption[] = [];
|
|
34
|
+
for (const bonus of bonuses) {
|
|
35
|
+
const card = buildCard(shell, bonus, root, st);
|
|
36
|
+
grid.appendChild(card);
|
|
37
|
+
if (isAffordable(shell, bonus)) affordable.push(bonus);
|
|
38
|
+
}
|
|
21
39
|
body.appendChild(grid);
|
|
40
|
+
// Initialize or restore focus index
|
|
41
|
+
if (affordable.length > 0) {
|
|
42
|
+
if (st.focusIndex < 0) st.focusIndex = 0;
|
|
43
|
+
else st.focusIndex = Math.min(st.focusIndex, affordable.length - 1);
|
|
44
|
+
applyFocusClass(root, bonuses, affordable, st.focusIndex);
|
|
45
|
+
} else {
|
|
46
|
+
st.focusIndex = -1;
|
|
47
|
+
}
|
|
22
48
|
};
|
|
49
|
+
|
|
23
50
|
renderGrid();
|
|
24
51
|
root.appendChild(buildBetBar(shell, renderGrid)); // thin bottom footer, only as tall as the pill
|
|
25
|
-
|
|
52
|
+
|
|
53
|
+
/** Step the bet by `dir` and re-render the grid (live prices + affordability) when it changed.
|
|
54
|
+
* Shared by the keyboard bet keys (the footer ± buttons keep their own copy). */
|
|
55
|
+
const stepBetBy = (dir: 1 | -1): void => {
|
|
56
|
+
const next = stepBet(shell.state, dir);
|
|
57
|
+
if (next === shell.state.bet) return;
|
|
58
|
+
shell.state.bet = next; shell.emit('betChange', next); shell.render();
|
|
59
|
+
renderGrid();
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** Keyboard handler for both browse and confirm phases. */
|
|
63
|
+
const onKey = (e: KeyboardEvent): boolean => {
|
|
64
|
+
const affordable = bonuses.filter((b) => isAffordable(shell, b));
|
|
65
|
+
|
|
66
|
+
// ── Confirm phase ──
|
|
67
|
+
if (st.confirmBonus) {
|
|
68
|
+
switch (e.code) {
|
|
69
|
+
case 'Enter':
|
|
70
|
+
case 'Space': {
|
|
71
|
+
const bonus = st.confirmBonus;
|
|
72
|
+
if (!isAffordable(shell, bonus)) return true;
|
|
73
|
+
if (bonus.type === 'feature') shell.activateFeature(bonus);
|
|
74
|
+
else shell.emit('buyBonusSelect', { id: bonus.id });
|
|
75
|
+
shell.closeModal();
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
case 'Escape':
|
|
79
|
+
// Remove the confirm dialog, return to browse
|
|
80
|
+
closeConfirm(root, st);
|
|
81
|
+
return true;
|
|
82
|
+
default:
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── Browse phase ──
|
|
88
|
+
const last = affordable.length - 1;
|
|
89
|
+
const mobile = shell.layout === 'mobile';
|
|
90
|
+
|
|
91
|
+
// Bet stepping mirrors the bar's keys (Shift+↑/↓, Shift+=/-, Numpad ±). Checked BEFORE arrow
|
|
92
|
+
// navigation so a bare arrow still moves card focus while a Shift+arrow changes the bet.
|
|
93
|
+
const bet = betDir(e);
|
|
94
|
+
if (bet !== null) { stepBetBy(bet); return true; }
|
|
95
|
+
|
|
96
|
+
// Determine navigation direction from key code + layout (mobile uses vertical arrows)
|
|
97
|
+
const fwdKey = e.code === 'ArrowRight' || (mobile && e.code === 'ArrowDown');
|
|
98
|
+
const bwdKey = e.code === 'ArrowLeft' || (mobile && e.code === 'ArrowUp');
|
|
99
|
+
|
|
100
|
+
if (fwdKey) {
|
|
101
|
+
if (last < 0) return true;
|
|
102
|
+
if (st.focusIndex < last) {
|
|
103
|
+
st.focusIndex++;
|
|
104
|
+
applyFocusClass(root, bonuses, affordable, st.focusIndex);
|
|
105
|
+
}
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
if (bwdKey) {
|
|
109
|
+
if (last < 0) return true;
|
|
110
|
+
if (st.focusIndex > 0) {
|
|
111
|
+
st.focusIndex--;
|
|
112
|
+
applyFocusClass(root, bonuses, affordable, st.focusIndex);
|
|
113
|
+
}
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
switch (e.code) {
|
|
118
|
+
case 'Enter':
|
|
119
|
+
case 'Space':
|
|
120
|
+
if (last < 0 || st.focusIndex < 0) return true;
|
|
121
|
+
{
|
|
122
|
+
const bonus = affordable[st.focusIndex];
|
|
123
|
+
openConfirm(shell, bonus, root, st);
|
|
124
|
+
}
|
|
125
|
+
return true;
|
|
126
|
+
// Bare =/- also step the bet (the Shift+=/- and Numpad variants are handled by betDir above).
|
|
127
|
+
case 'Equal':
|
|
128
|
+
stepBetBy(1);
|
|
129
|
+
return true;
|
|
130
|
+
case 'Minus':
|
|
131
|
+
stepBetBy(-1);
|
|
132
|
+
return true;
|
|
133
|
+
case 'Escape':
|
|
134
|
+
shell.closeModal();
|
|
135
|
+
return true;
|
|
136
|
+
default:
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
return { root, onKey };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Apply a CSS keyboard-focus class to the currently focused affordable card. */
|
|
145
|
+
function applyFocusClass(overlay: HTMLElement, bonuses: BonusOption[], affordable: BonusOption[], focusIndex: number): void {
|
|
146
|
+
for (const b of bonuses) {
|
|
147
|
+
const card = overlay.querySelector(`[data-ge="bonus-card-${b.id}"]`) as HTMLElement | null;
|
|
148
|
+
if (!card) continue;
|
|
149
|
+
card.classList.remove('ge-bonus-card--kbd-focus');
|
|
150
|
+
}
|
|
151
|
+
const focused = affordable[focusIndex];
|
|
152
|
+
if (!focused) return;
|
|
153
|
+
const card = overlay.querySelector(`[data-ge="bonus-card-${focused.id}"]`) as HTMLElement | null;
|
|
154
|
+
if (card) card.classList.add('ge-bonus-card--kbd-focus');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Open the confirm dialog for the given bonus and track it in overlay state. */
|
|
158
|
+
function openConfirm(shell: GameShell, bonus: BonusOption, overlay: HTMLElement, st: OverlayState): void {
|
|
159
|
+
closeConfirm(overlay, st); // remove any existing confirm
|
|
160
|
+
st.confirmBonus = bonus;
|
|
161
|
+
overlay.appendChild(buildConfirm(shell, bonus, overlay, st));
|
|
162
|
+
shell.fitModals();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Remove the confirm dialog and clear the overlay state. */
|
|
166
|
+
function closeConfirm(overlay: HTMLElement, st: OverlayState): void {
|
|
167
|
+
// The confirm dialog is a .ge-sheet with data-ge="bonus-confirm" appended directly to overlay.
|
|
168
|
+
const sheet = overlay.querySelector('[data-ge="bonus-confirm"]') as HTMLElement | null;
|
|
169
|
+
if (sheet) sheet.remove();
|
|
170
|
+
st.confirmBonus = undefined;
|
|
26
171
|
}
|
|
27
172
|
|
|
28
173
|
/** Bet control — a compact −/+ pill around the live stake, in a thin footer at the screen bottom.
|
|
@@ -63,7 +208,7 @@ function stepButton(ge: string, name: IconName): HTMLButtonElement {
|
|
|
63
208
|
|
|
64
209
|
/** A grid card: title → thumbnail → description → volatility → price → full-bleed CTA.
|
|
65
210
|
* Clicking (when affordable) opens the confirmation modal. */
|
|
66
|
-
function buildCard(shell: GameShell, bonus: BonusOption, overlay: HTMLElement): HTMLElement {
|
|
211
|
+
function buildCard(shell: GameShell, bonus: BonusOption, overlay: HTMLElement, st: OverlayState): HTMLElement {
|
|
67
212
|
const accent = effectiveAccent(bonus);
|
|
68
213
|
const card = document.createElement('div');
|
|
69
214
|
card.className = 'ge-bonus-card'; card.dataset.ge = `bonus-card-${bonus.id}`;
|
|
@@ -75,8 +220,7 @@ function buildCard(shell: GameShell, bonus: BonusOption, overlay: HTMLElement):
|
|
|
75
220
|
// affordability at click time, so it's a safe no-op when the option can't be bought.
|
|
76
221
|
const select = (): void => {
|
|
77
222
|
if (!isAffordable(shell, bonus)) return;
|
|
78
|
-
|
|
79
|
-
shell.fitModals();
|
|
223
|
+
openConfirm(shell, bonus, overlay, st);
|
|
80
224
|
};
|
|
81
225
|
|
|
82
226
|
// Game-supplied card UI: the shell keeps the wrapper (grid sizing + accent vars) and runs the
|
|
@@ -123,9 +267,9 @@ function cardBody(shell: GameShell, bonus: BonusOption): HTMLElement {
|
|
|
123
267
|
|
|
124
268
|
/** Confirmation modal — the shared card chrome (accent title heading, no ✕) with a bonus
|
|
125
269
|
* preview body and a full-bleed Cancel + action footer. */
|
|
126
|
-
function buildConfirm(shell: GameShell, bonus: BonusOption, overlay: HTMLElement): HTMLElement {
|
|
270
|
+
function buildConfirm(shell: GameShell, bonus: BonusOption, overlay: HTMLElement, st: OverlayState): HTMLElement {
|
|
127
271
|
const accent = effectiveAccent(bonus);
|
|
128
|
-
const ui = createCardModal({ ge: 'bonus-confirm', title: bonus.title, accent, onClose: () =>
|
|
272
|
+
const ui = createCardModal({ ge: 'bonus-confirm', title: bonus.title, accent, onClose: () => { closeConfirm(overlay, st); } });
|
|
129
273
|
|
|
130
274
|
const price = bonus.priceMultiplier * shell.state.bet;
|
|
131
275
|
const preview = document.createElement('div'); preview.className = 'ge-confirm-preview';
|
|
@@ -140,7 +284,7 @@ function buildConfirm(shell: GameShell, bonus: BonusOption, overlay: HTMLElement
|
|
|
140
284
|
const cancel = document.createElement('button');
|
|
141
285
|
cancel.className = 'ge-modal-btn ge-modal-btn--ghost'; cancel.dataset.ge = 'bonus-confirm-cancel';
|
|
142
286
|
cancel.textContent = shell.t('Cancel');
|
|
143
|
-
cancel.addEventListener('click', () =>
|
|
287
|
+
cancel.addEventListener('click', () => closeConfirm(overlay, st));
|
|
144
288
|
const buy = document.createElement('button');
|
|
145
289
|
buy.className = 'ge-modal-btn ge-modal-btn--accent'; buy.dataset.ge = 'bonus-confirm-buy';
|
|
146
290
|
buy.textContent = shell.t(actionLabel(bonus));
|
|
@@ -151,8 +295,7 @@ function buildConfirm(shell: GameShell, bonus: BonusOption, overlay: HTMLElement
|
|
|
151
295
|
if (!isAffordable(shell, bonus)) return;
|
|
152
296
|
if (bonus.type === 'feature') shell.activateFeature(bonus);
|
|
153
297
|
else shell.emit('buyBonusSelect', { id: bonus.id });
|
|
154
|
-
|
|
155
|
-
overlay.remove();
|
|
298
|
+
shell.closeModal();
|
|
156
299
|
});
|
|
157
300
|
actions.append(cancel, buy);
|
|
158
301
|
ui.card.appendChild(actions);
|
|
@@ -4,17 +4,32 @@ import { createOverlay, twoLine } from './primitives';
|
|
|
4
4
|
import { icon } from './icons';
|
|
5
5
|
import { PACKAGE_VERSION } from '../version';
|
|
6
6
|
|
|
7
|
+
/** Default order key for the auto-injected hotkeys section: just after `controls` (-1). */
|
|
8
|
+
const HOTKEYS_DEFAULT_ORDER = -0.5;
|
|
9
|
+
|
|
7
10
|
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
8
11
|
|
|
9
|
-
|
|
10
|
-
|
|
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({
|
|
11
20
|
title: shell.t('Game info'),
|
|
12
21
|
onClose: () => root.remove(),
|
|
13
22
|
onBack: () => { root.remove(); shell.openSettings(); },
|
|
14
23
|
});
|
|
15
24
|
root.dataset.ge = 'info-modal';
|
|
16
25
|
|
|
17
|
-
const
|
|
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;
|
|
18
33
|
// Default placement: modes first, controls second, the rest in declaration order.
|
|
19
34
|
// An explicit `order` overrides; ties keep declaration order (stable).
|
|
20
35
|
const base = (s: GameInfoSection, i: number): number =>
|
|
@@ -25,7 +40,26 @@ export function openGameInfoModal(shell: GameShell): HTMLElement {
|
|
|
25
40
|
.forEach(({ s }) => body.appendChild(renderSection(shell, s)));
|
|
26
41
|
|
|
27
42
|
body.appendChild(versionFooter(shell));
|
|
28
|
-
|
|
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 };
|
|
29
63
|
}
|
|
30
64
|
|
|
31
65
|
/** A muted version stamp pinned to the bottom of the game-info modal:
|
|
@@ -43,9 +77,11 @@ function renderSection(shell: GameShell, s: GameInfoSection): HTMLElement {
|
|
|
43
77
|
switch (s.type) {
|
|
44
78
|
case 'modes': return sectionModes(shell, s.modes, sec('info-modes', s.title, shell.t('Modes')));
|
|
45
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')));
|
|
46
81
|
case 'paytable': return sectionPaytable(s.rows, sec('info-paytable', s.title, shell.t('Paytable')));
|
|
47
82
|
case 'wins': return sectionWins(s, sec('info-wins', s.title, shell.t(winFallbackTitle(s.kind))));
|
|
48
|
-
|
|
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, ''));
|
|
49
85
|
}
|
|
50
86
|
}
|
|
51
87
|
|
|
@@ -123,6 +159,69 @@ function ctlBlock(shell: GameShell, label: string, rows: CtlRow[]): HTMLElement
|
|
|
123
159
|
return block;
|
|
124
160
|
}
|
|
125
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
|
+
|
|
126
225
|
// ── paytable (cards — image on top, name, then win tiers "<count> x<mult>") ────
|
|
127
226
|
function sectionPaytable(rows: PaytableRow[], el: HTMLElement): HTMLElement {
|
|
128
227
|
const grid = document.createElement('div'); grid.className = 'ge-gi-pt-grid';
|
|
@@ -6,22 +6,21 @@ export function openSettingsModal(shell: GameShell): HTMLElement {
|
|
|
6
6
|
const { root, body } = createOverlay({ title: shell.t('Settings'), onClose: () => root.remove() });
|
|
7
7
|
root.dataset.ge = 'settings-modal';
|
|
8
8
|
|
|
9
|
-
// Sound on/off
|
|
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.
|
|
10
11
|
const sound = (() => {
|
|
11
|
-
let on = true;
|
|
12
12
|
const btn = document.createElement('button');
|
|
13
|
-
btn.className = 'ge-snd
|
|
14
|
-
btn.setAttribute('aria-label', 'Sound');
|
|
15
|
-
const paint = () => {
|
|
13
|
+
btn.className = 'ge-snd'; btn.dataset.ge = 'setting-sound';
|
|
14
|
+
btn.setAttribute('aria-label', shell.t('Sound'));
|
|
15
|
+
const paint = (on: boolean) => {
|
|
16
16
|
btn.innerHTML = icon(on ? 'soundOn' : 'soundOff');
|
|
17
17
|
btn.classList.toggle('ge-active', on);
|
|
18
18
|
btn.setAttribute('aria-pressed', String(on));
|
|
19
19
|
};
|
|
20
|
-
paint();
|
|
21
|
-
btn.addEventListener('click', () =>
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
});
|
|
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);
|
|
25
24
|
const row = document.createElement('div'); row.className = 'ge-ov-row';
|
|
26
25
|
row.innerHTML = `<span class="ge-grow">${shell.t('Sound')}</span>`; row.appendChild(btn);
|
|
27
26
|
return row;
|