@energy8platform/platform-core 0.25.3 → 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 +1785 -212
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +83 -12
- package/dist/index.esm.js +1785 -212
- 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 +1552 -122
- package/dist/shell.cjs.js.map +1 -1
- package/dist/shell.d.ts +49 -14
- package/dist/shell.esm.js +1551 -123
- 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 +118 -74
- 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 +20 -3
- 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)
|
|
@@ -113,10 +158,11 @@ export class GameShell extends EventEmitter<ShellEvents> {
|
|
|
113
158
|
host.classList.remove('ge-fit');
|
|
114
159
|
host.style.transform = '';
|
|
115
160
|
host.style.transformOrigin = '';
|
|
116
|
-
// clear any per-zone
|
|
161
|
+
// clear any per-zone scale/zoom from a prior pass
|
|
117
162
|
for (const el of host.querySelectorAll('.ge-zone, .ge-winpill')) {
|
|
118
163
|
(el as HTMLElement).style.transform = '';
|
|
119
164
|
(el as HTMLElement).style.transformOrigin = '';
|
|
165
|
+
(el as HTMLElement).style.removeProperty('zoom');
|
|
120
166
|
}
|
|
121
167
|
if (this.layout === 'mobile') {
|
|
122
168
|
// Shrink the whole stack to fit narrow phones (mobile-s, or big balance/win/total-win
|
|
@@ -133,74 +179,59 @@ export class GameShell extends EventEmitter<ShellEvents> {
|
|
|
133
179
|
}
|
|
134
180
|
return;
|
|
135
181
|
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
//
|
|
139
|
-
//
|
|
182
|
+
// ONE fit-scale, from the SCREEN SIZE, applied identically in EVERY mode — switching base⇄replay
|
|
183
|
+
// must not resize the bar. The factor is the frame WIDTH vs the bar's design width, never the
|
|
184
|
+
// current mode's content width.
|
|
185
|
+
//
|
|
186
|
+
// It's applied with `zoom` (not `transform`): zoom shrinks the LAYOUT, so the zones genuinely
|
|
187
|
+
// take less room and still sit edge-to-edge (menu hard-left, controls hard-right) even when base's
|
|
188
|
+
// wide row would overflow a merely-visually-scaled bar — so there is no per-mode centred cluster
|
|
189
|
+
// and no width/mode branching. A wide WIN pill is still lifted above the row first so it can't
|
|
190
|
+
// shove the controls off-screen. (Mobile, above, keeps its own stacked fit.)
|
|
140
191
|
if (pill && bar.scrollWidth > bar.clientWidth + 1) { host.insertBefore(pill, bar); pill.classList.add('ge-up'); }
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
const naturalH = host.offsetHeight;
|
|
156
|
-
if (naturalH > availH && naturalH > 0) {
|
|
157
|
-
const s = (availH / naturalH).toFixed(4);
|
|
158
|
-
const scaleEdge = (el: Element | null, origin: string): void => {
|
|
159
|
-
if (!el) return;
|
|
160
|
-
(el as HTMLElement).style.transformOrigin = origin;
|
|
161
|
-
(el as HTMLElement).style.transform = `scale(${s})`;
|
|
162
|
-
};
|
|
163
|
-
scaleEdge(bar.querySelector('.ge-zone-left'), 'left bottom');
|
|
164
|
-
scaleEdge(bar.querySelector('.ge-zone-right'), 'right bottom');
|
|
165
|
-
scaleEdge(host.querySelector('.ge-winpill'), 'center bottom');
|
|
192
|
+
const zoomBar = (z: number): void => {
|
|
193
|
+
const v = z < 0.999 ? z.toFixed(4) : '';
|
|
194
|
+
for (const el of host.querySelectorAll('.ge-zone, .ge-winpill')) {
|
|
195
|
+
if (v) (el as HTMLElement).style.setProperty('zoom', v);
|
|
196
|
+
else (el as HTMLElement).style.removeProperty('zoom');
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
const s = Math.max(GameShell.BAR_MIN_SCALE, Math.min(1, this.root.clientWidth / GameShell.BAR_REF_WIDTH));
|
|
200
|
+
zoomBar(s);
|
|
201
|
+
// Safety: a pathologically long balance/win can still overflow the frame at the screen zoom —
|
|
202
|
+
// nudge the zoom down just enough that the far control (turbo) isn't clipped. Normal content
|
|
203
|
+
// never triggers this, so base and replay keep the SAME zoom (no size change on mode switch).
|
|
204
|
+
if (bar.scrollWidth > bar.clientWidth + 1 && bar.scrollWidth > 0) {
|
|
205
|
+
zoomBar(s * (bar.clientWidth / bar.scrollWidth));
|
|
166
206
|
}
|
|
167
207
|
}
|
|
168
208
|
|
|
169
|
-
/** Spacebar starts a spin — same path as the spin disc. Ignored when `features.spacebar` is
|
|
170
|
-
* false, while a spin is running, while autoplay is active, outside base mode, when an
|
|
171
|
-
* overlay/modal is open, or when an editable element is focused. `repeat` (held key) is
|
|
172
|
-
* ignored so it can't spam. */
|
|
173
209
|
/** Pull window focus into the iframe on first pointer interaction so `document` keydown (the
|
|
174
210
|
* spacebar shortcut) fires. No-op / harmless when already focused or full-page. */
|
|
175
211
|
private pullFocus = (): void => { try { window.focus(); } catch { /* cross-origin / non-browser */ } };
|
|
176
212
|
|
|
177
|
-
private handleKeyDown = (e: KeyboardEvent): void => {
|
|
178
|
-
if (this.destroyed || e.code !== 'Space' || e.repeat) return;
|
|
179
|
-
if (this.config.features.spacebar === false) return; // shortcut disabled (e.g. jurisdiction)
|
|
180
|
-
const t = e.target as HTMLElement | null;
|
|
181
|
-
if (t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName))) return;
|
|
182
|
-
// Space is ours now — swallow the browser default before any no-op bail. Otherwise the
|
|
183
|
-
// native "Space activates the focused button" still fires and re-clicks whichever shell
|
|
184
|
-
// <button> (menu/buy/auto) opened the overlay, tearing down + rebuilding the modal: a
|
|
185
|
-
// visible flicker. (Also stops the page from scrolling on Space.)
|
|
186
|
-
e.preventDefault();
|
|
187
|
-
if (this.modalHost.childElementCount > 0) return; // an overlay/modal is open
|
|
188
|
-
if (this.state.mode !== 'base' || this.state.busy || this.state.autoplay.active) return;
|
|
189
|
-
this.emit('spin');
|
|
190
|
-
};
|
|
191
|
-
|
|
192
213
|
setLayout(layout: 'wide' | 'mobile'): void {
|
|
193
214
|
if (layout === this.layout) return;
|
|
194
215
|
this.layout = layout;
|
|
195
216
|
this.render();
|
|
196
217
|
}
|
|
197
218
|
|
|
198
|
-
/** Resolve a built-in shell string
|
|
199
|
-
|
|
200
|
-
|
|
219
|
+
/** Resolve a built-in shell string through the i18n resolver (translation + optional socialize). */
|
|
220
|
+
t(text: string): string { return this.i18n.t(text); }
|
|
221
|
+
|
|
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
|
+
}
|
|
201
228
|
|
|
202
|
-
/**
|
|
203
|
-
|
|
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
|
+
}
|
|
204
235
|
|
|
205
236
|
/** Recolour the shell at runtime (e.g. switch dark/light scheme). */
|
|
206
237
|
setTheme(theme: ThemeConfig): void {
|
|
@@ -241,7 +272,7 @@ export class GameShell extends EventEmitter<ShellEvents> {
|
|
|
241
272
|
this.state.mode = mode;
|
|
242
273
|
this.render();
|
|
243
274
|
}
|
|
244
|
-
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); }
|
|
245
276
|
setAutoplay(a: AutoplayOptions): void { this.state.autoplay = a; this.render(); }
|
|
246
277
|
setTurbo(level: number): void { this.state.turbo = level; this.render(); }
|
|
247
278
|
/** Currency-aware money formatter for WIN amounts (variable decimals: 0.0041 stays 0.0041, not
|
|
@@ -250,7 +281,7 @@ export class GameShell extends EventEmitter<ShellEvents> {
|
|
|
250
281
|
setBuyBonusEnabled(enabled: boolean): void { this.state.buyBonusEnabled = enabled; this.render(); }
|
|
251
282
|
setFreeSpins(fs: FreeSpinsState): void { this.state.freeSpins = fs; this.render(); }
|
|
252
283
|
|
|
253
|
-
private showModal(el: HTMLElement): void {
|
|
284
|
+
private showModal(el: HTMLElement, onKey?: (e: KeyboardEvent) => boolean): void {
|
|
254
285
|
// The control that opened this overlay (menu/buy/auto) keeps DOM focus. Drop it, or a
|
|
255
286
|
// stray Space/Enter would natively re-activate that <button> and rebuild the modal — a
|
|
256
287
|
// visible flicker. Only relinquish focus we own (a shell control), never the host page's.
|
|
@@ -258,6 +289,7 @@ export class GameShell extends EventEmitter<ShellEvents> {
|
|
|
258
289
|
if (active && this.root.contains(active)) active.blur();
|
|
259
290
|
this.modalHost.innerHTML = '';
|
|
260
291
|
this.modalHost.appendChild(el);
|
|
292
|
+
this.modalOnKey = onKey;
|
|
261
293
|
this.fitModals();
|
|
262
294
|
}
|
|
263
295
|
|
|
@@ -274,9 +306,12 @@ export class GameShell extends EventEmitter<ShellEvents> {
|
|
|
274
306
|
* modals from filling a small popout edge-to-edge (so even short pickers scale down there). */
|
|
275
307
|
private static readonly MODAL_FIT = 0.86;
|
|
276
308
|
|
|
277
|
-
/**
|
|
278
|
-
*
|
|
279
|
-
|
|
309
|
+
/** The bar's design width (px). When the frame is narrower, the bar fit-scales DOWN with the
|
|
310
|
+
* screen — the SAME factor in every mode, so replay/free-spins shrink like base instead of
|
|
311
|
+
* staying full-size on a popout. */
|
|
312
|
+
private static readonly BAR_REF_WIDTH = 840;
|
|
313
|
+
/** Lower bound on the bar fit-scale (guards a degenerate near-zero frame). */
|
|
314
|
+
private static readonly BAR_MIN_SCALE = 0.5;
|
|
280
315
|
|
|
281
316
|
private fitSheet(root: HTMLElement): void {
|
|
282
317
|
const card = root.querySelector('.ge-modal-card') as HTMLElement | null;
|
|
@@ -309,18 +344,28 @@ export class GameShell extends EventEmitter<ShellEvents> {
|
|
|
309
344
|
|
|
310
345
|
openMenu(): void { this.emit('menuOpen'); this.openSettings(); }
|
|
311
346
|
openSettings(): void { this.emit('settingsOpen'); this.showModal(openSettingsModal(this)); }
|
|
312
|
-
openInfo(): void { this.emit('infoOpen'); this.showModal(
|
|
347
|
+
openInfo(): void { this.emit('infoOpen'); const { root, onKey } = openGameInfoModal(this); this.showModal(root, onKey); }
|
|
313
348
|
openBuyBonus(): void {
|
|
314
349
|
if (this.config.onBonusBuy) { this.config.onBonusBuy(); return; } // game handles it (own UI)
|
|
315
|
-
const
|
|
316
|
-
if (
|
|
350
|
+
const result = openBuyBonusOverlay(this);
|
|
351
|
+
if (result) this.showModal(result.root, result.onKey);
|
|
317
352
|
}
|
|
318
353
|
/** Open a generic, externally-driven modal (title + body + optional action buttons).
|
|
319
354
|
* Each action runs its `on` then closes; the ✕ shows when `availableClose` is true. */
|
|
320
|
-
openModal(opts: ModalOptions): void { this.showModal(buildModal(opts)); }
|
|
355
|
+
openModal(opts: ModalOptions): void { this.showModal(buildModal(opts), opts.onKey); }
|
|
321
356
|
/** Programmatically dismiss whatever modal/overlay is currently shown (e.g. auto-close the
|
|
322
357
|
* reconnect overlay once the link is restored). No-op when nothing is open. */
|
|
323
|
-
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; }
|
|
324
369
|
/** Open the non-dismissable replay summary modal (START REPLAY → onReplay → reopen). */
|
|
325
370
|
openReplay(opts: ReplayModalOptions): void {
|
|
326
371
|
if (this.destroyed) return;
|
|
@@ -328,19 +373,18 @@ export class GameShell extends EventEmitter<ShellEvents> {
|
|
|
328
373
|
}
|
|
329
374
|
|
|
330
375
|
/** Bet picker — list of available bets with an accent Confirm. */
|
|
331
|
-
openBetPicker(): void { this.showModal(
|
|
376
|
+
openBetPicker(): void { const { root, onKey } = openBetModal(this); this.showModal(root, onKey); }
|
|
332
377
|
/** Autoplay picker — spin-count list; Confirm starts autoplay. */
|
|
333
|
-
openAutoplayPicker(): void { this.showModal(
|
|
378
|
+
openAutoplayPicker(): void { const { root, onKey } = openAutoplayModal(this); this.showModal(root, onKey); }
|
|
334
379
|
|
|
335
380
|
destroy(): Promise<void> {
|
|
336
381
|
if (this.destroyed) return Promise.resolve();
|
|
337
382
|
this.destroyed = true;
|
|
338
383
|
this.ro?.disconnect();
|
|
339
384
|
this.ro = null;
|
|
340
|
-
if (
|
|
341
|
-
|
|
385
|
+
if (typeof document !== 'undefined') {
|
|
386
|
+
this.kbd?.detach();
|
|
342
387
|
document.removeEventListener('pointerdown', this.pullFocus, true);
|
|
343
|
-
this.keysBound = false;
|
|
344
388
|
}
|
|
345
389
|
this.cancelMoneyAnims();
|
|
346
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);
|