@energy8platform/shell 0.4.1 → 0.6.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/html.d.ts CHANGED
@@ -16,7 +16,14 @@ declare class EventEmitter<TEvents extends {}> {
16
16
  removeAllListeners(event?: keyof TEvents): this;
17
17
  }
18
18
 
19
- type ShellMode = 'base' | 'freeSpins' | 'replay';
19
+ /** `freeSpins` and `bonus` are the SAME bar layout (host-driven hero + Total Win); `freeSpins` is
20
+ * kept as a back-compat alias for the common case (its readout is derived current/total), while
21
+ * `bonus` pairs with `setBonus()` to show a game-supplied label + value (adventure, hold-and-spin,
22
+ * respins — anything that isn't a plain free-spins counter). */
23
+ type ShellMode = 'base' | 'bonus' | 'freeSpins' | 'replay';
24
+ /** The three independent volume sliders shown in the Settings overlay. */
25
+ type VolumeKey = 'master' | 'music' | 'sfx';
26
+ type VolumeLevels = Record<VolumeKey, number>;
20
27
  interface CurrencyConfig {
21
28
  symbol: string;
22
29
  position: 'left' | 'right';
@@ -205,6 +212,19 @@ interface FreeSpinsState {
205
212
  total: number;
206
213
  totalWin: number;
207
214
  }
215
+ /** A game-supplied bonus readout for the bar hero, set via `setBonus()`. Generalizes the
216
+ * free-spins counter: the shell renders `label` (localized via the shell translator, so a game
217
+ * i18n entry for it is honoured) + `value` (a pre-formatted string, shown verbatim — "2 / 10",
218
+ * "3", "×5", "7 coins") + the Total Win accumulator. The shell stays free of any per-game bonus
219
+ * concept — the label/value semantics live in the game/host. */
220
+ interface BonusReadout {
221
+ /** Bar label, e.g. 'Free spins' | 'Adventure' | 'Hold & Spin'. Run through the shell translator. */
222
+ label: string;
223
+ /** Pre-formatted counter value shown verbatim (NOT translated). */
224
+ value: string;
225
+ /** Cumulative bonus win for the Total Win readout. */
226
+ totalWin: number;
227
+ }
208
228
  /** One footer button of a generic modal. Clicking it runs `on` (if any), then closes the modal. */
209
229
  interface ModalAction {
210
230
  title: string;
@@ -265,9 +285,13 @@ interface ShellConfig {
265
285
  * opening the built-in buy-bonus overlay (e.g. the game shows its own bonus UI). The button
266
286
  * is shown whenever this OR `features.buyBonus` is set. */
267
287
  onBonusBuy?: () => void;
288
+ /** Initial Settings-overlay volume slider positions (each 0..1, defaults to 1 = 100%). The shell
289
+ * keeps them stateful across opens; read/update at runtime via `shell.getVolume()` /
290
+ * `shell.setVolume()`, and listen to `settingChange` ({ key: 'master'|'music'|'sfx' }) to apply. */
291
+ volumes?: Partial<VolumeLevels>;
268
292
  }
269
293
  /** ShellConfig after the controller applies defaults (version, isSocial, replay, theme). No mount. */
270
- type ResolvedShellConfig = Required<Pick<ShellConfig, 'language' | 'currency' | 'availableBets' | 'defaultBet' | 'balance' | 'win' | 'mode' | 'features' | 'gameInfo' | 'version' | 'isSocial' | 'replay'>> & Pick<ShellConfig, 'currentBet' | 'theme' | 'onBonusBuy'>;
294
+ type ResolvedShellConfig = Required<Pick<ShellConfig, 'language' | 'currency' | 'availableBets' | 'defaultBet' | 'balance' | 'win' | 'mode' | 'features' | 'gameInfo' | 'version' | 'isSocial' | 'replay'>> & Pick<ShellConfig, 'currentBet' | 'theme' | 'onBonusBuy' | 'volumes'>;
271
295
  interface ShellState {
272
296
  mode: ShellMode;
273
297
  /** Sticky replay marker — true for a historical-round replay, regardless of the current
@@ -283,9 +307,18 @@ interface ShellState {
283
307
  turbo: number;
284
308
  buyBonusEnabled: boolean;
285
309
  freeSpins: FreeSpinsState;
310
+ /** Game-supplied bonus label + value override (from `setBonus()`). When null the bar derives the
311
+ * readout from `freeSpins` (label 'Free spins', value current/total) — the back-compat default. */
312
+ bonus: {
313
+ label: string;
314
+ value: string;
315
+ } | null;
286
316
  /** The currently activated `feature` option (e.g. Ante), or null. Drives the
287
317
  * effective-bet readout tint and the BUY BONUS → DISABLE toggle on the bar. */
288
318
  activeFeature: BonusOption | null;
319
+ /** Volume slider positions (0..1) surfaced in the Settings overlay. Stateful across opens so a
320
+ * reopened overlay reflects the last-set positions instead of resetting to 100%. */
321
+ volumes: VolumeLevels;
289
322
  }
290
323
  interface ShellEvents {
291
324
  spin: void;
@@ -408,6 +441,13 @@ interface ShellHost {
408
441
  setSound(on: boolean): void;
409
442
  /** An open Settings overlay registers an icon updater here (null clears it on close). */
410
443
  setSoundRefresh(fn: ((on: boolean) => void) | null): void;
444
+ /** Current volume slider position (0..1) for master/music/sfx. */
445
+ getVolume(key: VolumeKey): number;
446
+ /** Set a volume slider (0..1): clamps, stores, emits `settingChange`, and live-updates an open
447
+ * Settings overlay. Called by the slider control on drag AND by game code as the public API. */
448
+ setVolume(key: VolumeKey, value: number): void;
449
+ /** An open Settings overlay registers a slider updater here (null clears it on close). */
450
+ setVolumeRefresh(fn: ((key: VolumeKey, value: number) => void) | null): void;
411
451
  /** Logic-bearing actions invoked by renderer controls. */
412
452
  readonly actions: ShellActions;
413
453
  }
@@ -471,13 +511,14 @@ declare class ShellController extends EventEmitter<ShellEvents> implements Shell
471
511
  tokens: ShellTokens;
472
512
  layout: ShellLayoutMode;
473
513
  soundOn: boolean;
474
- readonly engineVersion = "0.4.1";
514
+ readonly engineVersion = "0.6.0";
475
515
  readonly actions: ShellActions;
476
516
  private renderer;
477
517
  private i18n;
478
518
  private kbd?;
479
519
  private overlay;
480
520
  private soundRefresh;
521
+ private volumeRefresh;
481
522
  private prevBalance;
482
523
  private prevWin;
483
524
  private destroyed;
@@ -502,6 +543,12 @@ declare class ShellController extends EventEmitter<ShellEvents> implements Shell
502
543
  closeModal(): void;
503
544
  setSound(on: boolean): void;
504
545
  setSoundRefresh(fn: ((on: boolean) => void) | null): void;
546
+ getVolume(key: VolumeKey): number;
547
+ /** Set a volume slider (0..1). Shared by the slider control (drag) and game code (public API):
548
+ * clamps, stores so a reopened Settings overlay reflects it, emits `settingChange`, and
549
+ * live-updates the slider if the overlay is currently open. */
550
+ setVolume(key: VolumeKey, value: number): void;
551
+ setVolumeRefresh(fn: ((key: VolumeKey, value: number) => void) | null): void;
505
552
  activateFeature(bonus: BonusOption): void;
506
553
  deactivateFeature(): void;
507
554
  private money;
@@ -514,6 +561,10 @@ declare class ShellController extends EventEmitter<ShellEvents> implements Shell
514
561
  setTurbo(level: number): void;
515
562
  setBuyBonusEnabled(enabled: boolean): void;
516
563
  setFreeSpins(fs: FreeSpinsState): void;
564
+ /** Generic bonus readout (adventure / hold-and-spin / respins). Sets a game-supplied label+value
565
+ * override for the bar hero and folds `totalWin` into the shared accumulator. Pairs with
566
+ * `setMode('bonus')`. `setFreeSpins()` clears the override back to the derived current/total. */
567
+ setBonus(b: BonusReadout): void;
517
568
  setTheme(theme: ThemeConfig): void;
518
569
  setLanguage(lang: string): void;
519
570
  setSocial(isSocial: boolean): void;
@@ -537,7 +588,7 @@ interface I18n {
537
588
  declare function createI18n(opts: I18nOptions): I18n;
538
589
 
539
590
  /** The @energy8platform/shell package version, stamped into the game-info footer. */
540
- declare const PACKAGE_VERSION = "0.4.1";
591
+ declare const PACKAGE_VERSION = "0.6.0";
541
592
 
542
593
  /** A shell: the renderer-agnostic controller plus the surface facade (safeArea/barHeight/setVisible)
543
594
  * an embedding host reads. `createShell`, `createGameShell` and `createPixiShell` all return this. */
@@ -613,4 +664,4 @@ declare function createGameShell(config: HtmlShellConfig): ShellController;
613
664
  declare function removeGameShell(): Promise<void>;
614
665
 
615
666
  export { DEFAULT_ACCENT, ShellController as GameShell, HtmlRenderer, PACKAGE_VERSION, SCHEMES, ShellController, createGameShell, createI18n, createShell, normalizeLang, removeGameShell, resolveConfig, resolveTheme, socialize };
616
- export type { AutoplayConfig, AutoplayOptions, BonusCardContext, BonusOption, CellRef, CreateShellOptions, CurrencyConfig, FreeSpinsState, GameInfoContent, GameInfoSection, GameMode, HtmlShellConfig, I18n, I18nOptions, Lang, ModalAction, ModalOptions, OverlayHandle, OverlayRequest, PaylineDef, PaytableRow, ReplayModalOptions, ResolvedShellConfig, SafeArea, ShapeDef, Shell, ShellActions, HtmlShellConfig as ShellConfig, ShellEvents, ShellFeatures, ShellHost, ShellLayoutMode, ShellMode, ShellRenderer, ShellState, ShellSurface, ShellTokens, ThemeConfig, WinSection };
667
+ export type { AutoplayConfig, AutoplayOptions, BonusCardContext, BonusOption, BonusReadout, CellRef, CreateShellOptions, CurrencyConfig, FreeSpinsState, GameInfoContent, GameInfoSection, GameMode, HtmlShellConfig, I18n, I18nOptions, Lang, ModalAction, ModalOptions, OverlayHandle, OverlayRequest, PaylineDef, PaytableRow, ReplayModalOptions, ResolvedShellConfig, SafeArea, ShapeDef, Shell, ShellActions, HtmlShellConfig as ShellConfig, ShellEvents, ShellFeatures, ShellHost, ShellLayoutMode, ShellMode, ShellRenderer, ShellState, ShellSurface, ShellTokens, ThemeConfig, VolumeKey, VolumeLevels, WinSection };
package/dist/html.esm.js CHANGED
@@ -63,9 +63,19 @@ function createInitialState(config) {
63
63
  turbo: 0,
64
64
  buyBonusEnabled: true,
65
65
  freeSpins: { current: 0, total: 0, totalWin: 0 },
66
+ bonus: null,
66
67
  activeFeature: null,
68
+ volumes: {
69
+ master: clampVolume(config.volumes?.master),
70
+ music: clampVolume(config.volumes?.music),
71
+ sfx: clampVolume(config.volumes?.sfx),
72
+ },
67
73
  };
68
74
  }
75
+ /** Clamp a configured volume to 0..1, defaulting to full (1) when unset/invalid. */
76
+ function clampVolume(v) {
77
+ return typeof v === 'number' && Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 1;
78
+ }
69
79
  /** Step bet up/down within availableBets, clamped at the ends. */
70
80
  function stepBet(state, direction) {
71
81
  const idx = state.availableBets.indexOf(state.bet);
@@ -1436,7 +1446,7 @@ class KeyboardController {
1436
1446
 
1437
1447
  // AUTO-GENERATED by scripts/gen-version.mjs — do not edit. Mirrors package.json "version".
1438
1448
  /** The @energy8platform/shell package version, stamped into the game-info footer. */
1439
- const PACKAGE_VERSION = '0.4.1';
1449
+ const PACKAGE_VERSION = '0.6.0';
1440
1450
 
1441
1451
  /** Apply defaults to the raw config (the mount target lives on the renderer, not here). */
1442
1452
  function resolveConfig(config) {
@@ -1453,6 +1463,7 @@ function resolveConfig(config) {
1453
1463
  features: config.features,
1454
1464
  theme: config.theme,
1455
1465
  onBonusBuy: config.onBonusBuy,
1466
+ volumes: config.volumes,
1456
1467
  version: config.version ?? '1.0.0',
1457
1468
  isSocial: config.isSocial ?? false,
1458
1469
  replay: config.replay ?? config.mode === 'replay',
@@ -1474,6 +1485,7 @@ class ShellController extends EventEmitter {
1474
1485
  kbd;
1475
1486
  overlay = null;
1476
1487
  soundRefresh = null;
1488
+ volumeRefresh = null;
1477
1489
  prevBalance;
1478
1490
  prevWin;
1479
1491
  destroyed = false;
@@ -1497,9 +1509,15 @@ class ShellController extends EventEmitter {
1497
1509
  this.renderer.renderBar();
1498
1510
  }
1499
1511
  // ── ShellHost ──────────────────────────────────────────────────────────────
1500
- t(text) { return this.i18n.t(text); }
1501
- formatCurrency(n, win = false) { return formatCurrency(n, this.config.currency, win); }
1502
- formatWin(value) { return this.formatCurrency(value, true); }
1512
+ t(text) {
1513
+ return this.i18n.t(text);
1514
+ }
1515
+ formatCurrency(n, win = false) {
1516
+ return formatCurrency(n, this.config.currency, win);
1517
+ }
1518
+ formatWin(value) {
1519
+ return this.formatCurrency(value, true);
1520
+ }
1503
1521
  notifyResize(w, h) {
1504
1522
  const layout = w !== 0 && h > w ? 'mobile' : 'wide';
1505
1523
  if (layout !== this.layout) {
@@ -1566,12 +1584,24 @@ class ShellController extends EventEmitter {
1566
1584
  // eslint-disable-next-line @typescript-eslint/no-this-alias
1567
1585
  const self = this;
1568
1586
  const host = {
1569
- get state() { return self.state; },
1570
- get hotkeysEnabled() { return self.config.features.hotkeys !== false; },
1571
- get spacebarEnabled() { return self.config.features.spacebar !== false; },
1572
- get turboLevels() { return self.config.features.turbo; },
1573
- get autoplayEnabled() { return self.config.features.autoplay != null; },
1574
- get buyBonusEnabled() { return self.config.features.buyBonus !== false; },
1587
+ get state() {
1588
+ return self.state;
1589
+ },
1590
+ get hotkeysEnabled() {
1591
+ return self.config.features.hotkeys !== false;
1592
+ },
1593
+ get spacebarEnabled() {
1594
+ return self.config.features.spacebar !== false;
1595
+ },
1596
+ get turboLevels() {
1597
+ return self.config.features.turbo;
1598
+ },
1599
+ get autoplayEnabled() {
1600
+ return self.config.features.autoplay != null;
1601
+ },
1602
+ get buyBonusEnabled() {
1603
+ return self.config.features.buyBonus !== false;
1604
+ },
1575
1605
  hasOpenLayer: () => self.overlay !== null,
1576
1606
  routeToLayer: (e) => self.overlay?.onKey?.(e) ?? false,
1577
1607
  spin: () => self.actions.spin(),
@@ -1587,33 +1617,59 @@ class ShellController extends EventEmitter {
1587
1617
  this.kbd = new KeyboardController(host);
1588
1618
  this.kbd.attach();
1589
1619
  }
1590
- pullFocus = () => { try {
1591
- globalThis.focus?.();
1592
- }
1593
- catch { /* cross-origin */ } };
1620
+ pullFocus = () => {
1621
+ try {
1622
+ globalThis.focus?.();
1623
+ }
1624
+ catch {
1625
+ /* cross-origin */
1626
+ }
1627
+ };
1594
1628
  // ── overlay flow ─────────────────────────────────────────────────────────────
1595
1629
  show(req) {
1596
1630
  this.closeModal();
1597
1631
  this.overlay = this.renderer.openOverlay(req) ?? null;
1598
1632
  }
1599
- openMenu() { this.emit('menuOpen'); this.openSettings(); }
1600
- openSettings() { this.emit('settingsOpen'); this.show({ kind: 'settings' }); }
1601
- openInfo() { this.emit('infoOpen'); this.show({ kind: 'gameInfo' }); }
1602
- openBuyBonus() { if (this.config.onBonusBuy) {
1603
- this.config.onBonusBuy();
1604
- return;
1605
- } this.show({ kind: 'buyBonus' }); }
1606
- openBetPicker() { this.show({ kind: 'betPicker' }); }
1607
- openAutoplayPicker() { this.show({ kind: 'autoplayPicker' }); }
1608
- openReplay(opts) { if (this.destroyed)
1609
- return; this.show({ kind: 'replay', opts }); }
1610
- openModal(opts) { this.show({ kind: 'modal', opts }); }
1633
+ openMenu() {
1634
+ this.emit('menuOpen');
1635
+ this.openSettings();
1636
+ }
1637
+ openSettings() {
1638
+ this.emit('settingsOpen');
1639
+ this.show({ kind: 'settings' });
1640
+ }
1641
+ openInfo() {
1642
+ this.emit('infoOpen');
1643
+ this.show({ kind: 'gameInfo' });
1644
+ }
1645
+ openBuyBonus() {
1646
+ if (this.config.onBonusBuy) {
1647
+ this.config.onBonusBuy();
1648
+ return;
1649
+ }
1650
+ this.show({ kind: 'buyBonus' });
1651
+ }
1652
+ openBetPicker() {
1653
+ this.show({ kind: 'betPicker' });
1654
+ }
1655
+ openAutoplayPicker() {
1656
+ this.show({ kind: 'autoplayPicker' });
1657
+ }
1658
+ openReplay(opts) {
1659
+ if (this.destroyed)
1660
+ return;
1661
+ this.show({ kind: 'replay', opts });
1662
+ }
1663
+ openModal(opts) {
1664
+ this.show({ kind: 'modal', opts });
1665
+ }
1611
1666
  /** Programmatically dismiss whatever overlay/modal is open. No-op when nothing is shown. */
1612
1667
  closeModal() {
1613
1668
  if (!this.overlay)
1614
1669
  return;
1615
1670
  this.overlay = null;
1616
1671
  this.soundRefresh = null;
1672
+ this.volumeRefresh = null;
1617
1673
  this.renderer.closeOverlay();
1618
1674
  }
1619
1675
  // ── sound ──────────────────────────────────────────────────────────────────
@@ -1623,7 +1679,25 @@ class ShellController extends EventEmitter {
1623
1679
  this.soundRefresh?.(on);
1624
1680
  this.renderer.refreshSoundIcon?.(on);
1625
1681
  }
1626
- setSoundRefresh(fn) { this.soundRefresh = fn; }
1682
+ setSoundRefresh(fn) {
1683
+ this.soundRefresh = fn;
1684
+ }
1685
+ // ── volume ─────────────────────────────────────────────────────────────────
1686
+ getVolume(key) {
1687
+ return this.state.volumes[key];
1688
+ }
1689
+ /** Set a volume slider (0..1). Shared by the slider control (drag) and game code (public API):
1690
+ * clamps, stores so a reopened Settings overlay reflects it, emits `settingChange`, and
1691
+ * live-updates the slider if the overlay is currently open. */
1692
+ setVolume(key, value) {
1693
+ const v = Math.max(0, Math.min(1, value));
1694
+ this.state.volumes[key] = v;
1695
+ this.emit('settingChange', { key, value: v });
1696
+ this.volumeRefresh?.(key, v);
1697
+ }
1698
+ setVolumeRefresh(fn) {
1699
+ this.volumeRefresh = fn;
1700
+ }
1627
1701
  // ── features ─────────────────────────────────────────────────────────────────
1628
1702
  activateFeature(bonus) {
1629
1703
  this.state.activeFeature = bonus;
@@ -1644,21 +1718,81 @@ class ShellController extends EventEmitter {
1644
1718
  if (to !== from)
1645
1719
  this.renderer.animateMoney(field, from, to);
1646
1720
  }
1647
- setBalance(n) { const from = this.prevBalance; this.state.balance = n; this.prevBalance = n; this.money('balance', from, n); }
1648
- setWin(n) { const from = this.prevWin; this.state.win = n; this.prevWin = n; this.money('win', from, n); }
1649
- setBet(n) { this.state.bet = n; this.renderer.renderBar(); }
1650
- setMode(mode) { if (mode === 'replay')
1651
- this.state.replay = true; this.state.mode = mode; this.renderer.renderBar(); }
1652
- setBusy(busy) { this.state.busy = busy; this.renderer.renderBar(); this.kbd?.notifyBusyChanged(busy); }
1653
- setAutoplay(a) { this.state.autoplay = a; this.renderer.renderBar(); }
1654
- setTurbo(level) { this.state.turbo = level; this.renderer.renderBar(); }
1655
- setBuyBonusEnabled(enabled) { this.state.buyBonusEnabled = enabled; this.renderer.renderBar(); }
1656
- setFreeSpins(fs) { this.state.freeSpins = fs; this.renderer.renderBar(); }
1657
- setTheme(theme) { this.config.theme = theme; this.tokens = resolveTheme(theme); this.renderer.applyTheme(this.tokens); this.renderer.renderBar(); }
1658
- setLanguage(lang) { this.config.language = lang; this.i18n = createI18n({ language: lang, isSocial: this.config.isSocial }); this.renderer.renderBar(); }
1659
- setSocial(isSocial) { this.config.isSocial = isSocial; this.i18n = createI18n({ language: this.config.language, isSocial }); this.renderer.renderBar(); }
1660
- setLayout(layout) { if (layout === this.layout)
1661
- return; this.layout = layout; this.renderer.setLayout(layout); this.renderer.renderBar(); }
1721
+ setBalance(n) {
1722
+ const from = this.prevBalance;
1723
+ this.state.balance = n;
1724
+ this.prevBalance = n;
1725
+ this.money('balance', from, n);
1726
+ }
1727
+ setWin(n) {
1728
+ const from = this.prevWin;
1729
+ this.state.win = n;
1730
+ this.prevWin = n;
1731
+ this.money('win', from, n);
1732
+ }
1733
+ setBet(n) {
1734
+ this.state.bet = n;
1735
+ this.renderer.renderBar();
1736
+ }
1737
+ setMode(mode) {
1738
+ if (mode === 'replay')
1739
+ this.state.replay = true;
1740
+ this.state.mode = mode;
1741
+ this.renderer.renderBar();
1742
+ }
1743
+ setBusy(busy) {
1744
+ this.state.busy = busy;
1745
+ this.renderer.renderBar();
1746
+ this.kbd?.notifyBusyChanged(busy);
1747
+ }
1748
+ setAutoplay(a) {
1749
+ this.state.autoplay = a;
1750
+ this.renderer.renderBar();
1751
+ }
1752
+ setTurbo(level) {
1753
+ this.state.turbo = level;
1754
+ this.renderer.renderBar();
1755
+ }
1756
+ setBuyBonusEnabled(enabled) {
1757
+ this.state.buyBonusEnabled = enabled;
1758
+ this.renderer.renderBar();
1759
+ }
1760
+ setFreeSpins(fs) {
1761
+ this.state.freeSpins = fs;
1762
+ this.state.bonus = null;
1763
+ this.renderer.renderBar();
1764
+ }
1765
+ /** Generic bonus readout (adventure / hold-and-spin / respins). Sets a game-supplied label+value
1766
+ * override for the bar hero and folds `totalWin` into the shared accumulator. Pairs with
1767
+ * `setMode('bonus')`. `setFreeSpins()` clears the override back to the derived current/total. */
1768
+ setBonus(b) {
1769
+ this.state.bonus = { label: b.label, value: b.value };
1770
+ this.state.freeSpins = { ...this.state.freeSpins, totalWin: b.totalWin };
1771
+ this.renderer.renderBar();
1772
+ }
1773
+ setTheme(theme) {
1774
+ this.config.theme = theme;
1775
+ this.tokens = resolveTheme(theme);
1776
+ this.renderer.applyTheme(this.tokens);
1777
+ this.renderer.renderBar();
1778
+ }
1779
+ setLanguage(lang) {
1780
+ this.config.language = lang;
1781
+ this.i18n = createI18n({ language: lang, isSocial: this.config.isSocial });
1782
+ this.renderer.renderBar();
1783
+ }
1784
+ setSocial(isSocial) {
1785
+ this.config.isSocial = isSocial;
1786
+ this.i18n = createI18n({ language: this.config.language, isSocial });
1787
+ this.renderer.renderBar();
1788
+ }
1789
+ setLayout(layout) {
1790
+ if (layout === this.layout)
1791
+ return;
1792
+ this.layout = layout;
1793
+ this.renderer.setLayout(layout);
1794
+ this.renderer.renderBar();
1795
+ }
1662
1796
  destroy() {
1663
1797
  if (this.destroyed)
1664
1798
  return Promise.resolve();
@@ -2380,8 +2514,10 @@ function iconBtn(ge, name, onClick, active = false) {
2380
2514
  b.className = `ge-iconbtn${active ? ' ge-active' : ''}`;
2381
2515
  b.dataset.ge = ge;
2382
2516
  b.innerHTML = icon(name);
2383
- b.addEventListener('click', () => { if (!b.disabled)
2384
- onClick(); });
2517
+ b.addEventListener('click', () => {
2518
+ if (!b.disabled)
2519
+ onClick();
2520
+ });
2385
2521
  return b;
2386
2522
  }
2387
2523
  function renderBottomBar(host) {
@@ -2397,15 +2533,13 @@ function renderBottomBar(host) {
2397
2533
  // All three modes share the base plaque layout. FS/replay hide the controls that don't apply
2398
2534
  // and add Free Spins + Total Win blocks on the left; the per-spin WIN uses the base pill.
2399
2535
  const isBase = state.mode === 'base';
2400
- const isFS = state.mode === 'freeSpins';
2536
+ const isFS = state.mode === 'freeSpins' || state.mode === 'bonus';
2401
2537
  // FS always shows the spins counter + accumulated Total Win (even €0); a replay shows them
2402
2538
  // only when it's a free-spins replay (freeSpins.total > 0).
2403
2539
  const showFsBlocks = isFS || (state.mode === 'replay' && state.freeSpins.total > 0);
2404
2540
  // Replay is a read-only historical round — there's no real balance to show, so hide it. Keyed on
2405
2541
  // the sticky `replay` flag (not `mode`) so it stays hidden through a replay's free-spins phase.
2406
- const balance = state.replay
2407
- ? null
2408
- : readout('balance', host.t('Balance'), fmt(state.balance));
2542
+ const balance = state.replay ? null : readout('balance', host.t('Balance'), fmt(state.balance));
2409
2543
  // With a feature active (e.g. Ante) the BET readout shows the effective stake, tinted with
2410
2544
  // the feature accent; the base state.bet is unchanged and returns once the feature is off.
2411
2545
  const feature = state.activeFeature;
@@ -2428,21 +2562,25 @@ function renderBottomBar(host) {
2428
2562
  betDown = iconBtn('bet-down', 'minus', () => host.actions.stepBet(-1));
2429
2563
  betUp = iconBtn('bet-up', 'plus', () => host.actions.stepBet(1));
2430
2564
  betValue.classList.add('ge-betbtn'); // tap the stake → bet picker
2431
- betValue.addEventListener('click', () => { if (!betLocked(host))
2432
- host.actions.openBetPicker(); });
2565
+ betValue.addEventListener('click', () => {
2566
+ if (!betLocked(host))
2567
+ host.actions.openBetPicker();
2568
+ });
2433
2569
  spin = spinButton(host);
2434
2570
  auto = config.features.autoplay ? autoButton(host) : null;
2435
- buy = (config.features.buyBonus !== false || config.onBonusBuy) ? buyBtn(host) : null;
2571
+ buy = config.features.buyBonus !== false || config.onBonusBuy ? buyBtn(host) : null;
2436
2572
  }
2437
2573
  const winEl = state.win > 0 ? readout('win', host.t('Win'), fmtWin(state.win)) : null;
2438
2574
  // FS/replay left blocks: spins counter + accumulated Total Win (shown even at €0).
2439
2575
  // current = number → "current / total"; current = null/undefined → just the (game-driven) total.
2440
2576
  const fs = state.freeSpins;
2441
- const fsText = fs.current == null ? `${fs.total}` : `${fs.current} / ${fs.total}`;
2577
+ const fsText = state.bonus?.value ?? (fs.current == null ? `${fs.total}` : `${fs.current} / ${fs.total}`);
2442
2578
  // In FS the spins counter takes the SPIN slot as a rectangular hero plaque (same white/black-ring
2443
2579
  // style as the SPIN disc); Total Win stays an inline readout.
2444
- const fsHero = showFsBlocks ? fsHeroPlaque(host, fsText) : null;
2445
- const fsTotalWin = showFsBlocks ? readout('fs-totalwin', host.t('Total win'), fmtWin(fs.totalWin)) : null;
2580
+ const fsHero = showFsBlocks ? fsHeroPlaque(host, fsText, state.bonus?.label) : null;
2581
+ const fsTotalWin = showFsBlocks
2582
+ ? readout('fs-totalwin', host.t('Total win'), fmtWin(fs.totalWin))
2583
+ : null;
2446
2584
  // The hero in the centre/spin position: SPIN in base, the FS counter in free spins, nothing else.
2447
2585
  const hero = isBase ? spin : fsHero;
2448
2586
  if (mobile) {
@@ -2485,13 +2623,13 @@ function renderBottomBar(host) {
2485
2623
  }
2486
2624
  /** Free-spins hero plaque — takes the SPIN slot in FS: same white disc/black-ring language as SPIN,
2487
2625
  * but a rounded RECTANGLE showing the spins counter ("3 / 10"). */
2488
- function fsHeroPlaque(host, text) {
2626
+ function fsHeroPlaque(host, text, label) {
2489
2627
  const el = document.createElement('div');
2490
2628
  el.className = 'ge-fs-hero';
2491
2629
  el.dataset.ge = 'fs-counter';
2492
2630
  const lbl = document.createElement('span');
2493
2631
  lbl.className = 'ge-fs-lbl';
2494
- lbl.textContent = host.t('Free spins');
2632
+ lbl.textContent = host.t(label ?? 'Free spins');
2495
2633
  const num = document.createElement('span');
2496
2634
  num.className = 'ge-fs-num';
2497
2635
  num.textContent = text;
@@ -2511,7 +2649,9 @@ function plaque(cls, children) {
2511
2649
  d.append(...children);
2512
2650
  return d;
2513
2651
  }
2514
- function compact(items) { return items.filter((x) => x !== null); }
2652
+ function compact(items) {
2653
+ return items.filter((x) => x !== null);
2654
+ }
2515
2655
  function buyBtn(host) {
2516
2656
  const buy = document.createElement('button');
2517
2657
  buy.className = 'ge-shell-buybonus';
@@ -2524,15 +2664,19 @@ function buyBtn(host) {
2524
2664
  buy.innerHTML = `<span>${host.t('DISABLE')}</span>`;
2525
2665
  buy.style.background = accent;
2526
2666
  buy.style.color = contrastText(accent);
2527
- buy.addEventListener('click', () => { if (!buy.disabled)
2528
- host.actions.deactivateFeature(); });
2667
+ buy.addEventListener('click', () => {
2668
+ if (!buy.disabled)
2669
+ host.actions.deactivateFeature();
2670
+ });
2529
2671
  }
2530
2672
  else {
2531
2673
  // Ticket icon (no text); keep the label for screen readers.
2532
2674
  buy.setAttribute('aria-label', host.t('BUY BONUS'));
2533
2675
  buy.innerHTML = `<span class="ge-bb-tk">${icon('ticket')}</span>`;
2534
- buy.addEventListener('click', () => { if (!buy.disabled)
2535
- host.actions.openBuyBonus(); });
2676
+ buy.addEventListener('click', () => {
2677
+ if (!buy.disabled)
2678
+ host.actions.openBuyBonus();
2679
+ });
2536
2680
  }
2537
2681
  return buy;
2538
2682
  }
@@ -2550,15 +2694,19 @@ function spinButton(host) {
2550
2694
  const rem = state.autoplay.remaining;
2551
2695
  const label = Number.isFinite(rem) ? String(rem) : '∞';
2552
2696
  sp.innerHTML = `<span class="ge-spin-stop">${icon('stop')}</span><span class="ge-spin-count">${label}</span>`;
2553
- sp.addEventListener('click', () => { if (!sp.disabled)
2554
- host.actions.stopAutoplay(); });
2697
+ sp.addEventListener('click', () => {
2698
+ if (!sp.disabled)
2699
+ host.actions.stopAutoplay();
2700
+ });
2555
2701
  }
2556
2702
  else {
2557
2703
  sp.innerHTML = icon('spin');
2558
2704
  if (state.busy)
2559
2705
  sp.classList.add('ge-spinning');
2560
- sp.addEventListener('click', () => { if (!sp.disabled)
2561
- host.actions.spin(); });
2706
+ sp.addEventListener('click', () => {
2707
+ if (!sp.disabled)
2708
+ host.actions.spin();
2709
+ });
2562
2710
  }
2563
2711
  return sp;
2564
2712
  }
@@ -2707,7 +2855,10 @@ function openSettingsModal(host) {
2707
2855
  return row;
2708
2856
  })();
2709
2857
  body.appendChild(sound);
2710
- // Volume sliders — full-width column rows with a live value readout
2858
+ // Volume sliders — full-width column rows with a live value readout. Positions are read from the
2859
+ // shell's stored volumes (not hardcoded to 100%), so reopening the overlay reflects the last set
2860
+ // value, and `host.setVolume()` from game code updates them live via the registered refreshers.
2861
+ const updaters = {};
2711
2862
  const slider = (key, label) => {
2712
2863
  const row = document.createElement('div');
2713
2864
  row.className = 'ge-ov-row ge-col';
@@ -2715,7 +2866,6 @@ function openSettingsModal(host) {
2715
2866
  head.className = 'ge-row-head';
2716
2867
  const val = document.createElement('span');
2717
2868
  val.className = 'ge-val';
2718
- val.textContent = '100%';
2719
2869
  head.innerHTML = `<span>${label}</span>`;
2720
2870
  head.appendChild(val);
2721
2871
  const input = document.createElement('input');
@@ -2723,19 +2873,23 @@ function openSettingsModal(host) {
2723
2873
  input.min = '0';
2724
2874
  input.max = '1';
2725
2875
  input.step = '0.05';
2726
- input.value = '1';
2727
2876
  input.className = 'ge-slider';
2728
2877
  input.dataset.ge = `setting-${key}`;
2878
+ const paint = (v) => { input.value = String(v); val.textContent = `${Math.round(v * 100)}%`; };
2879
+ paint(host.getVolume(key));
2729
2880
  input.addEventListener('input', () => {
2730
2881
  val.textContent = `${Math.round(Number(input.value) * 100)}%`;
2731
- host.emit('settingChange', { key, value: Number(input.value) });
2882
+ host.setVolume(key, Number(input.value));
2732
2883
  });
2884
+ updaters[key] = paint;
2733
2885
  row.append(head, input);
2734
2886
  return row;
2735
2887
  };
2736
2888
  body.appendChild(slider('master', host.t('Master volume')));
2737
2889
  body.appendChild(slider('music', host.t('Music')));
2738
2890
  body.appendChild(slider('sfx', host.t('SFX')));
2891
+ // Live-update sliders when volume changes via host.setVolume (shell clears on close).
2892
+ host.setVolumeRefresh((key, v) => updaters[key]?.(v));
2739
2893
  // Game info — full-width row button that opens its own overlay
2740
2894
  const gameInfo = document.createElement('button');
2741
2895
  gameInfo.className = 'ge-ov-row';