@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.
@@ -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
- export function openGameInfoModal(shell: GameShell): HTMLElement {
10
- const { root, body } = createOverlay({
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 sections = shell.config.gameInfo.sections ?? [];
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
- return root;
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
- case 'custom': return sectionCustom(s, sec('info-custom', s.title, ''));
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 (starts on) full-width row with a speaker icon button
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 ge-active'; btn.dataset.ge = 'setting-sound';
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
- on = !on; paint();
23
- shell.emit('settingChange', { key: 'sound', value: on });
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;
@@ -15,46 +15,101 @@ interface SheetOpts {
15
15
  columns: number | { wide: number; mobile: number };
16
16
  confirmLabel: string;
17
17
  onConfirm: (id: string) => void;
18
+ /** Called to dismiss the picker (should invoke shell.closeModal()). */
19
+ onClose: () => void;
20
+ }
21
+
22
+ /** Result of buildSheet — the root DOM element plus a keyboard handler. */
23
+ interface Sheet {
24
+ root: HTMLElement;
25
+ /** Route keydown events here. Returns true if the event was consumed. */
26
+ onKey: (e: KeyboardEvent) => boolean;
18
27
  }
19
28
 
20
29
  /** A centred picker (chips grid + accent Confirm) on the shared card modal. */
21
- function buildSheet(opts: SheetOpts): HTMLElement {
22
- const ui = createCardModal({ ge: opts.ge, title: opts.title, onClose: () => ui.root.remove() });
30
+ function buildSheet(opts: SheetOpts): Sheet {
31
+ const ui = createCardModal({ ge: opts.ge, title: opts.title, onClose: () => opts.onClose() });
23
32
 
24
33
  const grid = document.createElement('div'); grid.className = 'ge-sheet-grid';
25
34
  const cols = typeof opts.columns === 'number' ? { wide: opts.columns, mobile: opts.columns } : opts.columns;
26
35
  grid.style.setProperty('--cols', String(cols.wide));
27
36
  grid.style.setProperty('--cols-m', String(cols.mobile));
28
37
  let selected = opts.selected;
38
+ let focusIndex = opts.choices.findIndex((c) => c.id === selected);
39
+ if (focusIndex < 0) focusIndex = 0;
29
40
  const chips: HTMLButtonElement[] = [];
30
- for (const c of opts.choices) {
41
+
42
+ /** Update chip visuals to reflect the current selected/focused index. */
43
+ function setHighlight(newIndex: number): void {
44
+ focusIndex = newIndex;
45
+ selected = opts.choices[focusIndex].id;
46
+ for (let i = 0; i < chips.length; i++) {
47
+ chips[i].classList.toggle('ge-on', i === focusIndex);
48
+ }
49
+ }
50
+
51
+ for (let i = 0; i < opts.choices.length; i++) {
52
+ const c = opts.choices[i];
31
53
  const chip = document.createElement('button');
32
- chip.className = 'ge-chip' + (c.id === selected ? ' ge-on' : '');
54
+ chip.className = 'ge-chip' + (i === focusIndex ? ' ge-on' : '');
33
55
  chip.dataset.id = c.id; chip.textContent = c.label;
56
+ const idx = i; // capture for closure
34
57
  chip.addEventListener('click', () => {
35
- selected = c.id;
36
- for (const x of chips) x.classList.toggle('ge-on', x.dataset.id === selected);
58
+ setHighlight(idx);
37
59
  });
38
60
  chips.push(chip); grid.appendChild(chip);
39
61
  }
40
62
  ui.body.appendChild(grid);
41
63
 
64
+ function doConfirm(): void {
65
+ opts.onConfirm(selected);
66
+ opts.onClose();
67
+ }
68
+
42
69
  // Single full-bleed Confirm; dismissal is the ✕ (top-right). No Cancel button.
43
70
  const confirm = document.createElement('button');
44
71
  confirm.className = 'ge-modal-btn ge-modal-btn--accent'; confirm.dataset.ge = 'sheet-confirm';
45
72
  confirm.textContent = opts.confirmLabel;
46
- confirm.addEventListener('click', () => { opts.onConfirm(selected); ui.root.remove(); });
73
+ confirm.addEventListener('click', doConfirm);
47
74
  ui.card.appendChild(confirm);
48
75
 
49
- return ui.root;
76
+ function onKey(e: KeyboardEvent): boolean {
77
+ const last = opts.choices.length - 1;
78
+ switch (e.code) {
79
+ case 'ArrowRight':
80
+ case 'ArrowDown':
81
+ case 'Equal': // + on most keyboards
82
+ case 'NumpadAdd':
83
+ if (focusIndex < last) setHighlight(focusIndex + 1);
84
+ return true;
85
+ case 'ArrowLeft':
86
+ case 'ArrowUp':
87
+ case 'Minus':
88
+ case 'NumpadSubtract':
89
+ if (focusIndex > 0) setHighlight(focusIndex - 1);
90
+ return true;
91
+ case 'Enter':
92
+ case 'Space':
93
+ doConfirm();
94
+ return true;
95
+ case 'Escape':
96
+ opts.onClose();
97
+ return true;
98
+ default:
99
+ return false;
100
+ }
101
+ }
102
+
103
+ return { root: ui.root, onKey };
50
104
  }
51
105
 
52
106
  /** Bet picker — all available bets as chips (6 per row, 3 on mobile), accent Confirm applies it. */
53
- export function openBetModal(shell: GameShell): HTMLElement {
107
+ export function openBetModal(shell: GameShell): { root: HTMLElement; onKey: (e: KeyboardEvent) => boolean } {
54
108
  return buildSheet({
55
109
  ge: 'bet-modal', title: shell.t('Bet'), columns: { wide: 6, mobile: 3 }, confirmLabel: shell.t('Confirm'),
56
110
  choices: shell.state.availableBets.map((b) => ({ id: String(b), label: formatCurrency(b, shell.config.currency) })),
57
111
  selected: String(shell.state.bet),
112
+ onClose: () => shell.closeModal(),
58
113
  onConfirm: (id) => {
59
114
  const v = Number(id);
60
115
  if (v !== shell.state.bet) { shell.state.bet = v; shell.emit('betChange', v); }
@@ -76,13 +131,14 @@ function autoplayCounts(maxCount?: number): number[] {
76
131
  }
77
132
 
78
133
  /** Autoplay picker — spin counts (incl. ∞ unless a maxCount caps them); Confirm starts autoplay. */
79
- export function openAutoplayModal(shell: GameShell): HTMLElement {
134
+ export function openAutoplayModal(shell: GameShell): { root: HTMLElement; onKey: (e: KeyboardEvent) => boolean } {
80
135
  const maxCount = shell.config.features.autoplay?.maxCount;
81
136
  const counts = autoplayCounts(maxCount);
82
137
  return buildSheet({
83
138
  ge: 'autoplay-modal', title: shell.t('Autoplay'), columns: 3, confirmLabel: shell.t('Start'),
84
139
  choices: counts.map((n) => ({ id: String(n), label: Number.isFinite(n) ? String(n) : '∞' })),
85
140
  selected: String(shell.state.autoplay.remaining || counts[0]),
141
+ onClose: () => shell.closeModal(),
86
142
  onConfirm: (id) => {
87
143
  let remaining = Number(id); // "Infinity" → Infinity
88
144
  if (maxCount != null) remaining = Math.min(remaining, maxCount); // defensive cap
@@ -52,8 +52,9 @@ export interface OverlayOpts {
52
52
  onBack?: () => void;
53
53
  }
54
54
 
55
- /** Full-screen overlay. Returns { root, body }; append content to body. */
56
- export function createOverlay(opts: OverlayOpts): { root: HTMLDivElement; body: HTMLDivElement } {
55
+ /** Full-screen overlay. Returns { root, body, scroll }; append content to body.
56
+ * The `scroll` element is the scrollable container (overflow-y: auto). */
57
+ export function createOverlay(opts: OverlayOpts): { root: HTMLDivElement; body: HTMLDivElement; scroll: HTMLDivElement } {
57
58
  const root = document.createElement('div');
58
59
  root.className = 'ge-shell-overlay';
59
60
  const head = document.createElement('div');
@@ -80,5 +81,5 @@ export function createOverlay(opts: OverlayOpts): { root: HTMLDivElement; body:
80
81
  const body = document.createElement('div'); body.className = 'ge-ov-body';
81
82
  scroll.appendChild(body);
82
83
  root.append(head, scroll);
83
- return { root, body };
84
+ return { root, body, scroll };
84
85
  }
package/src/shell/i18n.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { LOCALES } from './locales';
2
+
1
3
  // Social-casino language. English is the source (and, for now, the only) language; `socialize`
2
4
  // rewrites the restricted gambling vocabulary into social-safe phrasing while preserving case.
3
5
  //
@@ -71,3 +73,24 @@ export function socialize(text: string): string {
71
73
  return repl == null ? m : applyCase(m, repl);
72
74
  });
73
75
  }
76
+
77
+ export type Lang = 'de'|'en'|'es'|'fi'|'fr'|'hi'|'id'|'ja'|'ko'|'pl'|'pt'|'ru'|'tr'|'vi'|'zh'|'da';
78
+ export const LANGS: readonly Lang[] = ['de','en','es','fi','fr','hi','id','ja','ko','pl','pt','ru','tr','vi','zh','da'];
79
+ const LANG_SET = new Set<string>(LANGS);
80
+
81
+ export function normalizeLang(code: string | null | undefined): Lang {
82
+ const base = (code ?? '').toLowerCase().split(/[-_]/)[0];
83
+ return (LANG_SET.has(base) ? base : 'en') as Lang;
84
+ }
85
+
86
+ export interface I18nOptions { language: string; isSocial?: boolean; messages?: Partial<Record<Lang, Record<string, string>>>; }
87
+ export interface I18n { readonly lang: Lang; t(src: string): string; }
88
+
89
+ export function createI18n(opts: I18nOptions): I18n {
90
+ const lang = normalizeLang(opts.language);
91
+ const t = (src: string): string => {
92
+ if (lang === 'en') return opts.isSocial ? socialize(src) : src;
93
+ return opts.messages?.[lang]?.[src] ?? LOCALES[lang]?.[src] ?? src;
94
+ };
95
+ return { lang, t };
96
+ }
@@ -18,4 +18,5 @@ export function removeGameShell(): Promise<void> {
18
18
  }
19
19
 
20
20
  export { GameShell };
21
- export { socialize } from './i18n';
21
+ export { socialize, createI18n, normalizeLang } from './i18n';
22
+ export type { Lang, I18n, I18nOptions } from './i18n';
@@ -0,0 +1,229 @@
1
+ import type { ShellState } from './types';
2
+
3
+ export interface KeyboardHost {
4
+ readonly state: ShellState;
5
+ readonly hotkeysEnabled: boolean; // features.hotkeys !== false
6
+ readonly spacebarEnabled: boolean; // features.spacebar !== false
7
+ readonly turboLevels: number; // features.turbo
8
+ readonly autoplayEnabled: boolean; // features.autoplay != null
9
+ readonly buyBonusEnabled: boolean; // features.buyBonus !== false
10
+ hasOpenLayer(): boolean;
11
+ routeToLayer(e: KeyboardEvent): boolean; // give the key to the top layer's onKey; true if consumed
12
+ spin(): void;
13
+ stepBet(dir: 1 | -1): void;
14
+ toggleAutoplay(): void;
15
+ cycleTurbo(): void;
16
+ openBuyBonus(): void;
17
+ openInfo(): void;
18
+ openMenu(): void;
19
+ toggleMute(): void;
20
+ closeLayer(): void;
21
+ }
22
+
23
+ // Bet key detection: bet-up needs Shift for arrow/equal, NumpadAdd is bare; same logic for down.
24
+ // Exported so overlays with their own bet stepper (Buy bonus) honour the SAME keys as the bar.
25
+ export function betDir(e: KeyboardEvent): 1 | -1 | null {
26
+ if (e.code === 'ArrowUp' && e.shiftKey) return 1;
27
+ if (e.code === 'Equal' && e.shiftKey) return 1;
28
+ if (e.code === 'NumpadAdd') return 1;
29
+ if (e.code === 'ArrowDown' && e.shiftKey) return -1;
30
+ if (e.code === 'Minus' && e.shiftKey) return -1;
31
+ if (e.code === 'NumpadSubtract') return -1;
32
+ return null;
33
+ }
34
+
35
+ export class KeyboardController {
36
+ private host: KeyboardHost;
37
+ private doc: Document;
38
+ private spaceHeld = false;
39
+ private holdTimer: ReturnType<typeof setTimeout> | null = null;
40
+ // Bet hold-repeat state
41
+ private betHeldCode: string | null = null;
42
+ private betTimer: ReturnType<typeof setTimeout> | null = null;
43
+
44
+ constructor(host: KeyboardHost, doc?: Document) {
45
+ this.host = host;
46
+ this.doc = doc ?? (typeof document !== 'undefined' ? document : (null as unknown as Document));
47
+ }
48
+
49
+ private isSpinAllowed(): boolean {
50
+ const h = this.host;
51
+ const s = h.state;
52
+ return (
53
+ h.spacebarEnabled &&
54
+ h.hotkeysEnabled &&
55
+ !h.hasOpenLayer() &&
56
+ s.mode === 'base' &&
57
+ !s.autoplay.active
58
+ );
59
+ }
60
+
61
+ private isBetAllowed(): boolean {
62
+ const h = this.host;
63
+ const s = h.state;
64
+ return (
65
+ h.hotkeysEnabled &&
66
+ !h.hasOpenLayer() &&
67
+ s.mode === 'base' &&
68
+ !s.busy
69
+ );
70
+ }
71
+
72
+ private clearBetTimer(): void {
73
+ if (this.betTimer !== null) {
74
+ clearTimeout(this.betTimer);
75
+ this.betTimer = null;
76
+ }
77
+ }
78
+
79
+ private startBetRepeat(dir: 1 | -1, elapsed: number): void {
80
+ // elapsed is ms already spent holding; use it to accelerate toward 45ms floor.
81
+ // Start at 90ms, decrease ~1ms per 10ms held after the first repeat, floor at 45ms.
82
+ const interval = Math.max(45, 90 - Math.floor(elapsed / 10));
83
+ this.betTimer = setTimeout(() => {
84
+ this.betTimer = null;
85
+ if (this.betHeldCode !== null && this.isBetAllowed()) {
86
+ this.host.stepBet(dir);
87
+ this.startBetRepeat(dir, elapsed + interval);
88
+ }
89
+ }, interval);
90
+ }
91
+
92
+ private onKeyDown = (e: KeyboardEvent): void => {
93
+ const target = e.target as HTMLElement | null;
94
+ // Editable element guard — never intercept keyboard input
95
+ if (target && (target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName))) return;
96
+
97
+ // For Space: claim preventDefault early (before layer/mode/busy bail) so the browser's
98
+ // native "Space activates focused button" can't re-fire a shell control and flicker a modal.
99
+ if (e.code === 'Space' && !e.repeat) {
100
+ if (!this.host.spacebarEnabled || !this.host.hotkeysEnabled) return;
101
+ e.preventDefault();
102
+ if (this.host.hasOpenLayer()) {
103
+ this.host.routeToLayer(e);
104
+ return;
105
+ }
106
+ const s = this.host.state;
107
+ if (s.mode !== 'base' || s.busy || s.autoplay.active) return;
108
+ this.spaceHeld = true;
109
+ this.host.spin();
110
+ return;
111
+ }
112
+
113
+ // Bet step keys (Shift+arrows, Shift+=/-, NumpadAdd/Subtract) — non-repeat only
114
+ if (!e.repeat) {
115
+ const dir = betDir(e);
116
+ if (dir !== null && this.isBetAllowed()) {
117
+ this.betHeldCode = e.code;
118
+ this.host.stepBet(dir);
119
+ // First repeat after 350ms initial delay
120
+ this.clearBetTimer();
121
+ const capturedDir = dir;
122
+ this.betTimer = setTimeout(() => {
123
+ this.betTimer = null;
124
+ if (this.betHeldCode !== null && this.isBetAllowed()) {
125
+ this.host.stepBet(capturedDir);
126
+ this.startBetRepeat(capturedDir, 350);
127
+ }
128
+ }, 350);
129
+ return;
130
+ }
131
+ }
132
+
133
+ // Non-Space keys: give the open layer first refusal. If it consumes the key, done; Escape closes
134
+ // it. Anything the layer does NOT consume falls through to the chrome hotkeys below — so the
135
+ // Settings/Info pages still honour Shift+I (Game info), Shift+M (sound), Shift+S, etc.
136
+ if (this.host.hasOpenLayer()) {
137
+ const consumed = this.host.routeToLayer(e);
138
+ if (consumed) return;
139
+ if (e.code === 'Escape') { this.host.closeLayer(); return; }
140
+ // not consumed → fall through to the Shift+letter chrome hotkeys
141
+ }
142
+
143
+ // Shift+letter bar hotkeys — fire when no layer is open, OR when an open layer left the key
144
+ // unconsumed (see fall-through above); gated on hotkeys being enabled.
145
+ if (!e.repeat && e.shiftKey && this.host.hotkeysEnabled) {
146
+ const h = this.host;
147
+ const s = h.state;
148
+ switch (e.code) {
149
+ case 'KeyA':
150
+ if (h.autoplayEnabled && !s.replay) { h.toggleAutoplay(); return; }
151
+ break;
152
+ case 'KeyT':
153
+ if (h.turboLevels > 0 && !s.replay) { h.cycleTurbo(); return; }
154
+ break;
155
+ case 'KeyB':
156
+ if (h.buyBonusEnabled && s.mode === 'base' && !s.replay) { h.openBuyBonus(); return; }
157
+ break;
158
+ case 'KeyI':
159
+ h.openInfo(); return;
160
+ case 'KeyS':
161
+ h.openMenu(); return;
162
+ case 'KeyM':
163
+ h.toggleMute(); return;
164
+ }
165
+ }
166
+ };
167
+
168
+ private onKeyUp = (e: KeyboardEvent): void => {
169
+ if (e.code === 'Space') {
170
+ this.spaceHeld = false;
171
+ this.clearHoldTimer();
172
+ }
173
+ // Stop bet repeat on key release
174
+ if (e.code === this.betHeldCode) {
175
+ this.betHeldCode = null;
176
+ this.clearBetTimer();
177
+ }
178
+ };
179
+
180
+ private onBlur = (): void => {
181
+ // Window blur — stop bet repeat AND hold-to-spin (same as releasing both keys)
182
+ this.betHeldCode = null;
183
+ this.clearBetTimer();
184
+ this.spaceHeld = false;
185
+ this.clearHoldTimer();
186
+ };
187
+
188
+ private clearHoldTimer(): void {
189
+ if (this.holdTimer !== null) {
190
+ clearTimeout(this.holdTimer);
191
+ this.holdTimer = null;
192
+ }
193
+ }
194
+
195
+ attach(): void {
196
+ this.doc.addEventListener('keydown', this.onKeyDown);
197
+ this.doc.addEventListener('keyup', this.onKeyUp);
198
+ // Use window if available for blur events
199
+ if (typeof window !== 'undefined') {
200
+ window.addEventListener('blur', this.onBlur);
201
+ }
202
+ }
203
+
204
+ detach(): void {
205
+ this.doc.removeEventListener('keydown', this.onKeyDown);
206
+ this.doc.removeEventListener('keyup', this.onKeyUp);
207
+ if (typeof window !== 'undefined') {
208
+ window.removeEventListener('blur', this.onBlur);
209
+ }
210
+ this.spaceHeld = false;
211
+ this.clearHoldTimer();
212
+ this.betHeldCode = null;
213
+ this.clearBetTimer();
214
+ }
215
+
216
+ notifyBusyChanged(busy: boolean): void {
217
+ if (busy) return;
218
+ if (!this.spaceHeld) return;
219
+ if (!this.isSpinAllowed()) return;
220
+ // Schedule the next spin after the 120 ms floor (gap between completion and next spin).
221
+ this.clearHoldTimer();
222
+ this.holdTimer = setTimeout(() => {
223
+ this.holdTimer = null;
224
+ if (this.spaceHeld && this.isSpinAllowed()) {
225
+ this.host.spin();
226
+ }
227
+ }, 120);
228
+ }
229
+ }