@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
|
@@ -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):
|
|
22
|
-
const ui = createCardModal({ ge: opts.ge, title: opts.title, onClose: () =>
|
|
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
|
-
|
|
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' + (
|
|
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
|
-
|
|
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',
|
|
73
|
+
confirm.addEventListener('click', doConfirm);
|
|
47
74
|
ui.card.appendChild(confirm);
|
|
48
75
|
|
|
49
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/shell/index.ts
CHANGED
|
@@ -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
|
+
}
|