@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/shell.cjs.js CHANGED
@@ -50,6 +50,221 @@ class EventEmitter {
50
50
  }
51
51
  }
52
52
 
53
+ // Bet key detection: bet-up needs Shift for arrow/equal, NumpadAdd is bare; same logic for down.
54
+ // Exported so overlays with their own bet stepper (Buy bonus) honour the SAME keys as the bar.
55
+ function betDir(e) {
56
+ if (e.code === 'ArrowUp' && e.shiftKey)
57
+ return 1;
58
+ if (e.code === 'Equal' && e.shiftKey)
59
+ return 1;
60
+ if (e.code === 'NumpadAdd')
61
+ return 1;
62
+ if (e.code === 'ArrowDown' && e.shiftKey)
63
+ return -1;
64
+ if (e.code === 'Minus' && e.shiftKey)
65
+ return -1;
66
+ if (e.code === 'NumpadSubtract')
67
+ return -1;
68
+ return null;
69
+ }
70
+ class KeyboardController {
71
+ host;
72
+ doc;
73
+ spaceHeld = false;
74
+ holdTimer = null;
75
+ // Bet hold-repeat state
76
+ betHeldCode = null;
77
+ betTimer = null;
78
+ constructor(host, doc) {
79
+ this.host = host;
80
+ this.doc = doc ?? (typeof document !== 'undefined' ? document : null);
81
+ }
82
+ isSpinAllowed() {
83
+ const h = this.host;
84
+ const s = h.state;
85
+ return (h.spacebarEnabled &&
86
+ h.hotkeysEnabled &&
87
+ !h.hasOpenLayer() &&
88
+ s.mode === 'base' &&
89
+ !s.autoplay.active);
90
+ }
91
+ isBetAllowed() {
92
+ const h = this.host;
93
+ const s = h.state;
94
+ return (h.hotkeysEnabled &&
95
+ !h.hasOpenLayer() &&
96
+ s.mode === 'base' &&
97
+ !s.busy);
98
+ }
99
+ clearBetTimer() {
100
+ if (this.betTimer !== null) {
101
+ clearTimeout(this.betTimer);
102
+ this.betTimer = null;
103
+ }
104
+ }
105
+ startBetRepeat(dir, elapsed) {
106
+ // elapsed is ms already spent holding; use it to accelerate toward 45ms floor.
107
+ // Start at 90ms, decrease ~1ms per 10ms held after the first repeat, floor at 45ms.
108
+ const interval = Math.max(45, 90 - Math.floor(elapsed / 10));
109
+ this.betTimer = setTimeout(() => {
110
+ this.betTimer = null;
111
+ if (this.betHeldCode !== null && this.isBetAllowed()) {
112
+ this.host.stepBet(dir);
113
+ this.startBetRepeat(dir, elapsed + interval);
114
+ }
115
+ }, interval);
116
+ }
117
+ onKeyDown = (e) => {
118
+ const target = e.target;
119
+ // Editable element guard — never intercept keyboard input
120
+ if (target && (target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName)))
121
+ return;
122
+ // For Space: claim preventDefault early (before layer/mode/busy bail) so the browser's
123
+ // native "Space activates focused button" can't re-fire a shell control and flicker a modal.
124
+ if (e.code === 'Space' && !e.repeat) {
125
+ if (!this.host.spacebarEnabled || !this.host.hotkeysEnabled)
126
+ return;
127
+ e.preventDefault();
128
+ if (this.host.hasOpenLayer()) {
129
+ this.host.routeToLayer(e);
130
+ return;
131
+ }
132
+ const s = this.host.state;
133
+ if (s.mode !== 'base' || s.busy || s.autoplay.active)
134
+ return;
135
+ this.spaceHeld = true;
136
+ this.host.spin();
137
+ return;
138
+ }
139
+ // Bet step keys (Shift+arrows, Shift+=/-, NumpadAdd/Subtract) — non-repeat only
140
+ if (!e.repeat) {
141
+ const dir = betDir(e);
142
+ if (dir !== null && this.isBetAllowed()) {
143
+ this.betHeldCode = e.code;
144
+ this.host.stepBet(dir);
145
+ // First repeat after 350ms initial delay
146
+ this.clearBetTimer();
147
+ const capturedDir = dir;
148
+ this.betTimer = setTimeout(() => {
149
+ this.betTimer = null;
150
+ if (this.betHeldCode !== null && this.isBetAllowed()) {
151
+ this.host.stepBet(capturedDir);
152
+ this.startBetRepeat(capturedDir, 350);
153
+ }
154
+ }, 350);
155
+ return;
156
+ }
157
+ }
158
+ // Non-Space keys: give the open layer first refusal. If it consumes the key, done; Escape closes
159
+ // it. Anything the layer does NOT consume falls through to the chrome hotkeys below — so the
160
+ // Settings/Info pages still honour Shift+I (Game info), Shift+M (sound), Shift+S, etc.
161
+ if (this.host.hasOpenLayer()) {
162
+ const consumed = this.host.routeToLayer(e);
163
+ if (consumed)
164
+ return;
165
+ if (e.code === 'Escape') {
166
+ this.host.closeLayer();
167
+ return;
168
+ }
169
+ // not consumed → fall through to the Shift+letter chrome hotkeys
170
+ }
171
+ // Shift+letter bar hotkeys — fire when no layer is open, OR when an open layer left the key
172
+ // unconsumed (see fall-through above); gated on hotkeys being enabled.
173
+ if (!e.repeat && e.shiftKey && this.host.hotkeysEnabled) {
174
+ const h = this.host;
175
+ const s = h.state;
176
+ switch (e.code) {
177
+ case 'KeyA':
178
+ if (h.autoplayEnabled && !s.replay) {
179
+ h.toggleAutoplay();
180
+ return;
181
+ }
182
+ break;
183
+ case 'KeyT':
184
+ if (h.turboLevels > 0 && !s.replay) {
185
+ h.cycleTurbo();
186
+ return;
187
+ }
188
+ break;
189
+ case 'KeyB':
190
+ if (h.buyBonusEnabled && s.mode === 'base' && !s.replay) {
191
+ h.openBuyBonus();
192
+ return;
193
+ }
194
+ break;
195
+ case 'KeyI':
196
+ h.openInfo();
197
+ return;
198
+ case 'KeyS':
199
+ h.openMenu();
200
+ return;
201
+ case 'KeyM':
202
+ h.toggleMute();
203
+ return;
204
+ }
205
+ }
206
+ };
207
+ onKeyUp = (e) => {
208
+ if (e.code === 'Space') {
209
+ this.spaceHeld = false;
210
+ this.clearHoldTimer();
211
+ }
212
+ // Stop bet repeat on key release
213
+ if (e.code === this.betHeldCode) {
214
+ this.betHeldCode = null;
215
+ this.clearBetTimer();
216
+ }
217
+ };
218
+ onBlur = () => {
219
+ // Window blur — stop bet repeat AND hold-to-spin (same as releasing both keys)
220
+ this.betHeldCode = null;
221
+ this.clearBetTimer();
222
+ this.spaceHeld = false;
223
+ this.clearHoldTimer();
224
+ };
225
+ clearHoldTimer() {
226
+ if (this.holdTimer !== null) {
227
+ clearTimeout(this.holdTimer);
228
+ this.holdTimer = null;
229
+ }
230
+ }
231
+ attach() {
232
+ this.doc.addEventListener('keydown', this.onKeyDown);
233
+ this.doc.addEventListener('keyup', this.onKeyUp);
234
+ // Use window if available for blur events
235
+ if (typeof window !== 'undefined') {
236
+ window.addEventListener('blur', this.onBlur);
237
+ }
238
+ }
239
+ detach() {
240
+ this.doc.removeEventListener('keydown', this.onKeyDown);
241
+ this.doc.removeEventListener('keyup', this.onKeyUp);
242
+ if (typeof window !== 'undefined') {
243
+ window.removeEventListener('blur', this.onBlur);
244
+ }
245
+ this.spaceHeld = false;
246
+ this.clearHoldTimer();
247
+ this.betHeldCode = null;
248
+ this.clearBetTimer();
249
+ }
250
+ notifyBusyChanged(busy) {
251
+ if (busy)
252
+ return;
253
+ if (!this.spaceHeld)
254
+ return;
255
+ if (!this.isSpinAllowed())
256
+ return;
257
+ // Schedule the next spin after the 120 ms floor (gap between completion and next spin).
258
+ this.clearHoldTimer();
259
+ this.holdTimer = setTimeout(() => {
260
+ this.holdTimer = null;
261
+ if (this.spaceHeld && this.isSpinAllowed()) {
262
+ this.host.spin();
263
+ }
264
+ }, 120);
265
+ }
266
+ }
267
+
53
268
  function createInitialState(config) {
54
269
  return {
55
270
  mode: config.mode,
@@ -232,11 +447,11 @@ const SHELL_CSS = SHELL_FONT_CSS + `
232
447
 
233
448
  /* host = bottom-anchored flex column: [win pill (on overflow)] above [the bar] */
234
449
  #${SHELL_ROOT_ID} .ge-shell-barhost { position:absolute; left:0; right:0; bottom:0; pointer-events:none;
235
- display:flex; flex-direction:column; align-items:center; justify-content:flex-end; gap:8px;
450
+ display:flex; flex-direction:column; align-items:center; justify-content:flex-end; gap:4px;
236
451
  transform-origin:bottom center; }
237
452
  /* bottom bar: transparent, two zones (wide default) */
238
453
  #${SHELL_ROOT_ID} .ge-shell-bottom { width:100%; box-sizing:border-box; pointer-events:none;
239
- display:flex; align-items:center; justify-content:space-between; padding:0 18px 14px; gap:14px; }
454
+ display:flex; align-items:center; justify-content:space-between; padding:0 18px 6px; gap:14px; }
240
455
  #${SHELL_ROOT_ID} .ge-zone { display:flex; align-items:center; gap:14px; pointer-events:none; }
241
456
  #${SHELL_ROOT_ID} .ge-zone > * { pointer-events:auto; }
242
457
  #${SHELL_ROOT_ID} .ge-betstep { display:flex; flex-direction:column; gap:2px; }
@@ -319,6 +534,21 @@ const SHELL_CSS = SHELL_FONT_CSS + `
319
534
  #${SHELL_ROOT_ID} .ge-gi-version { text-align:center; color:var(--shell-muted); font-size:11px;
320
535
  letter-spacing:.08em; opacity:.7; margin:4px 0 2px; }
321
536
 
537
+ /* hotkeys — keycap chips → localized action label, mirrors controls row layout */
538
+ #${SHELL_ROOT_ID} .ge-gi-hk-block { display:flex; flex-direction:column; }
539
+ #${SHELL_ROOT_ID} .ge-gi-hk { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:9px 0; }
540
+ #${SHELL_ROOT_ID} .ge-gi-hk + .ge-gi-hk { border-top:1px solid var(--shell-plaque-line); }
541
+ #${SHELL_ROOT_ID} .ge-gi-hk-chips { display:flex; align-items:center; flex-wrap:wrap; gap:4px; flex:0 0 auto; }
542
+ #${SHELL_ROOT_ID} .ge-gi-hk-combo { display:inline-flex; align-items:center; gap:4px; }
543
+ #${SHELL_ROOT_ID} .ge-gi-hk-chip { display:inline-flex; align-items:center; justify-content:center;
544
+ padding:2px 7px; border-radius:6px; border:1px solid var(--shell-plaque-line);
545
+ background:var(--shell-plaque-dark); color:#fff;
546
+ font-family:'SFMono-Regular',Consolas,'Liberation Mono',Menlo,monospace; font-size:12px;
547
+ font-weight:600; line-height:1.5; white-space:nowrap; min-width:1.6em; text-align:center; }
548
+ #${SHELL_ROOT_ID} .ge-gi-hk-sep { color:var(--shell-plaque-label); font-size:11px; padding:0 1px; }
549
+ #${SHELL_ROOT_ID} .ge-gi-hk-sep2 { color:var(--shell-plaque-label); font-size:11px; padding:0 4px; }
550
+ #${SHELL_ROOT_ID} .ge-gi-hk-tx { color:rgba(255,255,255,.88); font-size:14px; font-weight:600; text-align:right; flex:1; }
551
+
322
552
  /* controls — two blocks (gameplay / menu & info), icon/name/description per control */
323
553
  #${SHELL_ROOT_ID} .ge-gi-ctl-block + .ge-gi-ctl-block { margin-top:16px; padding-top:4px; border-top:1px solid var(--shell-plaque-line); }
324
554
  #${SHELL_ROOT_ID} .ge-gi-ctl-block-h { color:var(--shell-plaque-label); font-size:11px; letter-spacing:.12em;
@@ -427,6 +657,8 @@ const SHELL_CSS = SHELL_FONT_CSS + `
427
657
  pointer-events:auto; cursor:pointer; transition:box-shadow .12s ease, background .12s ease; }
428
658
  #${SHELL_ROOT_ID} .ge-bonus-card:hover:not(.ge-bonus-off) {
429
659
  box-shadow:0 0 0 1px var(--card-acc), 0 12px 34px -12px var(--card-acc); }
660
+ #${SHELL_ROOT_ID} .ge-bonus-card--kbd-focus:not(.ge-bonus-off) {
661
+ box-shadow:0 0 0 1px var(--card-acc), 0 12px 34px -12px var(--card-acc); }
430
662
  /* custom card (BonusOption.custom): keep grid sizing + accent vars, drop the default chrome so the game owns the UI */
431
663
  #${SHELL_ROOT_ID} .ge-bonus-card--custom { background:none; border:none; cursor:default; }
432
664
  #${SHELL_ROOT_ID} .ge-bonus-body { display:flex; flex-direction:column; align-items:center; flex:1; padding:1.25em 1.1em .9em; }
@@ -505,7 +737,7 @@ const SHELL_CSS = SHELL_FONT_CSS + `
505
737
  #${SHELL_ROOT_ID} .ge-pl .ge-iconbtn { color:#fff; }
506
738
  /* LEFT: [menu] ⊐ coin ⊏ [balance] — coin overlaps both; balance fixed-wide so it doesn't jiggle */
507
739
  #${SHELL_ROOT_ID} .ge-pl-menu { border-radius:16px 0 0 16px; padding-right:20px; }
508
- #${SHELL_ROOT_ID} .ge-pl-bal { border-radius:0 16px 16px 0; padding-left:24px; min-width:240px; }
740
+ #${SHELL_ROOT_ID} .ge-pl-bal { border-radius:0 16px 16px 0; padding-left:24px; min-width:200px; }
509
741
  #${SHELL_ROOT_ID} .ge-zone-plaques .ge-shell-buybonus { margin:0 -16px; position:relative; z-index:3; }
510
742
  /* RIGHT: [bet] · |divider| · [auto · SPIN · turbo] */
511
743
  #${SHELL_ROOT_ID} .ge-pl-bet { border-radius:16px 0 0 16px; justify-content:space-between;
@@ -705,7 +937,8 @@ function createCardModal(opts) {
705
937
  }
706
938
  return { root, card, body };
707
939
  }
708
- /** Full-screen overlay. Returns { root, body }; append content to body. */
940
+ /** Full-screen overlay. Returns { root, body, scroll }; append content to body.
941
+ * The `scroll` element is the scrollable container (overflow-y: auto). */
709
942
  function createOverlay(opts) {
710
943
  const root = document.createElement('div');
711
944
  root.className = 'ge-shell-overlay';
@@ -743,7 +976,7 @@ function createOverlay(opts) {
743
976
  body.className = 'ge-ov-body';
744
977
  scroll.appendChild(body);
745
978
  root.append(head, scroll);
746
- return { root, body };
979
+ return { root, body, scroll };
747
980
  }
748
981
 
749
982
  /** A floating labelled money readout (balance/win/bet). */
@@ -995,24 +1228,22 @@ function applyBusy(shell, bar) {
995
1228
  function openSettingsModal(shell) {
996
1229
  const { root, body } = createOverlay({ title: shell.t('Settings'), onClose: () => root.remove() });
997
1230
  root.dataset.ge = 'settings-modal';
998
- // Sound on/off (starts on) full-width row with a speaker icon button
1231
+ // Sound on/off backed by the shell's shared `soundOn` state so this toggle and the Shift+M
1232
+ // hotkey stay in sync; `setSound` emits `settingChange({ key: 'sound' })` and refreshes the icon.
999
1233
  const sound = (() => {
1000
- let on = true;
1001
1234
  const btn = document.createElement('button');
1002
- btn.className = 'ge-snd ge-active';
1235
+ btn.className = 'ge-snd';
1003
1236
  btn.dataset.ge = 'setting-sound';
1004
- btn.setAttribute('aria-label', 'Sound');
1005
- const paint = () => {
1237
+ btn.setAttribute('aria-label', shell.t('Sound'));
1238
+ const paint = (on) => {
1006
1239
  btn.innerHTML = icon(on ? 'soundOn' : 'soundOff');
1007
1240
  btn.classList.toggle('ge-active', on);
1008
1241
  btn.setAttribute('aria-pressed', String(on));
1009
1242
  };
1010
- paint();
1011
- btn.addEventListener('click', () => {
1012
- on = !on;
1013
- paint();
1014
- shell.emit('settingChange', { key: 'sound', value: on });
1015
- });
1243
+ paint(shell.soundOn);
1244
+ btn.addEventListener('click', () => shell.setSound(!shell.soundOn));
1245
+ // Live-update the icon when sound changes from here OR via Shift+M (shell clears on close).
1246
+ shell.setSoundRefresh(paint);
1016
1247
  const row = document.createElement('div');
1017
1248
  row.className = 'ge-ov-row';
1018
1249
  row.innerHTML = `<span class="ge-grow">${shell.t('Sound')}</span>`;
@@ -1062,17 +1293,25 @@ function openSettingsModal(shell) {
1062
1293
 
1063
1294
  // AUTO-GENERATED by scripts/gen-version.mjs — do not edit. Mirrors package.json "version".
1064
1295
  /** The @energy8platform/platform-core package version, stamped at build time. */
1065
- const PACKAGE_VERSION = '0.25.3';
1296
+ const PACKAGE_VERSION = '0.26.0';
1066
1297
 
1298
+ /** Default order key for the auto-injected hotkeys section: just after `controls` (-1). */
1299
+ const HOTKEYS_DEFAULT_ORDER = -0.5;
1067
1300
  const SVG_NS = 'http://www.w3.org/2000/svg';
1068
1301
  function openGameInfoModal(shell) {
1069
- const { root, body } = createOverlay({
1302
+ const { root, body, scroll } = createOverlay({
1070
1303
  title: shell.t('Game info'),
1071
1304
  onClose: () => root.remove(),
1072
1305
  onBack: () => { root.remove(); shell.openSettings(); },
1073
1306
  });
1074
1307
  root.dataset.ge = 'info-modal';
1075
- const sections = shell.config.gameInfo.sections ?? [];
1308
+ const rawSections = shell.config.gameInfo.sections ?? [];
1309
+ // Auto-inject a hotkeys section unless the game already provides one or features.hotkeys === false.
1310
+ const sectionsWithHotkeys = [...rawSections];
1311
+ if (shell.config.features.hotkeys !== false && !rawSections.some((s) => s.type === 'hotkeys')) {
1312
+ sectionsWithHotkeys.push({ type: 'hotkeys', order: HOTKEYS_DEFAULT_ORDER });
1313
+ }
1314
+ const sections = sectionsWithHotkeys;
1076
1315
  // Default placement: modes first, controls second, the rest in declaration order.
1077
1316
  // An explicit `order` overrides; ties keep declaration order (stable).
1078
1317
  const base = (s, i) => s.order ?? (s.type === 'modes' ? -2 : s.type === 'controls' ? -1 : i);
@@ -1081,7 +1320,40 @@ function openGameInfoModal(shell) {
1081
1320
  .sort((a, b) => a.k - b.k || a.i - b.i)
1082
1321
  .forEach(({ s }) => body.appendChild(renderSection(shell, s)));
1083
1322
  body.appendChild(versionFooter(shell));
1084
- return root;
1323
+ const LINE = 60;
1324
+ const PAGE = () => Math.floor(scroll.clientHeight * 0.9) || Math.floor(540 * 0.9);
1325
+ const onKey = (e) => {
1326
+ switch (e.code) {
1327
+ case 'ArrowDown':
1328
+ scroll.scrollTop += LINE;
1329
+ return true;
1330
+ case 'ArrowUp':
1331
+ scroll.scrollTop = Math.max(0, scroll.scrollTop - LINE);
1332
+ return true;
1333
+ case 'PageDown':
1334
+ scroll.scrollTop += PAGE();
1335
+ return true;
1336
+ case 'PageUp':
1337
+ scroll.scrollTop = Math.max(0, scroll.scrollTop - PAGE());
1338
+ return true;
1339
+ case 'Space':
1340
+ if (e.shiftKey) {
1341
+ scroll.scrollTop = Math.max(0, scroll.scrollTop - PAGE());
1342
+ }
1343
+ else {
1344
+ scroll.scrollTop += PAGE();
1345
+ }
1346
+ return true;
1347
+ case 'Home':
1348
+ scroll.scrollTop = 0;
1349
+ return true;
1350
+ case 'End':
1351
+ scroll.scrollTop = scroll.scrollHeight - scroll.clientHeight;
1352
+ return true;
1353
+ default: return false;
1354
+ }
1355
+ };
1356
+ return { root, onKey };
1085
1357
  }
1086
1358
  /** A muted version stamp pinned to the bottom of the game-info modal:
1087
1359
  * `${config.version ?? '1.0.0'}.${engine version without dots}` (e.g. '1.0.0.0246'). */
@@ -1097,9 +1369,11 @@ function renderSection(shell, s) {
1097
1369
  switch (s.type) {
1098
1370
  case 'modes': return sectionModes(shell, s.modes, sec('info-modes', s.title, shell.t('Modes')));
1099
1371
  case 'controls': return sectionControls(shell, sec('info-controls', s.title, shell.t('Controls')));
1372
+ case 'hotkeys': return sectionHotkeys(shell, sec('info-hotkeys', s.title, shell.t('Hotkeys')));
1100
1373
  case 'paytable': return sectionPaytable(s.rows, sec('info-paytable', s.title, shell.t('Paytable')));
1101
1374
  case 'wins': return sectionWins(s, sec('info-wins', s.title, shell.t(winFallbackTitle(s.kind))));
1102
- case 'custom': return sectionCustom(s, sec('info-custom', s.title, ''));
1375
+ // Translate the heading (e.g. the host-built DISCLAIMER title); the body stays verbatim.
1376
+ case 'custom': return sectionCustom(s, sec('info-custom', s.title != null ? shell.t(s.title) : undefined, ''));
1103
1377
  }
1104
1378
  }
1105
1379
  /** A titled glass-plaque section shell. */
@@ -1181,6 +1455,57 @@ function ctlBlock(shell, label, rows) {
1181
1455
  }
1182
1456
  return block;
1183
1457
  }
1458
+ function sectionHotkeys(shell, el) {
1459
+ const { features } = shell.config;
1460
+ /** Render one or more key names as keycap chips joined by " / ". */
1461
+ const chips = (...keys) => keys.map((k) => `<span class="ge-gi-hk-chip">${k}</span>`).join('<span class="ge-gi-hk-sep"> / </span>');
1462
+ const rows = [
1463
+ { chips: ['Space'], name: 'Spin', on: true },
1464
+ { chips: ['Shift', '↑', 'Shift', '='], name: 'Raise bet', on: true },
1465
+ { chips: ['Shift', '↓', 'Shift', '-'], name: 'Lower bet', on: true },
1466
+ { chips: ['Shift', 'A'], name: 'Autoplay', on: features.autoplay != null },
1467
+ { chips: ['Shift', 'T'], name: 'Turbo', on: features.turbo > 0 },
1468
+ { chips: ['Shift', 'B'], name: 'Buy bonus', on: features.buyBonus !== false },
1469
+ { chips: ['Shift', 'I'], name: 'Game info', on: true },
1470
+ { chips: ['Shift', 'S'], name: 'Menu', on: true },
1471
+ { chips: ['Shift', 'M'], name: 'Mute', on: true },
1472
+ { chips: ['←', '→'], name: 'Navigate', on: true },
1473
+ { chips: ['Enter'], name: 'Confirm', on: true },
1474
+ { chips: ['Esc'], name: 'Close', on: true },
1475
+ ];
1476
+ const block = document.createElement('div');
1477
+ block.className = 'ge-gi-hk-block';
1478
+ for (const r of rows.filter((x) => x.on)) {
1479
+ const row = document.createElement('div');
1480
+ row.className = 'ge-gi-hk';
1481
+ // Build the chips column
1482
+ const chipsEl = document.createElement('div');
1483
+ chipsEl.className = 'ge-gi-hk-chips';
1484
+ if (r.name === 'Raise bet' || r.name === 'Lower bet') {
1485
+ // Two combos separated by " / ": Shift+↑ / Shift+= and Shift+↓ / Shift+-
1486
+ const [k1, k2, k3, k4] = r.chips;
1487
+ chipsEl.innerHTML =
1488
+ `<span class="ge-gi-hk-combo">${chips(k1, k2)}</span>` +
1489
+ `<span class="ge-gi-hk-sep2"> / </span>` +
1490
+ `<span class="ge-gi-hk-combo">${chips(k3, k4)}</span>`;
1491
+ }
1492
+ else if (r.chips.length > 1) {
1493
+ // Chord: Shift + X
1494
+ chipsEl.innerHTML = `<span class="ge-gi-hk-combo">${chips(...r.chips)}</span>`;
1495
+ }
1496
+ else {
1497
+ chipsEl.innerHTML = chips(...r.chips);
1498
+ }
1499
+ const tx = document.createElement('div');
1500
+ tx.className = 'ge-gi-hk-tx';
1501
+ tx.textContent = shell.t(r.name);
1502
+ row.appendChild(chipsEl);
1503
+ row.appendChild(tx);
1504
+ block.appendChild(row);
1505
+ }
1506
+ el.appendChild(block);
1507
+ return el;
1508
+ }
1184
1509
  // ── paytable (cards — image on top, name, then win tiers "<count> x<mult>") ────
1185
1510
  function sectionPaytable(rows, el) {
1186
1511
  const grid = document.createElement('div');
@@ -1379,25 +1704,165 @@ function sectionCustom(s, el) {
1379
1704
  return el;
1380
1705
  }
1381
1706
 
1382
- /** Buy-bonus overlay — a grid of art-forward cards, one per option. */
1707
+ /** Buy-bonus overlay — a grid of art-forward cards, one per option.
1708
+ * Returns the overlay element + a keyboard handler for the shell's `showModal`. */
1383
1709
  function openBuyBonusOverlay(shell) {
1384
1710
  const bonuses = shell.config.features.buyBonus;
1385
1711
  if (bonuses === false || bonuses.length === 0)
1386
1712
  return null;
1387
- const { root, body } = createOverlay({ title: shell.t('Buy bonus'), onClose: () => root.remove() });
1713
+ const st = { focusIndex: -1, confirmBonus: undefined };
1714
+ const { root, body } = createOverlay({ title: shell.t('Buy bonus'), onClose: () => shell.closeModal() });
1388
1715
  root.dataset.ge = 'buybonus-overlay';
1389
1716
  // Re-render the grid whenever the bet changes so every card's price stays live.
1390
1717
  const renderGrid = () => {
1391
1718
  body.innerHTML = '';
1392
1719
  const grid = document.createElement('div');
1393
1720
  grid.className = 'ge-bb-grid';
1394
- for (const bonus of bonuses)
1395
- grid.appendChild(buildCard(shell, bonus, root));
1721
+ const affordable = [];
1722
+ for (const bonus of bonuses) {
1723
+ const card = buildCard(shell, bonus, root, st);
1724
+ grid.appendChild(card);
1725
+ if (isAffordable(shell, bonus))
1726
+ affordable.push(bonus);
1727
+ }
1396
1728
  body.appendChild(grid);
1729
+ // Initialize or restore focus index
1730
+ if (affordable.length > 0) {
1731
+ if (st.focusIndex < 0)
1732
+ st.focusIndex = 0;
1733
+ else
1734
+ st.focusIndex = Math.min(st.focusIndex, affordable.length - 1);
1735
+ applyFocusClass(root, bonuses, affordable, st.focusIndex);
1736
+ }
1737
+ else {
1738
+ st.focusIndex = -1;
1739
+ }
1397
1740
  };
1398
1741
  renderGrid();
1399
1742
  root.appendChild(buildBetBar(shell, renderGrid)); // thin bottom footer, only as tall as the pill
1400
- return root;
1743
+ /** Step the bet by `dir` and re-render the grid (live prices + affordability) when it changed.
1744
+ * Shared by the keyboard bet keys (the footer ± buttons keep their own copy). */
1745
+ const stepBetBy = (dir) => {
1746
+ const next = stepBet(shell.state, dir);
1747
+ if (next === shell.state.bet)
1748
+ return;
1749
+ shell.state.bet = next;
1750
+ shell.emit('betChange', next);
1751
+ shell.render();
1752
+ renderGrid();
1753
+ };
1754
+ /** Keyboard handler for both browse and confirm phases. */
1755
+ const onKey = (e) => {
1756
+ const affordable = bonuses.filter((b) => isAffordable(shell, b));
1757
+ // ── Confirm phase ──
1758
+ if (st.confirmBonus) {
1759
+ switch (e.code) {
1760
+ case 'Enter':
1761
+ case 'Space': {
1762
+ const bonus = st.confirmBonus;
1763
+ if (!isAffordable(shell, bonus))
1764
+ return true;
1765
+ if (bonus.type === 'feature')
1766
+ shell.activateFeature(bonus);
1767
+ else
1768
+ shell.emit('buyBonusSelect', { id: bonus.id });
1769
+ shell.closeModal();
1770
+ return true;
1771
+ }
1772
+ case 'Escape':
1773
+ // Remove the confirm dialog, return to browse
1774
+ closeConfirm(root, st);
1775
+ return true;
1776
+ default:
1777
+ return false;
1778
+ }
1779
+ }
1780
+ // ── Browse phase ──
1781
+ const last = affordable.length - 1;
1782
+ const mobile = shell.layout === 'mobile';
1783
+ // Bet stepping mirrors the bar's keys (Shift+↑/↓, Shift+=/-, Numpad ±). Checked BEFORE arrow
1784
+ // navigation so a bare arrow still moves card focus while a Shift+arrow changes the bet.
1785
+ const bet = betDir(e);
1786
+ if (bet !== null) {
1787
+ stepBetBy(bet);
1788
+ return true;
1789
+ }
1790
+ // Determine navigation direction from key code + layout (mobile uses vertical arrows)
1791
+ const fwdKey = e.code === 'ArrowRight' || (mobile && e.code === 'ArrowDown');
1792
+ const bwdKey = e.code === 'ArrowLeft' || (mobile && e.code === 'ArrowUp');
1793
+ if (fwdKey) {
1794
+ if (last < 0)
1795
+ return true;
1796
+ if (st.focusIndex < last) {
1797
+ st.focusIndex++;
1798
+ applyFocusClass(root, bonuses, affordable, st.focusIndex);
1799
+ }
1800
+ return true;
1801
+ }
1802
+ if (bwdKey) {
1803
+ if (last < 0)
1804
+ return true;
1805
+ if (st.focusIndex > 0) {
1806
+ st.focusIndex--;
1807
+ applyFocusClass(root, bonuses, affordable, st.focusIndex);
1808
+ }
1809
+ return true;
1810
+ }
1811
+ switch (e.code) {
1812
+ case 'Enter':
1813
+ case 'Space':
1814
+ if (last < 0 || st.focusIndex < 0)
1815
+ return true;
1816
+ {
1817
+ const bonus = affordable[st.focusIndex];
1818
+ openConfirm(shell, bonus, root, st);
1819
+ }
1820
+ return true;
1821
+ // Bare =/- also step the bet (the Shift+=/- and Numpad variants are handled by betDir above).
1822
+ case 'Equal':
1823
+ stepBetBy(1);
1824
+ return true;
1825
+ case 'Minus':
1826
+ stepBetBy(-1);
1827
+ return true;
1828
+ case 'Escape':
1829
+ shell.closeModal();
1830
+ return true;
1831
+ default:
1832
+ return false;
1833
+ }
1834
+ };
1835
+ return { root, onKey };
1836
+ }
1837
+ /** Apply a CSS keyboard-focus class to the currently focused affordable card. */
1838
+ function applyFocusClass(overlay, bonuses, affordable, focusIndex) {
1839
+ for (const b of bonuses) {
1840
+ const card = overlay.querySelector(`[data-ge="bonus-card-${b.id}"]`);
1841
+ if (!card)
1842
+ continue;
1843
+ card.classList.remove('ge-bonus-card--kbd-focus');
1844
+ }
1845
+ const focused = affordable[focusIndex];
1846
+ if (!focused)
1847
+ return;
1848
+ const card = overlay.querySelector(`[data-ge="bonus-card-${focused.id}"]`);
1849
+ if (card)
1850
+ card.classList.add('ge-bonus-card--kbd-focus');
1851
+ }
1852
+ /** Open the confirm dialog for the given bonus and track it in overlay state. */
1853
+ function openConfirm(shell, bonus, overlay, st) {
1854
+ closeConfirm(overlay, st); // remove any existing confirm
1855
+ st.confirmBonus = bonus;
1856
+ overlay.appendChild(buildConfirm(shell, bonus, overlay, st));
1857
+ shell.fitModals();
1858
+ }
1859
+ /** Remove the confirm dialog and clear the overlay state. */
1860
+ function closeConfirm(overlay, st) {
1861
+ // The confirm dialog is a .ge-sheet with data-ge="bonus-confirm" appended directly to overlay.
1862
+ const sheet = overlay.querySelector('[data-ge="bonus-confirm"]');
1863
+ if (sheet)
1864
+ sheet.remove();
1865
+ st.confirmBonus = undefined;
1401
1866
  }
1402
1867
  /** Bet control — a compact −/+ pill around the live stake, in a thin footer at the screen bottom.
1403
1868
  * Stepping repaints the value, re-prices the cards, and updates the control bar. */
@@ -1444,7 +1909,7 @@ function stepButton(ge, name) {
1444
1909
  }
1445
1910
  /** A grid card: title → thumbnail → description → volatility → price → full-bleed CTA.
1446
1911
  * Clicking (when affordable) opens the confirmation modal. */
1447
- function buildCard(shell, bonus, overlay) {
1912
+ function buildCard(shell, bonus, overlay, st) {
1448
1913
  const accent = effectiveAccent(bonus);
1449
1914
  const card = document.createElement('div');
1450
1915
  card.className = 'ge-bonus-card';
@@ -1457,8 +1922,7 @@ function buildCard(shell, bonus, overlay) {
1457
1922
  const select = () => {
1458
1923
  if (!isAffordable(shell, bonus))
1459
1924
  return;
1460
- overlay.appendChild(buildConfirm(shell, bonus, overlay));
1461
- shell.fitModals();
1925
+ openConfirm(shell, bonus, overlay, st);
1462
1926
  };
1463
1927
  // Game-supplied card UI: the shell keeps the wrapper (grid sizing + accent vars) and runs the
1464
1928
  // buy flow when the game calls ctx.select(); the game owns everything inside.
@@ -1503,9 +1967,9 @@ function cardBody(shell, bonus) {
1503
1967
  }
1504
1968
  /** Confirmation modal — the shared card chrome (accent title heading, no ✕) with a bonus
1505
1969
  * preview body and a full-bleed Cancel + action footer. */
1506
- function buildConfirm(shell, bonus, overlay) {
1970
+ function buildConfirm(shell, bonus, overlay, st) {
1507
1971
  const accent = effectiveAccent(bonus);
1508
- const ui = createCardModal({ ge: 'bonus-confirm', title: bonus.title, accent, onClose: () => ui.root.remove() });
1972
+ const ui = createCardModal({ ge: 'bonus-confirm', title: bonus.title, accent, onClose: () => { closeConfirm(overlay, st); } });
1509
1973
  const price = bonus.priceMultiplier * shell.state.bet;
1510
1974
  const preview = document.createElement('div');
1511
1975
  preview.className = 'ge-confirm-preview';
@@ -1521,7 +1985,7 @@ function buildConfirm(shell, bonus, overlay) {
1521
1985
  cancel.className = 'ge-modal-btn ge-modal-btn--ghost';
1522
1986
  cancel.dataset.ge = 'bonus-confirm-cancel';
1523
1987
  cancel.textContent = shell.t('Cancel');
1524
- cancel.addEventListener('click', () => ui.root.remove());
1988
+ cancel.addEventListener('click', () => closeConfirm(overlay, st));
1525
1989
  const buy = document.createElement('button');
1526
1990
  buy.className = 'ge-modal-btn ge-modal-btn--accent';
1527
1991
  buy.dataset.ge = 'bonus-confirm-buy';
@@ -1536,8 +2000,7 @@ function buildConfirm(shell, bonus, overlay) {
1536
2000
  shell.activateFeature(bonus);
1537
2001
  else
1538
2002
  shell.emit('buyBonusSelect', { id: bonus.id });
1539
- ui.root.remove();
1540
- overlay.remove();
2003
+ shell.closeModal();
1541
2004
  });
1542
2005
  actions.append(cancel, buy);
1543
2006
  ui.card.appendChild(actions);
@@ -1566,36 +2029,79 @@ function isAffordable(shell, bonus) {
1566
2029
 
1567
2030
  /** A centred picker (chips grid + accent Confirm) on the shared card modal. */
1568
2031
  function buildSheet(opts) {
1569
- const ui = createCardModal({ ge: opts.ge, title: opts.title, onClose: () => ui.root.remove() });
2032
+ const ui = createCardModal({ ge: opts.ge, title: opts.title, onClose: () => opts.onClose() });
1570
2033
  const grid = document.createElement('div');
1571
2034
  grid.className = 'ge-sheet-grid';
1572
2035
  const cols = typeof opts.columns === 'number' ? { wide: opts.columns, mobile: opts.columns } : opts.columns;
1573
2036
  grid.style.setProperty('--cols', String(cols.wide));
1574
2037
  grid.style.setProperty('--cols-m', String(cols.mobile));
1575
2038
  let selected = opts.selected;
2039
+ let focusIndex = opts.choices.findIndex((c) => c.id === selected);
2040
+ if (focusIndex < 0)
2041
+ focusIndex = 0;
1576
2042
  const chips = [];
1577
- for (const c of opts.choices) {
2043
+ /** Update chip visuals to reflect the current selected/focused index. */
2044
+ function setHighlight(newIndex) {
2045
+ focusIndex = newIndex;
2046
+ selected = opts.choices[focusIndex].id;
2047
+ for (let i = 0; i < chips.length; i++) {
2048
+ chips[i].classList.toggle('ge-on', i === focusIndex);
2049
+ }
2050
+ }
2051
+ for (let i = 0; i < opts.choices.length; i++) {
2052
+ const c = opts.choices[i];
1578
2053
  const chip = document.createElement('button');
1579
- chip.className = 'ge-chip' + (c.id === selected ? ' ge-on' : '');
2054
+ chip.className = 'ge-chip' + (i === focusIndex ? ' ge-on' : '');
1580
2055
  chip.dataset.id = c.id;
1581
2056
  chip.textContent = c.label;
2057
+ const idx = i; // capture for closure
1582
2058
  chip.addEventListener('click', () => {
1583
- selected = c.id;
1584
- for (const x of chips)
1585
- x.classList.toggle('ge-on', x.dataset.id === selected);
2059
+ setHighlight(idx);
1586
2060
  });
1587
2061
  chips.push(chip);
1588
2062
  grid.appendChild(chip);
1589
2063
  }
1590
2064
  ui.body.appendChild(grid);
2065
+ function doConfirm() {
2066
+ opts.onConfirm(selected);
2067
+ opts.onClose();
2068
+ }
1591
2069
  // Single full-bleed Confirm; dismissal is the ✕ (top-right). No Cancel button.
1592
2070
  const confirm = document.createElement('button');
1593
2071
  confirm.className = 'ge-modal-btn ge-modal-btn--accent';
1594
2072
  confirm.dataset.ge = 'sheet-confirm';
1595
2073
  confirm.textContent = opts.confirmLabel;
1596
- confirm.addEventListener('click', () => { opts.onConfirm(selected); ui.root.remove(); });
2074
+ confirm.addEventListener('click', doConfirm);
1597
2075
  ui.card.appendChild(confirm);
1598
- return ui.root;
2076
+ function onKey(e) {
2077
+ const last = opts.choices.length - 1;
2078
+ switch (e.code) {
2079
+ case 'ArrowRight':
2080
+ case 'ArrowDown':
2081
+ case 'Equal': // + on most keyboards
2082
+ case 'NumpadAdd':
2083
+ if (focusIndex < last)
2084
+ setHighlight(focusIndex + 1);
2085
+ return true;
2086
+ case 'ArrowLeft':
2087
+ case 'ArrowUp':
2088
+ case 'Minus':
2089
+ case 'NumpadSubtract':
2090
+ if (focusIndex > 0)
2091
+ setHighlight(focusIndex - 1);
2092
+ return true;
2093
+ case 'Enter':
2094
+ case 'Space':
2095
+ doConfirm();
2096
+ return true;
2097
+ case 'Escape':
2098
+ opts.onClose();
2099
+ return true;
2100
+ default:
2101
+ return false;
2102
+ }
2103
+ }
2104
+ return { root: ui.root, onKey };
1599
2105
  }
1600
2106
  /** Bet picker — all available bets as chips (6 per row, 3 on mobile), accent Confirm applies it. */
1601
2107
  function openBetModal(shell) {
@@ -1603,6 +2109,7 @@ function openBetModal(shell) {
1603
2109
  ge: 'bet-modal', title: shell.t('Bet'), columns: { wide: 6, mobile: 3 }, confirmLabel: shell.t('Confirm'),
1604
2110
  choices: shell.state.availableBets.map((b) => ({ id: String(b), label: formatCurrency(b, shell.config.currency) })),
1605
2111
  selected: String(shell.state.bet),
2112
+ onClose: () => shell.closeModal(),
1606
2113
  onConfirm: (id) => {
1607
2114
  const v = Number(id);
1608
2115
  if (v !== shell.state.bet) {
@@ -1633,6 +2140,7 @@ function openAutoplayModal(shell) {
1633
2140
  ge: 'autoplay-modal', title: shell.t('Autoplay'), columns: 3, confirmLabel: shell.t('Start'),
1634
2141
  choices: counts.map((n) => ({ id: String(n), label: Number.isFinite(n) ? String(n) : '∞' })),
1635
2142
  selected: String(shell.state.autoplay.remaining || counts[0]),
2143
+ onClose: () => shell.closeModal(),
1636
2144
  onConfirm: (id) => {
1637
2145
  let remaining = Number(id); // "Infinity" → Infinity
1638
2146
  if (maxCount != null)
@@ -1780,6 +2288,868 @@ function countUp(el, from, to, fmt, durationMs = 450) {
1780
2288
  };
1781
2289
  }
1782
2290
 
2291
+ // MACHINE-TRANSLATED — native QA required before production use.
2292
+ // Keys are the English source strings exactly as they appear at t('…') call sites.
2293
+ // Entries within each language block are sorted alphabetically by key for diff stability.
2294
+ // 'en' is omitted: the resolver returns the source string unchanged for English.
2295
+ const LOCALES = {
2296
+ da: {
2297
+ DISCLAIMER: 'Ansvarsfraskrivelse',
2298
+ Activate: 'Aktivér',
2299
+ Autoplay: 'Autoplay',
2300
+ Balance: 'Saldo',
2301
+ Bet: 'Indsats',
2302
+ 'BUY BONUS': 'KØB BONUS',
2303
+ Buy: 'Køb',
2304
+ 'Buy bonus': 'Køb bonus',
2305
+ Cancel: 'Annuller',
2306
+ Close: 'Luk',
2307
+ 'Cluster pays': 'Klyngeudbetaling',
2308
+ Confirm: 'Bekræft',
2309
+ Controls: 'Kontroller',
2310
+ 'Decrease your stake.': 'Reducer din indsats.',
2311
+ DISABLE: 'DEAKTIVER',
2312
+ 'Dismiss the current overlay.': 'Luk det aktuelle overlay.',
2313
+ 'Free spins': 'Gratisspins',
2314
+ Game: 'Spil',
2315
+ 'Game info': 'Spilinfo',
2316
+ Hotkeys: 'Tastaturgenveje',
2317
+ 'Increase your stake.': 'Forøg din indsats.',
2318
+ 'Lower bet': 'Lavere indsats',
2319
+ 'Master volume': 'Hovedvolumen',
2320
+ 'Max win': 'Maks gevinst',
2321
+ Menu: 'Menu',
2322
+ 'Menu & info': 'Menu og info',
2323
+ Modes: 'Tilstande',
2324
+ Music: 'Musik',
2325
+ Mute: 'Lydløs',
2326
+ 'Mute or unmute the game.': 'Slå lyden til/fra.',
2327
+ Navigate: 'Naviger',
2328
+ 'Open settings and game info.': 'Åbn indstillinger og spilinfo.',
2329
+ 'Open the paytable and rules.': 'Åbn gevinsttabel og regler.',
2330
+ 'Pay a fixed cost to enter a bonus feature.': 'Betal en fast pris for at aktivere en bonusfunktion.',
2331
+ Paylines: 'Gevinstlinjer',
2332
+ Paytable: 'Gevinsttabel',
2333
+ 'Pays anywhere': 'Udbetaler overalt',
2334
+ Price: 'Pris',
2335
+ 'Raise bet': 'Hæv indsats',
2336
+ Replay: 'Genspil',
2337
+ RTP: 'RTP',
2338
+ SFX: 'Lydeffekter',
2339
+ Settings: 'Indstillinger',
2340
+ Sound: 'Lyd',
2341
+ Spin: 'Drej',
2342
+ 'Spin automatically a set number of times.': 'Spin automatisk et bestemt antal gange.',
2343
+ 'Speed up spin animations.': 'Gør spilanimationer hurtigere.',
2344
+ Start: 'Start',
2345
+ 'Start a spin at the current bet.': 'Start et spin med den aktuelle indsats.',
2346
+ 'Start replay': 'Start genspil',
2347
+ 'Total win': 'Samlet gevinst',
2348
+ Turbo: 'Turbo',
2349
+ 'Ways to win': 'Vindermuligheder',
2350
+ Win: 'Gevinst',
2351
+ 'Winning shapes': 'Vindende former',
2352
+ },
2353
+ de: {
2354
+ DISCLAIMER: 'Haftungsausschluss',
2355
+ Activate: 'Aktivieren',
2356
+ Autoplay: 'Autoplay',
2357
+ Balance: 'Kontostand',
2358
+ Bet: 'Einsatz',
2359
+ 'BUY BONUS': 'BONUS KAUFEN',
2360
+ Buy: 'Kaufen',
2361
+ 'Buy bonus': 'Bonus kaufen',
2362
+ Cancel: 'Abbrechen',
2363
+ Close: 'Schließen',
2364
+ 'Cluster pays': 'Cluster-Gewinne',
2365
+ Confirm: 'Bestätigen',
2366
+ Controls: 'Steuerung',
2367
+ 'Decrease your stake.': 'Einsatz verringern.',
2368
+ DISABLE: 'DEAKTIVIEREN',
2369
+ 'Dismiss the current overlay.': 'Aktuelles Overlay schließen.',
2370
+ 'Free spins': 'Freispiele',
2371
+ Game: 'Spiel',
2372
+ 'Game info': 'Spielinfo',
2373
+ Hotkeys: 'Tastenkürzel',
2374
+ 'Increase your stake.': 'Einsatz erhöhen.',
2375
+ 'Lower bet': 'Einsatz senken',
2376
+ 'Master volume': 'Gesamtlautstärke',
2377
+ 'Max win': 'Max. Gewinn',
2378
+ Menu: 'Menü',
2379
+ 'Menu & info': 'Menü & Info',
2380
+ Modes: 'Modi',
2381
+ Music: 'Musik',
2382
+ Mute: 'Stummschalten',
2383
+ 'Mute or unmute the game.': 'Ton stummschalten oder aktivieren.',
2384
+ Navigate: 'Navigieren',
2385
+ 'Open settings and game info.': 'Einstellungen und Spielinfo öffnen.',
2386
+ 'Open the paytable and rules.': 'Gewinntabelle und Regeln öffnen.',
2387
+ 'Pay a fixed cost to enter a bonus feature.': 'Zahle einen festen Betrag, um ein Bonus-Feature zu aktivieren.',
2388
+ Paylines: 'Gewinnlinien',
2389
+ Paytable: 'Gewinntabelle',
2390
+ 'Pays anywhere': 'Zahlt überall',
2391
+ Price: 'Preis',
2392
+ 'Raise bet': 'Einsatz erhöhen',
2393
+ Replay: 'Wiederholen',
2394
+ RTP: 'RTP',
2395
+ SFX: 'Soundeffekte',
2396
+ Settings: 'Einstellungen',
2397
+ Sound: 'Ton',
2398
+ Spin: 'Drehen',
2399
+ 'Spin automatically a set number of times.': 'Automatisch eine festgelegte Anzahl von Runden spielen.',
2400
+ 'Speed up spin animations.': 'Animationen beschleunigen.',
2401
+ Start: 'Start',
2402
+ 'Start a spin at the current bet.': 'Mit dem aktuellen Einsatz drehen.',
2403
+ 'Start replay': 'Wiederholen',
2404
+ 'Total win': 'Gesamtgewinn',
2405
+ Turbo: 'Turbo',
2406
+ 'Ways to win': 'Gewinnwege',
2407
+ Win: 'Gewinn',
2408
+ 'Winning shapes': 'Gewinnmuster',
2409
+ },
2410
+ es: {
2411
+ DISCLAIMER: 'Aviso legal',
2412
+ Activate: 'Activar',
2413
+ Autoplay: 'Giro automático',
2414
+ Balance: 'Saldo',
2415
+ Bet: 'Apuesta',
2416
+ 'BUY BONUS': 'COMPRAR BONO',
2417
+ Buy: 'Comprar',
2418
+ 'Buy bonus': 'Comprar bono',
2419
+ Cancel: 'Cancelar',
2420
+ Close: 'Cerrar',
2421
+ 'Cluster pays': 'Pago en racimo',
2422
+ Confirm: 'Confirmar',
2423
+ Controls: 'Controles',
2424
+ 'Decrease your stake.': 'Reducir apuesta.',
2425
+ DISABLE: 'DESACTIVAR',
2426
+ 'Dismiss the current overlay.': 'Cerrar el panel actual.',
2427
+ 'Free spins': 'Giros gratis',
2428
+ Game: 'Juego',
2429
+ 'Game info': 'Info del juego',
2430
+ Hotkeys: 'Atajos de teclado',
2431
+ 'Increase your stake.': 'Aumentar apuesta.',
2432
+ 'Lower bet': 'Bajar apuesta',
2433
+ 'Master volume': 'Volumen principal',
2434
+ 'Max win': 'Ganancia máxima',
2435
+ Menu: 'Menú',
2436
+ 'Menu & info': 'Menú e info',
2437
+ Modes: 'Modos',
2438
+ Music: 'Música',
2439
+ Mute: 'Silenciar',
2440
+ 'Mute or unmute the game.': 'Activar o silenciar el sonido.',
2441
+ Navigate: 'Navegar',
2442
+ 'Open settings and game info.': 'Abrir ajustes e info del juego.',
2443
+ 'Open the paytable and rules.': 'Abrir tabla de pagos y reglas.',
2444
+ 'Pay a fixed cost to enter a bonus feature.': 'Paga un coste fijo para acceder a una función de bono.',
2445
+ Paylines: 'Líneas de pago',
2446
+ Paytable: 'Tabla de pagos',
2447
+ 'Pays anywhere': 'Paga en cualquier lugar',
2448
+ Price: 'Precio',
2449
+ 'Raise bet': 'Subir apuesta',
2450
+ Replay: 'Repetir',
2451
+ RTP: 'RTP',
2452
+ SFX: 'Efectos de sonido',
2453
+ Settings: 'Ajustes',
2454
+ Sound: 'Sonido',
2455
+ Spin: 'Girar',
2456
+ 'Spin automatically a set number of times.': 'Girar automáticamente un número determinado de veces.',
2457
+ 'Speed up spin animations.': 'Acelerar las animaciones de giro.',
2458
+ Start: 'Iniciar',
2459
+ 'Start a spin at the current bet.': 'Iniciar un giro con la apuesta actual.',
2460
+ 'Start replay': 'Iniciar repetición',
2461
+ 'Total win': 'Ganancia total',
2462
+ Turbo: 'Turbo',
2463
+ 'Ways to win': 'Formas de ganar',
2464
+ Win: 'Premio',
2465
+ 'Winning shapes': 'Figuras ganadoras',
2466
+ },
2467
+ fi: {
2468
+ DISCLAIMER: 'Vastuuvapauslauseke',
2469
+ Activate: 'Aktivoi',
2470
+ Autoplay: 'Automaattipeli',
2471
+ Balance: 'Saldo',
2472
+ Bet: 'Panos',
2473
+ 'BUY BONUS': 'OSTA BONUS',
2474
+ Buy: 'Osta',
2475
+ 'Buy bonus': 'Osta bonus',
2476
+ Cancel: 'Peruuta',
2477
+ Close: 'Sulje',
2478
+ 'Cluster pays': 'Ryhmävoitto',
2479
+ Confirm: 'Vahvista',
2480
+ Controls: 'Ohjaimet',
2481
+ 'Decrease your stake.': 'Pienennä panostasi.',
2482
+ DISABLE: 'POISTA KÄYTÖSTÄ',
2483
+ 'Dismiss the current overlay.': 'Sulje nykyinen näkymä.',
2484
+ 'Free spins': 'Ilmaiskierrokset',
2485
+ Game: 'Peli',
2486
+ 'Game info': 'Pelitiedot',
2487
+ Hotkeys: 'Pikanäppäimet',
2488
+ 'Increase your stake.': 'Kasvata panostasi.',
2489
+ 'Lower bet': 'Pienennä panosta',
2490
+ 'Master volume': 'Pääänenvoimakkuus',
2491
+ 'Max win': 'Maksimivoitto',
2492
+ Menu: 'Valikko',
2493
+ 'Menu & info': 'Valikko ja tiedot',
2494
+ Modes: 'Tilat',
2495
+ Music: 'Musiikki',
2496
+ Mute: 'Mykistä',
2497
+ 'Mute or unmute the game.': 'Mykistä tai poista mykistys.',
2498
+ Navigate: 'Navigoi',
2499
+ 'Open settings and game info.': 'Avaa asetukset ja pelitiedot.',
2500
+ 'Open the paytable and rules.': 'Avaa voittotaulukko ja säännöt.',
2501
+ 'Pay a fixed cost to enter a bonus feature.': 'Maksa kiinteä summa päästäksesi bonusominaisuuteen.',
2502
+ Paylines: 'Voittolinjat',
2503
+ Paytable: 'Voittotaulukko',
2504
+ 'Pays anywhere': 'Voittaa missä tahansa',
2505
+ Price: 'Hinta',
2506
+ 'Raise bet': 'Nosta panosta',
2507
+ Replay: 'Toista uudelleen',
2508
+ RTP: 'RTP',
2509
+ SFX: 'Ääniefektit',
2510
+ Settings: 'Asetukset',
2511
+ Sound: 'Ääni',
2512
+ Spin: 'Pyöräytä',
2513
+ 'Spin automatically a set number of times.': 'Pyöräytä automaattisesti määritetty määrä kertoja.',
2514
+ 'Speed up spin animations.': 'Nopeuta pyöräytysanimaatioita.',
2515
+ Start: 'Aloita',
2516
+ 'Start a spin at the current bet.': 'Aloita pyöräytys nykyisellä panoksella.',
2517
+ 'Start replay': 'Aloita uudelleentoisto',
2518
+ 'Total win': 'Kokonaisvoitto',
2519
+ Turbo: 'Turbo',
2520
+ 'Ways to win': 'Voittotavat',
2521
+ Win: 'Voitto',
2522
+ 'Winning shapes': 'Voittokuviot',
2523
+ },
2524
+ fr: {
2525
+ DISCLAIMER: 'Avertissement',
2526
+ Activate: 'Activer',
2527
+ Autoplay: 'Jeu automatique',
2528
+ Balance: 'Solde',
2529
+ Bet: 'Mise',
2530
+ 'BUY BONUS': 'ACHETER BONUS',
2531
+ Buy: 'Acheter',
2532
+ 'Buy bonus': 'Acheter un bonus',
2533
+ Cancel: 'Annuler',
2534
+ Close: 'Fermer',
2535
+ 'Cluster pays': 'Gains en grappe',
2536
+ Confirm: 'Confirmer',
2537
+ Controls: 'Commandes',
2538
+ 'Decrease your stake.': 'Diminuer votre mise.',
2539
+ DISABLE: 'DÉSACTIVER',
2540
+ 'Dismiss the current overlay.': 'Fermer le panneau actuel.',
2541
+ 'Free spins': 'Tours gratuits',
2542
+ Game: 'Jeu',
2543
+ 'Game info': 'Infos du jeu',
2544
+ Hotkeys: 'Raccourcis clavier',
2545
+ 'Increase your stake.': 'Augmenter votre mise.',
2546
+ 'Lower bet': 'Baisser la mise',
2547
+ 'Master volume': 'Volume principal',
2548
+ 'Max win': 'Gain maximum',
2549
+ Menu: 'Menu',
2550
+ 'Menu & info': 'Menu et info',
2551
+ Modes: 'Modes',
2552
+ Music: 'Musique',
2553
+ Mute: 'Couper le son',
2554
+ 'Mute or unmute the game.': 'Couper ou rétablir le son.',
2555
+ Navigate: 'Naviguer',
2556
+ 'Open settings and game info.': 'Ouvrir les paramètres et infos du jeu.',
2557
+ 'Open the paytable and rules.': 'Ouvrir la table des gains et les règles.',
2558
+ 'Pay a fixed cost to enter a bonus feature.': 'Payez un coût fixe pour accéder à une fonctionnalité bonus.',
2559
+ Paylines: 'Lignes de paiement',
2560
+ Paytable: 'Table des gains',
2561
+ 'Pays anywhere': 'Gains sur toute la grille',
2562
+ Price: 'Prix',
2563
+ 'Raise bet': 'Augmenter la mise',
2564
+ Replay: 'Revoir',
2565
+ RTP: 'RTP',
2566
+ SFX: 'Effets sonores',
2567
+ Settings: 'Paramètres',
2568
+ Sound: 'Son',
2569
+ Spin: 'Tourner',
2570
+ 'Spin automatically a set number of times.': 'Tourner automatiquement un nombre de fois défini.',
2571
+ 'Speed up spin animations.': 'Accélérer les animations de spin.',
2572
+ Start: 'Lancer',
2573
+ 'Start a spin at the current bet.': 'Lancer un tour avec la mise actuelle.',
2574
+ 'Start replay': 'Lancer le replay',
2575
+ 'Total win': 'Gain total',
2576
+ Turbo: 'Turbo',
2577
+ 'Ways to win': 'Façons de gagner',
2578
+ Win: 'Gain',
2579
+ 'Winning shapes': 'Figures gagnantes',
2580
+ },
2581
+ hi: {
2582
+ DISCLAIMER: 'अस्वीकरण',
2583
+ Activate: 'सक्रिय करें',
2584
+ Autoplay: 'ऑटोप्ले',
2585
+ Balance: 'बैलेंस',
2586
+ Bet: 'दांव',
2587
+ 'BUY BONUS': 'बोनस खरीदें',
2588
+ Buy: 'खरीदें',
2589
+ 'Buy bonus': 'बोनस खरीदें',
2590
+ Cancel: 'रद्द करें',
2591
+ Close: 'बंद करें',
2592
+ 'Cluster pays': 'क्लस्टर भुगतान',
2593
+ Confirm: 'पुष्टि करें',
2594
+ Controls: 'नियंत्रण',
2595
+ 'Decrease your stake.': 'अपना दांव कम करें।',
2596
+ DISABLE: 'बंद करें',
2597
+ 'Dismiss the current overlay.': 'वर्तमान ओवरले बंद करें।',
2598
+ 'Free spins': 'फ्री स्पिन',
2599
+ Game: 'खेल',
2600
+ 'Game info': 'गेम जानकारी',
2601
+ Hotkeys: 'कीबोर्ड शॉर्टकट',
2602
+ 'Increase your stake.': 'अपना दांव बढ़ाएं।',
2603
+ 'Lower bet': 'दांव घटाएं',
2604
+ 'Master volume': 'मुख्य वॉल्यूम',
2605
+ 'Max win': 'अधिकतम जीत',
2606
+ Menu: 'मेनू',
2607
+ 'Menu & info': 'मेनू और जानकारी',
2608
+ Modes: 'मोड',
2609
+ Music: 'संगीत',
2610
+ Mute: 'म्यूट करें',
2611
+ 'Mute or unmute the game.': 'गेम को म्यूट या अनम्यूट करें।',
2612
+ Navigate: 'नेविगेट करें',
2613
+ 'Open settings and game info.': 'सेटिंग और गेम जानकारी खोलें।',
2614
+ 'Open the paytable and rules.': 'पेटेबल और नियम खोलें।',
2615
+ 'Pay a fixed cost to enter a bonus feature.': 'बोनस फीचर में प्रवेश के लिए एक निश्चित राशि दें।',
2616
+ Paylines: 'पेलाइन',
2617
+ Paytable: 'पेटेबल',
2618
+ 'Pays anywhere': 'कहीं भी जीत',
2619
+ Price: 'मूल्य',
2620
+ 'Raise bet': 'दांव बढ़ाएं',
2621
+ Replay: 'दोबारा खेलें',
2622
+ RTP: 'RTP',
2623
+ SFX: 'ध्वनि प्रभाव',
2624
+ Settings: 'सेटिंग',
2625
+ Sound: 'ध्वनि',
2626
+ Spin: 'स्पिन',
2627
+ 'Spin automatically a set number of times.': 'एक निश्चित संख्या में स्वचालित रूप से स्पिन करें।',
2628
+ 'Speed up spin animations.': 'स्पिन एनिमेशन को तेज़ करें।',
2629
+ Start: 'शुरू करें',
2630
+ 'Start a spin at the current bet.': 'वर्तमान दांव पर स्पिन शुरू करें।',
2631
+ 'Start replay': 'रीप्ले शुरू करें',
2632
+ 'Total win': 'कुल जीत',
2633
+ Turbo: 'टर्बो',
2634
+ 'Ways to win': 'जीत के तरीके',
2635
+ Win: 'जीत',
2636
+ 'Winning shapes': 'जीत के आकार',
2637
+ },
2638
+ id: {
2639
+ DISCLAIMER: 'Penafian',
2640
+ Activate: 'Aktifkan',
2641
+ Autoplay: 'Putar Otomatis',
2642
+ Balance: 'Saldo',
2643
+ Bet: 'Taruhan',
2644
+ 'BUY BONUS': 'BELI BONUS',
2645
+ Buy: 'Beli',
2646
+ 'Buy bonus': 'Beli bonus',
2647
+ Cancel: 'Batal',
2648
+ Close: 'Tutup',
2649
+ 'Cluster pays': 'Bayar kluster',
2650
+ Confirm: 'Konfirmasi',
2651
+ Controls: 'Kontrol',
2652
+ 'Decrease your stake.': 'Kurangi taruhan Anda.',
2653
+ DISABLE: 'NONAKTIFKAN',
2654
+ 'Dismiss the current overlay.': 'Tutup overlay saat ini.',
2655
+ 'Free spins': 'Putaran gratis',
2656
+ Game: 'Permainan',
2657
+ 'Game info': 'Info permainan',
2658
+ Hotkeys: 'Pintasan keyboard',
2659
+ 'Increase your stake.': 'Tingkatkan taruhan Anda.',
2660
+ 'Lower bet': 'Turunkan taruhan',
2661
+ 'Master volume': 'Volume utama',
2662
+ 'Max win': 'Kemenangan maks',
2663
+ Menu: 'Menu',
2664
+ 'Menu & info': 'Menu & info',
2665
+ Modes: 'Mode',
2666
+ Music: 'Musik',
2667
+ Mute: 'Bisukan',
2668
+ 'Mute or unmute the game.': 'Bisukan atau aktifkan suara permainan.',
2669
+ Navigate: 'Navigasi',
2670
+ 'Open settings and game info.': 'Buka pengaturan dan info permainan.',
2671
+ 'Open the paytable and rules.': 'Buka tabel pembayaran dan aturan.',
2672
+ 'Pay a fixed cost to enter a bonus feature.': 'Bayar biaya tetap untuk masuk ke fitur bonus.',
2673
+ Paylines: 'Garis pembayaran',
2674
+ Paytable: 'Tabel pembayaran',
2675
+ 'Pays anywhere': 'Menang di mana saja',
2676
+ Price: 'Harga',
2677
+ 'Raise bet': 'Naikkan taruhan',
2678
+ Replay: 'Putar ulang',
2679
+ RTP: 'RTP',
2680
+ SFX: 'Efek suara',
2681
+ Settings: 'Pengaturan',
2682
+ Sound: 'Suara',
2683
+ Spin: 'Putar',
2684
+ 'Spin automatically a set number of times.': 'Putar otomatis sejumlah kali yang ditentukan.',
2685
+ 'Speed up spin animations.': 'Percepat animasi putaran.',
2686
+ Start: 'Mulai',
2687
+ 'Start a spin at the current bet.': 'Mulai putaran dengan taruhan saat ini.',
2688
+ 'Start replay': 'Mulai putar ulang',
2689
+ 'Total win': 'Total kemenangan',
2690
+ Turbo: 'Turbo',
2691
+ 'Ways to win': 'Cara menang',
2692
+ Win: 'Menang',
2693
+ 'Winning shapes': 'Bentuk kemenangan',
2694
+ },
2695
+ ja: {
2696
+ DISCLAIMER: '免責事項',
2697
+ Activate: '有効化',
2698
+ Autoplay: 'オートプレイ',
2699
+ Balance: '残高',
2700
+ Bet: 'ベット',
2701
+ 'BUY BONUS': 'ボーナス購入',
2702
+ Buy: '購入',
2703
+ 'Buy bonus': 'ボーナス購入',
2704
+ Cancel: 'キャンセル',
2705
+ Close: '閉じる',
2706
+ 'Cluster pays': 'クラスター配当',
2707
+ Confirm: '確認',
2708
+ Controls: '操作方法',
2709
+ 'Decrease your stake.': 'ベットを減らす。',
2710
+ DISABLE: '無効',
2711
+ 'Dismiss the current overlay.': '現在のオーバーレイを閉じる。',
2712
+ 'Free spins': 'フリースピン',
2713
+ Game: 'ゲーム',
2714
+ 'Game info': 'ゲーム情報',
2715
+ Hotkeys: 'キーボードショートカット',
2716
+ 'Increase your stake.': 'ベットを増やす。',
2717
+ 'Lower bet': 'ベットを下げる',
2718
+ 'Master volume': 'マスターボリューム',
2719
+ 'Max win': '最大当選',
2720
+ Menu: 'メニュー',
2721
+ 'Menu & info': 'メニューと情報',
2722
+ Modes: 'モード',
2723
+ Music: 'ミュージック',
2724
+ Mute: 'ミュート',
2725
+ 'Mute or unmute the game.': 'ゲームをミュート/ミュート解除する。',
2726
+ Navigate: 'ナビゲート',
2727
+ 'Open settings and game info.': '設定とゲーム情報を開く。',
2728
+ 'Open the paytable and rules.': '配当表とルールを開く。',
2729
+ 'Pay a fixed cost to enter a bonus feature.': 'ボーナス機能に入るために固定料金を支払う。',
2730
+ Paylines: 'ペイライン',
2731
+ Paytable: '配当表',
2732
+ 'Pays anywhere': 'どこでも当選',
2733
+ Price: '価格',
2734
+ 'Raise bet': 'ベットを上げる',
2735
+ Replay: 'リプレイ',
2736
+ RTP: 'RTP',
2737
+ SFX: '効果音',
2738
+ Settings: '設定',
2739
+ Sound: 'サウンド',
2740
+ Spin: 'スピン',
2741
+ 'Spin automatically a set number of times.': '設定した回数だけ自動でスピンする。',
2742
+ 'Speed up spin animations.': 'スピンアニメーションを高速化する。',
2743
+ Start: 'スタート',
2744
+ 'Start a spin at the current bet.': '現在のベットでスピンを開始する。',
2745
+ 'Start replay': 'リプレイ開始',
2746
+ 'Total win': '合計当選',
2747
+ Turbo: 'ターボ',
2748
+ 'Ways to win': '当選方法',
2749
+ Win: '当選',
2750
+ 'Winning shapes': '当選形状',
2751
+ },
2752
+ ko: {
2753
+ DISCLAIMER: '면책 조항',
2754
+ Activate: '활성화',
2755
+ Autoplay: '자동 플레이',
2756
+ Balance: '잔액',
2757
+ Bet: '베팅',
2758
+ 'BUY BONUS': '보너스 구매',
2759
+ Buy: '구매',
2760
+ 'Buy bonus': '보너스 구매',
2761
+ Cancel: '취소',
2762
+ Close: '닫기',
2763
+ 'Cluster pays': '클러스터 페이',
2764
+ Confirm: '확인',
2765
+ Controls: '조작법',
2766
+ 'Decrease your stake.': '베팅을 줄이세요.',
2767
+ DISABLE: '비활성화',
2768
+ 'Dismiss the current overlay.': '현재 오버레이를 닫습니다.',
2769
+ 'Free spins': '무료 스핀',
2770
+ Game: '게임',
2771
+ 'Game info': '게임 정보',
2772
+ Hotkeys: '키보드 단축키',
2773
+ 'Increase your stake.': '베팅을 늘리세요.',
2774
+ 'Lower bet': '베팅 낮추기',
2775
+ 'Master volume': '마스터 볼륨',
2776
+ 'Max win': '최대 당첨',
2777
+ Menu: '메뉴',
2778
+ 'Menu & info': '메뉴 & 정보',
2779
+ Modes: '모드',
2780
+ Music: '음악',
2781
+ Mute: '음소거',
2782
+ 'Mute or unmute the game.': '게임 소리를 켜거나 끕니다.',
2783
+ Navigate: '이동',
2784
+ 'Open settings and game info.': '설정 및 게임 정보를 엽니다.',
2785
+ 'Open the paytable and rules.': '페이테이블 및 규칙을 엽니다.',
2786
+ 'Pay a fixed cost to enter a bonus feature.': '고정 비용을 지불하고 보너스 기능에 진입하세요.',
2787
+ Paylines: '페이라인',
2788
+ Paytable: '페이테이블',
2789
+ 'Pays anywhere': '어디서나 당첨',
2790
+ Price: '가격',
2791
+ 'Raise bet': '베팅 올리기',
2792
+ Replay: '다시보기',
2793
+ RTP: 'RTP',
2794
+ SFX: '효과음',
2795
+ Settings: '설정',
2796
+ Sound: '사운드',
2797
+ Spin: '스핀',
2798
+ 'Spin automatically a set number of times.': '정해진 횟수만큼 자동으로 스핀합니다.',
2799
+ 'Speed up spin animations.': '스핀 애니메이션을 빠르게 합니다.',
2800
+ Start: '시작',
2801
+ 'Start a spin at the current bet.': '현재 베팅으로 스핀을 시작합니다.',
2802
+ 'Start replay': '다시보기 시작',
2803
+ 'Total win': '총 당첨',
2804
+ Turbo: '터보',
2805
+ 'Ways to win': '당첨 방법',
2806
+ Win: '당첨',
2807
+ 'Winning shapes': '당첨 패턴',
2808
+ },
2809
+ pl: {
2810
+ DISCLAIMER: 'Zastrzeżenie',
2811
+ Activate: 'Aktywuj',
2812
+ Autoplay: 'Autoplay',
2813
+ Balance: 'Saldo',
2814
+ Bet: 'Zakład',
2815
+ 'BUY BONUS': 'KUP BONUS',
2816
+ Buy: 'Kup',
2817
+ 'Buy bonus': 'Kup bonus',
2818
+ Cancel: 'Anuluj',
2819
+ Close: 'Zamknij',
2820
+ 'Cluster pays': 'Wypłaty klastrowe',
2821
+ Confirm: 'Potwierdź',
2822
+ Controls: 'Sterowanie',
2823
+ 'Decrease your stake.': 'Zmniejsz swój zakład.',
2824
+ DISABLE: 'WYŁĄCZ',
2825
+ 'Dismiss the current overlay.': 'Zamknij bieżące okno.',
2826
+ 'Free spins': 'Darmowe spiny',
2827
+ Game: 'Gra',
2828
+ 'Game info': 'Informacje o grze',
2829
+ Hotkeys: 'Skróty klawiaturowe',
2830
+ 'Increase your stake.': 'Zwiększ swój zakład.',
2831
+ 'Lower bet': 'Obniż zakład',
2832
+ 'Master volume': 'Głośność główna',
2833
+ 'Max win': 'Maks. wygrana',
2834
+ Menu: 'Menu',
2835
+ 'Menu & info': 'Menu i informacje',
2836
+ Modes: 'Tryby',
2837
+ Music: 'Muzyka',
2838
+ Mute: 'Wycisz',
2839
+ 'Mute or unmute the game.': 'Wycisz lub odcisz dźwięk gry.',
2840
+ Navigate: 'Nawiguj',
2841
+ 'Open settings and game info.': 'Otwórz ustawienia i informacje o grze.',
2842
+ 'Open the paytable and rules.': 'Otwórz tabelę wygranych i zasady.',
2843
+ 'Pay a fixed cost to enter a bonus feature.': 'Zapłać stałą kwotę, aby wejść do funkcji bonusowej.',
2844
+ Paylines: 'Linie wygrywające',
2845
+ Paytable: 'Tabela wygranych',
2846
+ 'Pays anywhere': 'Wypłaca wszędzie',
2847
+ Price: 'Cena',
2848
+ 'Raise bet': 'Podnieś zakład',
2849
+ Replay: 'Odtwórz ponownie',
2850
+ RTP: 'RTP',
2851
+ SFX: 'Efekty dźwiękowe',
2852
+ Settings: 'Ustawienia',
2853
+ Sound: 'Dźwięk',
2854
+ Spin: 'Zakręć',
2855
+ 'Spin automatically a set number of times.': 'Obracaj automatycznie określoną liczbę razy.',
2856
+ 'Speed up spin animations.': 'Przyspiesz animacje obrotów.',
2857
+ Start: 'Start',
2858
+ 'Start a spin at the current bet.': 'Rozpocznij obrót przy bieżącym zakładzie.',
2859
+ 'Start replay': 'Rozpocznij odtwarzanie',
2860
+ 'Total win': 'Łączna wygrana',
2861
+ Turbo: 'Turbo',
2862
+ 'Ways to win': 'Sposoby wygrywania',
2863
+ Win: 'Wygrana',
2864
+ 'Winning shapes': 'Wzory wygrywające',
2865
+ },
2866
+ pt: {
2867
+ DISCLAIMER: 'Aviso legal',
2868
+ Activate: 'Ativar',
2869
+ Autoplay: 'Giro automático',
2870
+ Balance: 'Saldo',
2871
+ Bet: 'Aposta',
2872
+ 'BUY BONUS': 'COMPRAR BÔNUS',
2873
+ Buy: 'Comprar',
2874
+ 'Buy bonus': 'Comprar bônus',
2875
+ Cancel: 'Cancelar',
2876
+ Close: 'Fechar',
2877
+ 'Cluster pays': 'Pagamento em cluster',
2878
+ Confirm: 'Confirmar',
2879
+ Controls: 'Controles',
2880
+ 'Decrease your stake.': 'Diminuir aposta.',
2881
+ DISABLE: 'DESATIVAR',
2882
+ 'Dismiss the current overlay.': 'Fechar o painel atual.',
2883
+ 'Free spins': 'Giros grátis',
2884
+ Game: 'Jogo',
2885
+ 'Game info': 'Info do jogo',
2886
+ Hotkeys: 'Atalhos de teclado',
2887
+ 'Increase your stake.': 'Aumentar aposta.',
2888
+ 'Lower bet': 'Baixar aposta',
2889
+ 'Master volume': 'Volume principal',
2890
+ 'Max win': 'Ganho máximo',
2891
+ Menu: 'Menu',
2892
+ 'Menu & info': 'Menu e info',
2893
+ Modes: 'Modos',
2894
+ Music: 'Música',
2895
+ Mute: 'Silenciar',
2896
+ 'Mute or unmute the game.': 'Ativar ou silenciar o som do jogo.',
2897
+ Navigate: 'Navegar',
2898
+ 'Open settings and game info.': 'Abrir configurações e info do jogo.',
2899
+ 'Open the paytable and rules.': 'Abrir tabela de pagamentos e regras.',
2900
+ 'Pay a fixed cost to enter a bonus feature.': 'Pague um custo fixo para entrar numa funcionalidade de bônus.',
2901
+ Paylines: 'Linhas de pagamento',
2902
+ Paytable: 'Tabela de pagamentos',
2903
+ 'Pays anywhere': 'Paga em qualquer posição',
2904
+ Price: 'Preço',
2905
+ 'Raise bet': 'Aumentar aposta',
2906
+ Replay: 'Repetir',
2907
+ RTP: 'RTP',
2908
+ SFX: 'Efeitos sonoros',
2909
+ Settings: 'Configurações',
2910
+ Sound: 'Som',
2911
+ Spin: 'Girar',
2912
+ 'Spin automatically a set number of times.': 'Girar automaticamente um número definido de vezes.',
2913
+ 'Speed up spin animations.': 'Acelerar as animações de giro.',
2914
+ Start: 'Iniciar',
2915
+ 'Start a spin at the current bet.': 'Iniciar um giro com a aposta atual.',
2916
+ 'Start replay': 'Iniciar repetição',
2917
+ 'Total win': 'Ganho total',
2918
+ Turbo: 'Turbo',
2919
+ 'Ways to win': 'Formas de ganhar',
2920
+ Win: 'Ganho',
2921
+ 'Winning shapes': 'Figuras vencedoras',
2922
+ },
2923
+ ru: {
2924
+ DISCLAIMER: 'Отказ от ответственности',
2925
+ Activate: 'Активировать',
2926
+ Autoplay: 'Автоигра',
2927
+ Balance: 'Баланс',
2928
+ Bet: 'Ставка',
2929
+ 'BUY BONUS': 'КУПИТЬ БОНУС',
2930
+ Buy: 'Купить',
2931
+ 'Buy bonus': 'Купить бонус',
2932
+ Cancel: 'Отмена',
2933
+ Close: 'Закрыть',
2934
+ 'Cluster pays': 'Кластерные выплаты',
2935
+ Confirm: 'Подтвердить',
2936
+ Controls: 'Управление',
2937
+ 'Decrease your stake.': 'Уменьшить ставку.',
2938
+ DISABLE: 'ОТКЛЮЧИТЬ',
2939
+ 'Dismiss the current overlay.': 'Закрыть текущее окно.',
2940
+ 'Free spins': 'Бесплатные вращения',
2941
+ Game: 'Игра',
2942
+ 'Game info': 'Информация об игре',
2943
+ Hotkeys: 'Горячие клавиши',
2944
+ 'Increase your stake.': 'Увеличить ставку.',
2945
+ 'Lower bet': 'Уменьшить ставку',
2946
+ 'Master volume': 'Общая громкость',
2947
+ 'Max win': 'Макс. выигрыш',
2948
+ Menu: 'Меню',
2949
+ 'Menu & info': 'Меню и информация',
2950
+ Modes: 'Режимы',
2951
+ Music: 'Музыка',
2952
+ Mute: 'Отключить звук',
2953
+ 'Mute or unmute the game.': 'Включить или отключить звук.',
2954
+ Navigate: 'Навигация',
2955
+ 'Open settings and game info.': 'Открыть настройки и информацию об игре.',
2956
+ 'Open the paytable and rules.': 'Открыть таблицу выплат и правила.',
2957
+ 'Pay a fixed cost to enter a bonus feature.': 'Заплатите фиксированную сумму для входа в бонусный раунд.',
2958
+ Paylines: 'Линии выплат',
2959
+ Paytable: 'Таблица выплат',
2960
+ 'Pays anywhere': 'Выплачивает в любом месте',
2961
+ Price: 'Цена',
2962
+ 'Raise bet': 'Повысить ставку',
2963
+ Replay: 'Повтор',
2964
+ RTP: 'RTP',
2965
+ SFX: 'Звуковые эффекты',
2966
+ Settings: 'Настройки',
2967
+ Sound: 'Звук',
2968
+ Spin: 'Вращение',
2969
+ 'Spin automatically a set number of times.': 'Автоматически вращать заданное количество раз.',
2970
+ 'Speed up spin animations.': 'Ускорить анимацию вращений.',
2971
+ Start: 'Начать',
2972
+ 'Start a spin at the current bet.': 'Начать вращение с текущей ставкой.',
2973
+ 'Start replay': 'Начать повтор',
2974
+ 'Total win': 'Общий выигрыш',
2975
+ Turbo: 'Турбо',
2976
+ 'Ways to win': 'Способы выигрыша',
2977
+ Win: 'Выигрыш',
2978
+ 'Winning shapes': 'Выигрышные комбинации',
2979
+ },
2980
+ tr: {
2981
+ DISCLAIMER: 'Yasal uyarı',
2982
+ Activate: 'Etkinleştir',
2983
+ Autoplay: 'Otomatik Oyun',
2984
+ Balance: 'Bakiye',
2985
+ Bet: 'Bahis',
2986
+ 'BUY BONUS': 'BONUS SATIN AL',
2987
+ Buy: 'Satın al',
2988
+ 'Buy bonus': 'Bonus satın al',
2989
+ Cancel: 'İptal',
2990
+ Close: 'Kapat',
2991
+ 'Cluster pays': 'Küme ödemeleri',
2992
+ Confirm: 'Onayla',
2993
+ Controls: 'Kontroller',
2994
+ 'Decrease your stake.': 'Bahsinizi azaltın.',
2995
+ DISABLE: 'DEVRE DIŞI',
2996
+ 'Dismiss the current overlay.': 'Mevcut pencereyi kapat.',
2997
+ 'Free spins': 'Ücretsiz dönüşler',
2998
+ Game: 'Oyun',
2999
+ 'Game info': 'Oyun bilgisi',
3000
+ Hotkeys: 'Klavye kısayolları',
3001
+ 'Increase your stake.': 'Bahsinizi artırın.',
3002
+ 'Lower bet': 'Bahsi düşür',
3003
+ 'Master volume': 'Ana ses',
3004
+ 'Max win': 'Maks kazanç',
3005
+ Menu: 'Menü',
3006
+ 'Menu & info': 'Menü ve bilgi',
3007
+ Modes: 'Modlar',
3008
+ Music: 'Müzik',
3009
+ Mute: 'Sessiz',
3010
+ 'Mute or unmute the game.': 'Oyun sesini aç ya da kapat.',
3011
+ Navigate: 'Gezin',
3012
+ 'Open settings and game info.': 'Ayarları ve oyun bilgisini aç.',
3013
+ 'Open the paytable and rules.': 'Ödeme tablosunu ve kuralları aç.',
3014
+ 'Pay a fixed cost to enter a bonus feature.': 'Bonus özelliğine girmek için sabit bir ücret ödeyin.',
3015
+ Paylines: 'Ödeme çizgileri',
3016
+ Paytable: 'Ödeme tablosu',
3017
+ 'Pays anywhere': 'Her yerde kazandırır',
3018
+ Price: 'Fiyat',
3019
+ 'Raise bet': 'Bahsi artır',
3020
+ Replay: 'Tekrar oynat',
3021
+ RTP: 'RTP',
3022
+ SFX: 'Ses efektleri',
3023
+ Settings: 'Ayarlar',
3024
+ Sound: 'Ses',
3025
+ Spin: 'Döndür',
3026
+ 'Spin automatically a set number of times.': 'Belirlenen sayıda otomatik döndür.',
3027
+ 'Speed up spin animations.': 'Döndürme animasyonlarını hızlandır.',
3028
+ Start: 'Başlat',
3029
+ 'Start a spin at the current bet.': 'Mevcut bahisle döndürmeyi başlat.',
3030
+ 'Start replay': 'Tekrarı başlat',
3031
+ 'Total win': 'Toplam kazanç',
3032
+ Turbo: 'Turbo',
3033
+ 'Ways to win': 'Kazanma yolları',
3034
+ Win: 'Kazanç',
3035
+ 'Winning shapes': 'Kazanan şekiller',
3036
+ },
3037
+ vi: {
3038
+ DISCLAIMER: 'Tuyên bố miễn trừ trách nhiệm',
3039
+ Activate: 'Kích hoạt',
3040
+ Autoplay: 'Tự động quay',
3041
+ Balance: 'Số dư',
3042
+ Bet: 'Cược',
3043
+ 'BUY BONUS': 'MUA THƯỞNG',
3044
+ Buy: 'Mua',
3045
+ 'Buy bonus': 'Mua thưởng',
3046
+ Cancel: 'Hủy',
3047
+ Close: 'Đóng',
3048
+ 'Cluster pays': 'Trả theo cụm',
3049
+ Confirm: 'Xác nhận',
3050
+ Controls: 'Điều khiển',
3051
+ 'Decrease your stake.': 'Giảm mức cược.',
3052
+ DISABLE: 'TẮT',
3053
+ 'Dismiss the current overlay.': 'Đóng lớp phủ hiện tại.',
3054
+ 'Free spins': 'Quay miễn phí',
3055
+ Game: 'Trò chơi',
3056
+ 'Game info': 'Thông tin trò chơi',
3057
+ Hotkeys: 'Phím tắt',
3058
+ 'Increase your stake.': 'Tăng mức cược.',
3059
+ 'Lower bet': 'Giảm cược',
3060
+ 'Master volume': 'Âm lượng chính',
3061
+ 'Max win': 'Thắng tối đa',
3062
+ Menu: 'Menu',
3063
+ 'Menu & info': 'Menu & thông tin',
3064
+ Modes: 'Chế độ',
3065
+ Music: 'Âm nhạc',
3066
+ Mute: 'Tắt âm',
3067
+ 'Mute or unmute the game.': 'Tắt hoặc bật âm thanh trò chơi.',
3068
+ Navigate: 'Điều hướng',
3069
+ 'Open settings and game info.': 'Mở cài đặt và thông tin trò chơi.',
3070
+ 'Open the paytable and rules.': 'Mở bảng trả thưởng và quy tắc.',
3071
+ 'Pay a fixed cost to enter a bonus feature.': 'Trả một khoản phí cố định để tham gia tính năng thưởng.',
3072
+ Paylines: 'Đường thắng',
3073
+ Paytable: 'Bảng trả thưởng',
3074
+ 'Pays anywhere': 'Trả bất cứ đâu',
3075
+ Price: 'Giá',
3076
+ 'Raise bet': 'Tăng cược',
3077
+ Replay: 'Xem lại',
3078
+ RTP: 'RTP',
3079
+ SFX: 'Hiệu ứng âm thanh',
3080
+ Settings: 'Cài đặt',
3081
+ Sound: 'Âm thanh',
3082
+ Spin: 'Quay',
3083
+ 'Spin automatically a set number of times.': 'Tự động quay một số lần nhất định.',
3084
+ 'Speed up spin animations.': 'Tăng tốc độ hoạt ảnh quay.',
3085
+ Start: 'Bắt đầu',
3086
+ 'Start a spin at the current bet.': 'Bắt đầu quay với mức cược hiện tại.',
3087
+ 'Start replay': 'Bắt đầu xem lại',
3088
+ 'Total win': 'Tổng thắng',
3089
+ Turbo: 'Turbo',
3090
+ 'Ways to win': 'Cách thắng',
3091
+ Win: 'Thắng',
3092
+ 'Winning shapes': 'Hình thắng',
3093
+ },
3094
+ zh: {
3095
+ DISCLAIMER: '免责声明',
3096
+ Activate: '激活',
3097
+ Autoplay: '自动游戏',
3098
+ Balance: '余额',
3099
+ Bet: '投注',
3100
+ 'BUY BONUS': '购买奖励',
3101
+ Buy: '购买',
3102
+ 'Buy bonus': '购买奖励',
3103
+ Cancel: '取消',
3104
+ Close: '关闭',
3105
+ 'Cluster pays': '集群赔付',
3106
+ Confirm: '确认',
3107
+ Controls: '控制',
3108
+ 'Decrease your stake.': '降低投注额。',
3109
+ DISABLE: '禁用',
3110
+ 'Dismiss the current overlay.': '关闭当前弹窗。',
3111
+ 'Free spins': '免费旋转',
3112
+ Game: '游戏',
3113
+ 'Game info': '游戏信息',
3114
+ Hotkeys: '快捷键',
3115
+ 'Increase your stake.': '提高投注额。',
3116
+ 'Lower bet': '降低投注',
3117
+ 'Master volume': '主音量',
3118
+ 'Max win': '最大奖金',
3119
+ Menu: '菜单',
3120
+ 'Menu & info': '菜单与信息',
3121
+ Modes: '模式',
3122
+ Music: '音乐',
3123
+ Mute: '静音',
3124
+ 'Mute or unmute the game.': '静音或取消静音。',
3125
+ Navigate: '导航',
3126
+ 'Open settings and game info.': '打开设置和游戏信息。',
3127
+ 'Open the paytable and rules.': '打开赔付表和规则。',
3128
+ 'Pay a fixed cost to enter a bonus feature.': '支付固定费用以进入奖励功能。',
3129
+ Paylines: '赔付线',
3130
+ Paytable: '赔付表',
3131
+ 'Pays anywhere': '任意位置赢',
3132
+ Price: '价格',
3133
+ 'Raise bet': '提高投注',
3134
+ Replay: '重播',
3135
+ RTP: 'RTP',
3136
+ SFX: '音效',
3137
+ Settings: '设置',
3138
+ Sound: '声音',
3139
+ Spin: '旋转',
3140
+ 'Spin automatically a set number of times.': '自动旋转设定次数。',
3141
+ 'Speed up spin animations.': '加速旋转动画。',
3142
+ Start: '开始',
3143
+ 'Start a spin at the current bet.': '以当前投注额开始旋转。',
3144
+ 'Start replay': '开始重播',
3145
+ 'Total win': '总奖金',
3146
+ Turbo: '急速',
3147
+ 'Ways to win': '赢法',
3148
+ Win: '奖金',
3149
+ 'Winning shapes': '赢利图形',
3150
+ },
3151
+ };
3152
+
1783
3153
  // Social-casino language. English is the source (and, for now, the only) language; `socialize`
1784
3154
  // rewrites the restricted gambling vocabulary into social-safe phrasing while preserving case.
1785
3155
  //
@@ -1852,6 +3222,21 @@ function socialize(text) {
1852
3222
  return repl == null ? m : applyCase(m, repl);
1853
3223
  });
1854
3224
  }
3225
+ const LANGS = ['de', 'en', 'es', 'fi', 'fr', 'hi', 'id', 'ja', 'ko', 'pl', 'pt', 'ru', 'tr', 'vi', 'zh', 'da'];
3226
+ const LANG_SET = new Set(LANGS);
3227
+ function normalizeLang(code) {
3228
+ const base = (code ?? '').toLowerCase().split(/[-_]/)[0];
3229
+ return (LANG_SET.has(base) ? base : 'en');
3230
+ }
3231
+ function createI18n(opts) {
3232
+ const lang = normalizeLang(opts.language);
3233
+ const t = (src) => {
3234
+ if (lang === 'en')
3235
+ return opts.isSocial ? socialize(src) : src;
3236
+ return opts.messages?.[lang]?.[src] ?? LOCALES[lang]?.[src] ?? src;
3237
+ };
3238
+ return { lang, t };
3239
+ }
1855
3240
 
1856
3241
  const REMOVE_FADE_MS = 300;
1857
3242
  class GameShell extends EventEmitter {
@@ -1867,10 +3252,19 @@ class GameShell extends EventEmitter {
1867
3252
  prevBalance = 0;
1868
3253
  prevWin = 0;
1869
3254
  moneyAnims = [];
1870
- keysBound = false;
3255
+ kbd;
3256
+ i18n;
3257
+ /** onKey handler of the currently open modal/overlay, if any (set in showModal, cleared in closeModal). */
3258
+ modalOnKey = undefined;
3259
+ /** Shared sound on/off state — Settings speaker toggle and the Shift+M hotkey stay in sync. The
3260
+ * game listens to `settingChange({ key: 'sound' })` to (un)mute audio. */
3261
+ soundOn = true;
3262
+ /** Set by the open Settings modal so Shift+M live-updates its speaker icon; cleared on close. */
3263
+ soundRefresh = null;
1871
3264
  constructor(config) {
1872
3265
  super();
1873
3266
  this.config = config;
3267
+ this.i18n = createI18n({ language: config.language, isSocial: config.isSocial });
1874
3268
  this.state = createInitialState(config);
1875
3269
  this.styleEl = document.createElement('style');
1876
3270
  this.styleEl.textContent = SHELL_CSS;
@@ -1886,12 +3280,54 @@ class GameShell extends EventEmitter {
1886
3280
  this.prevWin = this.state.win;
1887
3281
  this.observeLayout();
1888
3282
  if (typeof document !== 'undefined') {
1889
- document.addEventListener('keydown', this.handleKeyDown);
3283
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
3284
+ const shell = this;
3285
+ const host = {
3286
+ get state() { return shell.state; },
3287
+ get hotkeysEnabled() { return shell.config.features.hotkeys !== false; },
3288
+ get spacebarEnabled() { return shell.config.features.spacebar !== false; },
3289
+ get turboLevels() { return shell.config.features.turbo; },
3290
+ get autoplayEnabled() { return shell.config.features.autoplay != null; },
3291
+ get buyBonusEnabled() { return shell.config.features.buyBonus !== false; },
3292
+ hasOpenLayer: () => shell.modalHost.childElementCount > 0,
3293
+ routeToLayer: (e) => shell.modalOnKey?.(e) ?? false,
3294
+ spin: () => shell.emit('spin'),
3295
+ stepBet: (dir) => {
3296
+ const next = stepBet(shell.state, dir);
3297
+ if (next === shell.state.bet)
3298
+ return;
3299
+ shell.state.bet = next;
3300
+ shell.emit('betChange', next);
3301
+ shell.render();
3302
+ },
3303
+ toggleAutoplay: () => {
3304
+ if (shell.state.autoplay.active) {
3305
+ shell.state.autoplay = { active: false, remaining: 0 };
3306
+ shell.emit('autoplayStop');
3307
+ shell.render();
3308
+ }
3309
+ else {
3310
+ shell.openAutoplayPicker();
3311
+ }
3312
+ },
3313
+ cycleTurbo: () => {
3314
+ const next = nextTurbo(shell.state.turbo, shell.config.features.turbo);
3315
+ shell.state.turbo = next;
3316
+ shell.emit('turboChange', next);
3317
+ shell.render();
3318
+ },
3319
+ openBuyBonus: () => shell.openBuyBonus(),
3320
+ openInfo: () => shell.openInfo(),
3321
+ openMenu: () => shell.openMenu(),
3322
+ toggleMute: () => shell.setSound(!shell.soundOn),
3323
+ closeLayer: () => shell.closeModal(),
3324
+ };
3325
+ this.kbd = new KeyboardController(host);
3326
+ this.kbd.attach();
1890
3327
  // Stake serves the game in an iframe; on first paint focus is on the HOST page, so a `document`
1891
3328
  // keydown never fires and Space scrolls the parent. Pull window focus into the iframe on the
1892
3329
  // first pointer interaction so the spacebar shortcut works. Harmless on full-page Energy8.
1893
3330
  document.addEventListener('pointerdown', this.pullFocus, true);
1894
- this.keysBound = true;
1895
3331
  }
1896
3332
  this.render();
1897
3333
  // re-fit once the bundled webfont swaps in (text metrics change → row width changes)
@@ -1941,10 +3377,11 @@ class GameShell extends EventEmitter {
1941
3377
  host.classList.remove('ge-fit');
1942
3378
  host.style.transform = '';
1943
3379
  host.style.transformOrigin = '';
1944
- // clear any per-zone height-scale from a prior pass
3380
+ // clear any per-zone scale/zoom from a prior pass
1945
3381
  for (const el of host.querySelectorAll('.ge-zone, .ge-winpill')) {
1946
3382
  el.style.transform = '';
1947
3383
  el.style.transformOrigin = '';
3384
+ el.style.removeProperty('zoom');
1948
3385
  }
1949
3386
  if (this.layout === 'mobile') {
1950
3387
  // Shrink the whole stack to fit narrow phones (mobile-s, or big balance/win/total-win
@@ -1962,84 +3399,63 @@ class GameShell extends EventEmitter {
1962
3399
  }
1963
3400
  return;
1964
3401
  }
1965
- const availW = this.root.clientWidth - 12;
1966
- const availH = this.root.clientHeight * GameShell.BAR_MAX_FRACTION;
1967
- // 1) If the inline row overflows the width, lift the WIN pill onto its own line above the bar
1968
- // (keeps the controls as large as possible — base's wide row + a big WIN pill hit this).
3402
+ // ONE fit-scale, from the SCREEN SIZE, applied identically in EVERY mode — switching base⇄replay
3403
+ // must not resize the bar. The factor is the frame WIDTH vs the bar's design width, never the
3404
+ // current mode's content width.
3405
+ //
3406
+ // It's applied with `zoom` (not `transform`): zoom shrinks the LAYOUT, so the zones genuinely
3407
+ // take less room and still sit edge-to-edge (menu hard-left, controls hard-right) even when base's
3408
+ // wide row would overflow a merely-visually-scaled bar — so there is no per-mode centred cluster
3409
+ // and no width/mode branching. A wide WIN pill is still lifted above the row first so it can't
3410
+ // shove the controls off-screen. (Mobile, above, keeps its own stacked fit.)
1969
3411
  if (pill && bar.scrollWidth > bar.clientWidth + 1) {
1970
3412
  host.insertBefore(pill, bar);
1971
3413
  pill.classList.add('ge-up');
1972
3414
  }
1973
- // 2) Still too WIDE (the control row itself doesn't fit) shrink-to-content + uniform scale,
1974
- // centred. Only base's wide row reaches here.
1975
- if (bar.scrollWidth > bar.clientWidth + 1) {
1976
- host.classList.add('ge-fit');
1977
- const naturalW = host.offsetWidth, naturalH = host.offsetHeight;
1978
- const s = Math.min(1, naturalW > 0 ? availW / naturalW : 1, naturalH > 0 ? availH / naturalH : 1);
1979
- if (s < 0.999)
1980
- host.style.transform = `translateX(-50%) scale(${s.toFixed(4)})`;
1981
- else
1982
- host.classList.remove('ge-fit');
1983
- return;
1984
- }
1985
- // 3) Fits the WIDTH but the stack (control row + any lifted WIN pill) is too TALL for a short
1986
- // frame replay/free-spins on Popout S. Shrink each piece toward its OWN edge so the bar
1987
- // keeps its full-width space-between layout (menu hard-left, controls hard-right), just
1988
- // lower NOT packed into a centred cluster. Scale by frame HEIGHT only.
1989
- const naturalH = host.offsetHeight;
1990
- if (naturalH > availH && naturalH > 0) {
1991
- const s = (availH / naturalH).toFixed(4);
1992
- const scaleEdge = (el, origin) => {
1993
- if (!el)
1994
- return;
1995
- el.style.transformOrigin = origin;
1996
- el.style.transform = `scale(${s})`;
1997
- };
1998
- scaleEdge(bar.querySelector('.ge-zone-left'), 'left bottom');
1999
- scaleEdge(bar.querySelector('.ge-zone-right'), 'right bottom');
2000
- scaleEdge(host.querySelector('.ge-winpill'), 'center bottom');
3415
+ const zoomBar = (z) => {
3416
+ const v = z < 0.999 ? z.toFixed(4) : '';
3417
+ for (const el of host.querySelectorAll('.ge-zone, .ge-winpill')) {
3418
+ if (v)
3419
+ el.style.setProperty('zoom', v);
3420
+ else
3421
+ el.style.removeProperty('zoom');
3422
+ }
3423
+ };
3424
+ const s = Math.max(GameShell.BAR_MIN_SCALE, Math.min(1, this.root.clientWidth / GameShell.BAR_REF_WIDTH));
3425
+ zoomBar(s);
3426
+ // Safety: a pathologically long balance/win can still overflow the frame at the screen zoom —
3427
+ // nudge the zoom down just enough that the far control (turbo) isn't clipped. Normal content
3428
+ // never triggers this, so base and replay keep the SAME zoom (no size change on mode switch).
3429
+ if (bar.scrollWidth > bar.clientWidth + 1 && bar.scrollWidth > 0) {
3430
+ zoomBar(s * (bar.clientWidth / bar.scrollWidth));
2001
3431
  }
2002
3432
  }
2003
- /** Spacebar starts a spin — same path as the spin disc. Ignored when `features.spacebar` is
2004
- * false, while a spin is running, while autoplay is active, outside base mode, when an
2005
- * overlay/modal is open, or when an editable element is focused. `repeat` (held key) is
2006
- * ignored so it can't spam. */
2007
3433
  /** Pull window focus into the iframe on first pointer interaction so `document` keydown (the
2008
3434
  * spacebar shortcut) fires. No-op / harmless when already focused or full-page. */
2009
3435
  pullFocus = () => { try {
2010
3436
  window.focus();
2011
3437
  }
2012
3438
  catch { /* cross-origin / non-browser */ } };
2013
- handleKeyDown = (e) => {
2014
- if (this.destroyed || e.code !== 'Space' || e.repeat)
2015
- return;
2016
- if (this.config.features.spacebar === false)
2017
- return; // shortcut disabled (e.g. jurisdiction)
2018
- const t = e.target;
2019
- if (t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName)))
2020
- return;
2021
- // Space is ours now — swallow the browser default before any no-op bail. Otherwise the
2022
- // native "Space activates the focused button" still fires and re-clicks whichever shell
2023
- // <button> (menu/buy/auto) opened the overlay, tearing down + rebuilding the modal: a
2024
- // visible flicker. (Also stops the page from scrolling on Space.)
2025
- e.preventDefault();
2026
- if (this.modalHost.childElementCount > 0)
2027
- return; // an overlay/modal is open
2028
- if (this.state.mode !== 'base' || this.state.busy || this.state.autoplay.active)
2029
- return;
2030
- this.emit('spin');
2031
- };
2032
3439
  setLayout(layout) {
2033
3440
  if (layout === this.layout)
2034
3441
  return;
2035
3442
  this.layout = layout;
2036
3443
  this.render();
2037
3444
  }
2038
- /** Resolve a built-in shell string. English is the source; with `isSocial` it is run through
2039
- * the social-casino word-swap. Game-supplied strings should NOT be passed through this. */
2040
- t(text) { return this.config.isSocial ? socialize(text) : text; }
2041
- /** Toggle the social vocabulary at runtime (re-renders the bar; reopen overlays to refresh them). */
2042
- setSocial(isSocial) { this.config.isSocial = isSocial; this.render(); }
3445
+ /** Resolve a built-in shell string through the i18n resolver (translation + optional socialize). */
3446
+ t(text) { return this.i18n.t(text); }
3447
+ /** Toggle the social vocabulary at runtime (rebuilds resolver, re-renders bar). */
3448
+ setSocial(isSocial) {
3449
+ this.config.isSocial = isSocial;
3450
+ this.i18n = createI18n({ language: this.config.language, isSocial });
3451
+ this.render();
3452
+ }
3453
+ /** Swap the active language at runtime (rebuilds resolver, re-renders bar). */
3454
+ setLanguage(lang) {
3455
+ this.config.language = lang;
3456
+ this.i18n = createI18n({ language: lang, isSocial: this.config.isSocial });
3457
+ this.render();
3458
+ }
2043
3459
  /** Recolour the shell at runtime (e.g. switch dark/light scheme). */
2044
3460
  setTheme(theme) {
2045
3461
  this.config.theme = theme;
@@ -2080,7 +3496,7 @@ class GameShell extends EventEmitter {
2080
3496
  this.state.mode = mode;
2081
3497
  this.render();
2082
3498
  }
2083
- setBusy(busy) { this.state.busy = busy; this.render(); }
3499
+ setBusy(busy) { this.state.busy = busy; this.render(); this.kbd?.notifyBusyChanged(busy); }
2084
3500
  setAutoplay(a) { this.state.autoplay = a; this.render(); }
2085
3501
  setTurbo(level) { this.state.turbo = level; this.render(); }
2086
3502
  /** Currency-aware money formatter for WIN amounts (variable decimals: 0.0041 stays 0.0041, not
@@ -2088,7 +3504,7 @@ class GameShell extends EventEmitter {
2088
3504
  formatWin(value) { return formatCurrency(value, this.config.currency, true); }
2089
3505
  setBuyBonusEnabled(enabled) { this.state.buyBonusEnabled = enabled; this.render(); }
2090
3506
  setFreeSpins(fs) { this.state.freeSpins = fs; this.render(); }
2091
- showModal(el) {
3507
+ showModal(el, onKey) {
2092
3508
  // The control that opened this overlay (menu/buy/auto) keeps DOM focus. Drop it, or a
2093
3509
  // stray Space/Enter would natively re-activate that <button> and rebuild the modal — a
2094
3510
  // visible flicker. Only relinquish focus we own (a shell control), never the host page's.
@@ -2097,6 +3513,7 @@ class GameShell extends EventEmitter {
2097
3513
  active.blur();
2098
3514
  this.modalHost.innerHTML = '';
2099
3515
  this.modalHost.appendChild(el);
3516
+ this.modalOnKey = onKey;
2100
3517
  this.fitModals();
2101
3518
  }
2102
3519
  /** Uniformly scale every open centred card modal (`.ge-sheet`) down so it fits a short/narrow
@@ -2111,9 +3528,12 @@ class GameShell extends EventEmitter {
2111
3528
  /** Fraction of the frame a card modal may occupy; the rest is breathing-room margin. Keeps
2112
3529
  * modals from filling a small popout edge-to-edge (so even short pickers scale down there). */
2113
3530
  static MODAL_FIT = 0.86;
2114
- /** Max fraction of the frame HEIGHT the bottom bar may occupy before it fit-scales down. Keeps the
2115
- * bar a consistent, small slice on short popouts in EVERY mode (base this already via width). */
2116
- static BAR_MAX_FRACTION = 0.27;
3531
+ /** The bar's design width (px). When the frame is narrower, the bar fit-scales DOWN with the
3532
+ * screen the SAME factor in every mode, so replay/free-spins shrink like base instead of
3533
+ * staying full-size on a popout. */
3534
+ static BAR_REF_WIDTH = 840;
3535
+ /** Lower bound on the bar fit-scale (guards a degenerate near-zero frame). */
3536
+ static BAR_MIN_SCALE = 0.5;
2117
3537
  fitSheet(root) {
2118
3538
  const card = root.querySelector('.ge-modal-card');
2119
3539
  if (!card)
@@ -2146,22 +3566,31 @@ class GameShell extends EventEmitter {
2146
3566
  }
2147
3567
  openMenu() { this.emit('menuOpen'); this.openSettings(); }
2148
3568
  openSettings() { this.emit('settingsOpen'); this.showModal(openSettingsModal(this)); }
2149
- openInfo() { this.emit('infoOpen'); this.showModal(openGameInfoModal(this)); }
3569
+ openInfo() { this.emit('infoOpen'); const { root, onKey } = openGameInfoModal(this); this.showModal(root, onKey); }
2150
3570
  openBuyBonus() {
2151
3571
  if (this.config.onBonusBuy) {
2152
3572
  this.config.onBonusBuy();
2153
3573
  return;
2154
3574
  } // game handles it (own UI)
2155
- const overlay = openBuyBonusOverlay(this);
2156
- if (overlay)
2157
- this.showModal(overlay);
3575
+ const result = openBuyBonusOverlay(this);
3576
+ if (result)
3577
+ this.showModal(result.root, result.onKey);
2158
3578
  }
2159
3579
  /** Open a generic, externally-driven modal (title + body + optional action buttons).
2160
3580
  * Each action runs its `on` then closes; the ✕ shows when `availableClose` is true. */
2161
- openModal(opts) { this.showModal(buildModal(opts)); }
3581
+ openModal(opts) { this.showModal(buildModal(opts), opts.onKey); }
2162
3582
  /** Programmatically dismiss whatever modal/overlay is currently shown (e.g. auto-close the
2163
3583
  * reconnect overlay once the link is restored). No-op when nothing is open. */
2164
- closeModal() { this.modalHost.innerHTML = ''; }
3584
+ closeModal() { this.modalOnKey = undefined; this.soundRefresh = null; this.modalHost.innerHTML = ''; }
3585
+ /** Flip the shared sound state, notify the game (`settingChange({ key: 'sound' })`), and live-update
3586
+ * the Settings speaker icon if that modal is open. Used by both the Settings toggle and Shift+M. */
3587
+ setSound(on) {
3588
+ this.soundOn = on;
3589
+ this.emit('settingChange', { key: 'sound', value: on });
3590
+ this.soundRefresh?.(on);
3591
+ }
3592
+ /** The Settings modal registers an icon-updater while open (cleared on close). */
3593
+ setSoundRefresh(fn) { this.soundRefresh = fn; }
2165
3594
  /** Open the non-dismissable replay summary modal (START REPLAY → onReplay → reopen). */
2166
3595
  openReplay(opts) {
2167
3596
  if (this.destroyed)
@@ -2169,19 +3598,18 @@ class GameShell extends EventEmitter {
2169
3598
  this.showModal(buildReplayModal(this, opts));
2170
3599
  }
2171
3600
  /** Bet picker — list of available bets with an accent Confirm. */
2172
- openBetPicker() { this.showModal(openBetModal(this)); }
3601
+ openBetPicker() { const { root, onKey } = openBetModal(this); this.showModal(root, onKey); }
2173
3602
  /** Autoplay picker — spin-count list; Confirm starts autoplay. */
2174
- openAutoplayPicker() { this.showModal(openAutoplayModal(this)); }
3603
+ openAutoplayPicker() { const { root, onKey } = openAutoplayModal(this); this.showModal(root, onKey); }
2175
3604
  destroy() {
2176
3605
  if (this.destroyed)
2177
3606
  return Promise.resolve();
2178
3607
  this.destroyed = true;
2179
3608
  this.ro?.disconnect();
2180
3609
  this.ro = null;
2181
- if (this.keysBound) {
2182
- document.removeEventListener('keydown', this.handleKeyDown);
3610
+ if (typeof document !== 'undefined') {
3611
+ this.kbd?.detach();
2183
3612
  document.removeEventListener('pointerdown', this.pullFocus, true);
2184
- this.keysBound = false;
2185
3613
  }
2186
3614
  this.cancelMoneyAnims();
2187
3615
  this.removeAllListeners();
@@ -2227,6 +3655,8 @@ function removeGameShell() {
2227
3655
 
2228
3656
  exports.GameShell = GameShell;
2229
3657
  exports.createGameShell = createGameShell;
3658
+ exports.createI18n = createI18n;
3659
+ exports.normalizeLang = normalizeLang;
2230
3660
  exports.removeGameShell = removeGameShell;
2231
3661
  exports.socialize = socialize;
2232
3662
  //# sourceMappingURL=shell.cjs.js.map