@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/pixi.esm.js CHANGED
@@ -64,9 +64,19 @@ function createInitialState(config) {
64
64
  turbo: 0,
65
65
  buyBonusEnabled: true,
66
66
  freeSpins: { current: 0, total: 0, totalWin: 0 },
67
+ bonus: null,
67
68
  activeFeature: null,
69
+ volumes: {
70
+ master: clampVolume(config.volumes?.master),
71
+ music: clampVolume(config.volumes?.music),
72
+ sfx: clampVolume(config.volumes?.sfx),
73
+ },
68
74
  };
69
75
  }
76
+ /** Clamp a configured volume to 0..1, defaulting to full (1) when unset/invalid. */
77
+ function clampVolume(v) {
78
+ return typeof v === 'number' && Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 1;
79
+ }
70
80
  /** Step bet up/down within availableBets, clamped at the ends. */
71
81
  function stepBet(state, direction) {
72
82
  const idx = state.availableBets.indexOf(state.bet);
@@ -1437,7 +1447,7 @@ class KeyboardController {
1437
1447
 
1438
1448
  // AUTO-GENERATED by scripts/gen-version.mjs — do not edit. Mirrors package.json "version".
1439
1449
  /** The @energy8platform/shell package version, stamped into the game-info footer. */
1440
- const PACKAGE_VERSION = '0.4.1';
1450
+ const PACKAGE_VERSION = '0.6.0';
1441
1451
 
1442
1452
  /** Apply defaults to the raw config (the mount target lives on the renderer, not here). */
1443
1453
  function resolveConfig(config) {
@@ -1454,6 +1464,7 @@ function resolveConfig(config) {
1454
1464
  features: config.features,
1455
1465
  theme: config.theme,
1456
1466
  onBonusBuy: config.onBonusBuy,
1467
+ volumes: config.volumes,
1457
1468
  version: config.version ?? '1.0.0',
1458
1469
  isSocial: config.isSocial ?? false,
1459
1470
  replay: config.replay ?? config.mode === 'replay',
@@ -1475,6 +1486,7 @@ class ShellController extends EventEmitter {
1475
1486
  kbd;
1476
1487
  overlay = null;
1477
1488
  soundRefresh = null;
1489
+ volumeRefresh = null;
1478
1490
  prevBalance;
1479
1491
  prevWin;
1480
1492
  destroyed = false;
@@ -1498,9 +1510,15 @@ class ShellController extends EventEmitter {
1498
1510
  this.renderer.renderBar();
1499
1511
  }
1500
1512
  // ── ShellHost ──────────────────────────────────────────────────────────────
1501
- t(text) { return this.i18n.t(text); }
1502
- formatCurrency(n, win = false) { return formatCurrency(n, this.config.currency, win); }
1503
- formatWin(value) { return this.formatCurrency(value, true); }
1513
+ t(text) {
1514
+ return this.i18n.t(text);
1515
+ }
1516
+ formatCurrency(n, win = false) {
1517
+ return formatCurrency(n, this.config.currency, win);
1518
+ }
1519
+ formatWin(value) {
1520
+ return this.formatCurrency(value, true);
1521
+ }
1504
1522
  notifyResize(w, h) {
1505
1523
  const layout = w !== 0 && h > w ? 'mobile' : 'wide';
1506
1524
  if (layout !== this.layout) {
@@ -1567,12 +1585,24 @@ class ShellController extends EventEmitter {
1567
1585
  // eslint-disable-next-line @typescript-eslint/no-this-alias
1568
1586
  const self = this;
1569
1587
  const host = {
1570
- get state() { return self.state; },
1571
- get hotkeysEnabled() { return self.config.features.hotkeys !== false; },
1572
- get spacebarEnabled() { return self.config.features.spacebar !== false; },
1573
- get turboLevels() { return self.config.features.turbo; },
1574
- get autoplayEnabled() { return self.config.features.autoplay != null; },
1575
- get buyBonusEnabled() { return self.config.features.buyBonus !== false; },
1588
+ get state() {
1589
+ return self.state;
1590
+ },
1591
+ get hotkeysEnabled() {
1592
+ return self.config.features.hotkeys !== false;
1593
+ },
1594
+ get spacebarEnabled() {
1595
+ return self.config.features.spacebar !== false;
1596
+ },
1597
+ get turboLevels() {
1598
+ return self.config.features.turbo;
1599
+ },
1600
+ get autoplayEnabled() {
1601
+ return self.config.features.autoplay != null;
1602
+ },
1603
+ get buyBonusEnabled() {
1604
+ return self.config.features.buyBonus !== false;
1605
+ },
1576
1606
  hasOpenLayer: () => self.overlay !== null,
1577
1607
  routeToLayer: (e) => self.overlay?.onKey?.(e) ?? false,
1578
1608
  spin: () => self.actions.spin(),
@@ -1588,33 +1618,59 @@ class ShellController extends EventEmitter {
1588
1618
  this.kbd = new KeyboardController(host);
1589
1619
  this.kbd.attach();
1590
1620
  }
1591
- pullFocus = () => { try {
1592
- globalThis.focus?.();
1593
- }
1594
- catch { /* cross-origin */ } };
1621
+ pullFocus = () => {
1622
+ try {
1623
+ globalThis.focus?.();
1624
+ }
1625
+ catch {
1626
+ /* cross-origin */
1627
+ }
1628
+ };
1595
1629
  // ── overlay flow ─────────────────────────────────────────────────────────────
1596
1630
  show(req) {
1597
1631
  this.closeModal();
1598
1632
  this.overlay = this.renderer.openOverlay(req) ?? null;
1599
1633
  }
1600
- openMenu() { this.emit('menuOpen'); this.openSettings(); }
1601
- openSettings() { this.emit('settingsOpen'); this.show({ kind: 'settings' }); }
1602
- openInfo() { this.emit('infoOpen'); this.show({ kind: 'gameInfo' }); }
1603
- openBuyBonus() { if (this.config.onBonusBuy) {
1604
- this.config.onBonusBuy();
1605
- return;
1606
- } this.show({ kind: 'buyBonus' }); }
1607
- openBetPicker() { this.show({ kind: 'betPicker' }); }
1608
- openAutoplayPicker() { this.show({ kind: 'autoplayPicker' }); }
1609
- openReplay(opts) { if (this.destroyed)
1610
- return; this.show({ kind: 'replay', opts }); }
1611
- openModal(opts) { this.show({ kind: 'modal', opts }); }
1634
+ openMenu() {
1635
+ this.emit('menuOpen');
1636
+ this.openSettings();
1637
+ }
1638
+ openSettings() {
1639
+ this.emit('settingsOpen');
1640
+ this.show({ kind: 'settings' });
1641
+ }
1642
+ openInfo() {
1643
+ this.emit('infoOpen');
1644
+ this.show({ kind: 'gameInfo' });
1645
+ }
1646
+ openBuyBonus() {
1647
+ if (this.config.onBonusBuy) {
1648
+ this.config.onBonusBuy();
1649
+ return;
1650
+ }
1651
+ this.show({ kind: 'buyBonus' });
1652
+ }
1653
+ openBetPicker() {
1654
+ this.show({ kind: 'betPicker' });
1655
+ }
1656
+ openAutoplayPicker() {
1657
+ this.show({ kind: 'autoplayPicker' });
1658
+ }
1659
+ openReplay(opts) {
1660
+ if (this.destroyed)
1661
+ return;
1662
+ this.show({ kind: 'replay', opts });
1663
+ }
1664
+ openModal(opts) {
1665
+ this.show({ kind: 'modal', opts });
1666
+ }
1612
1667
  /** Programmatically dismiss whatever overlay/modal is open. No-op when nothing is shown. */
1613
1668
  closeModal() {
1614
1669
  if (!this.overlay)
1615
1670
  return;
1616
1671
  this.overlay = null;
1617
1672
  this.soundRefresh = null;
1673
+ this.volumeRefresh = null;
1618
1674
  this.renderer.closeOverlay();
1619
1675
  }
1620
1676
  // ── sound ──────────────────────────────────────────────────────────────────
@@ -1624,7 +1680,25 @@ class ShellController extends EventEmitter {
1624
1680
  this.soundRefresh?.(on);
1625
1681
  this.renderer.refreshSoundIcon?.(on);
1626
1682
  }
1627
- setSoundRefresh(fn) { this.soundRefresh = fn; }
1683
+ setSoundRefresh(fn) {
1684
+ this.soundRefresh = fn;
1685
+ }
1686
+ // ── volume ─────────────────────────────────────────────────────────────────
1687
+ getVolume(key) {
1688
+ return this.state.volumes[key];
1689
+ }
1690
+ /** Set a volume slider (0..1). Shared by the slider control (drag) and game code (public API):
1691
+ * clamps, stores so a reopened Settings overlay reflects it, emits `settingChange`, and
1692
+ * live-updates the slider if the overlay is currently open. */
1693
+ setVolume(key, value) {
1694
+ const v = Math.max(0, Math.min(1, value));
1695
+ this.state.volumes[key] = v;
1696
+ this.emit('settingChange', { key, value: v });
1697
+ this.volumeRefresh?.(key, v);
1698
+ }
1699
+ setVolumeRefresh(fn) {
1700
+ this.volumeRefresh = fn;
1701
+ }
1628
1702
  // ── features ─────────────────────────────────────────────────────────────────
1629
1703
  activateFeature(bonus) {
1630
1704
  this.state.activeFeature = bonus;
@@ -1645,21 +1719,81 @@ class ShellController extends EventEmitter {
1645
1719
  if (to !== from)
1646
1720
  this.renderer.animateMoney(field, from, to);
1647
1721
  }
1648
- setBalance(n) { const from = this.prevBalance; this.state.balance = n; this.prevBalance = n; this.money('balance', from, n); }
1649
- setWin(n) { const from = this.prevWin; this.state.win = n; this.prevWin = n; this.money('win', from, n); }
1650
- setBet(n) { this.state.bet = n; this.renderer.renderBar(); }
1651
- setMode(mode) { if (mode === 'replay')
1652
- this.state.replay = true; this.state.mode = mode; this.renderer.renderBar(); }
1653
- setBusy(busy) { this.state.busy = busy; this.renderer.renderBar(); this.kbd?.notifyBusyChanged(busy); }
1654
- setAutoplay(a) { this.state.autoplay = a; this.renderer.renderBar(); }
1655
- setTurbo(level) { this.state.turbo = level; this.renderer.renderBar(); }
1656
- setBuyBonusEnabled(enabled) { this.state.buyBonusEnabled = enabled; this.renderer.renderBar(); }
1657
- setFreeSpins(fs) { this.state.freeSpins = fs; this.renderer.renderBar(); }
1658
- setTheme(theme) { this.config.theme = theme; this.tokens = resolveTheme(theme); this.renderer.applyTheme(this.tokens); this.renderer.renderBar(); }
1659
- setLanguage(lang) { this.config.language = lang; this.i18n = createI18n({ language: lang, isSocial: this.config.isSocial }); this.renderer.renderBar(); }
1660
- setSocial(isSocial) { this.config.isSocial = isSocial; this.i18n = createI18n({ language: this.config.language, isSocial }); this.renderer.renderBar(); }
1661
- setLayout(layout) { if (layout === this.layout)
1662
- return; this.layout = layout; this.renderer.setLayout(layout); this.renderer.renderBar(); }
1722
+ setBalance(n) {
1723
+ const from = this.prevBalance;
1724
+ this.state.balance = n;
1725
+ this.prevBalance = n;
1726
+ this.money('balance', from, n);
1727
+ }
1728
+ setWin(n) {
1729
+ const from = this.prevWin;
1730
+ this.state.win = n;
1731
+ this.prevWin = n;
1732
+ this.money('win', from, n);
1733
+ }
1734
+ setBet(n) {
1735
+ this.state.bet = n;
1736
+ this.renderer.renderBar();
1737
+ }
1738
+ setMode(mode) {
1739
+ if (mode === 'replay')
1740
+ this.state.replay = true;
1741
+ this.state.mode = mode;
1742
+ this.renderer.renderBar();
1743
+ }
1744
+ setBusy(busy) {
1745
+ this.state.busy = busy;
1746
+ this.renderer.renderBar();
1747
+ this.kbd?.notifyBusyChanged(busy);
1748
+ }
1749
+ setAutoplay(a) {
1750
+ this.state.autoplay = a;
1751
+ this.renderer.renderBar();
1752
+ }
1753
+ setTurbo(level) {
1754
+ this.state.turbo = level;
1755
+ this.renderer.renderBar();
1756
+ }
1757
+ setBuyBonusEnabled(enabled) {
1758
+ this.state.buyBonusEnabled = enabled;
1759
+ this.renderer.renderBar();
1760
+ }
1761
+ setFreeSpins(fs) {
1762
+ this.state.freeSpins = fs;
1763
+ this.state.bonus = null;
1764
+ this.renderer.renderBar();
1765
+ }
1766
+ /** Generic bonus readout (adventure / hold-and-spin / respins). Sets a game-supplied label+value
1767
+ * override for the bar hero and folds `totalWin` into the shared accumulator. Pairs with
1768
+ * `setMode('bonus')`. `setFreeSpins()` clears the override back to the derived current/total. */
1769
+ setBonus(b) {
1770
+ this.state.bonus = { label: b.label, value: b.value };
1771
+ this.state.freeSpins = { ...this.state.freeSpins, totalWin: b.totalWin };
1772
+ this.renderer.renderBar();
1773
+ }
1774
+ setTheme(theme) {
1775
+ this.config.theme = theme;
1776
+ this.tokens = resolveTheme(theme);
1777
+ this.renderer.applyTheme(this.tokens);
1778
+ this.renderer.renderBar();
1779
+ }
1780
+ setLanguage(lang) {
1781
+ this.config.language = lang;
1782
+ this.i18n = createI18n({ language: lang, isSocial: this.config.isSocial });
1783
+ this.renderer.renderBar();
1784
+ }
1785
+ setSocial(isSocial) {
1786
+ this.config.isSocial = isSocial;
1787
+ this.i18n = createI18n({ language: this.config.language, isSocial });
1788
+ this.renderer.renderBar();
1789
+ }
1790
+ setLayout(layout) {
1791
+ if (layout === this.layout)
1792
+ return;
1793
+ this.layout = layout;
1794
+ this.renderer.setLayout(layout);
1795
+ this.renderer.renderBar();
1796
+ }
1663
1797
  destroy() {
1664
1798
  if (this.destroyed)
1665
1799
  return Promise.resolve();
@@ -2922,8 +3056,12 @@ class TurboButton extends Container {
2922
3056
  this.accent = accent;
2923
3057
  this._paint(false);
2924
3058
  }
2925
- measureSize() { return { w: this.box, h: this.box }; }
2926
- setLayoutSize() { }
3059
+ measureSize() {
3060
+ return { w: this.box, h: this.box };
3061
+ }
3062
+ setLayoutSize() {
3063
+ /* fixed */
3064
+ }
2927
3065
  }
2928
3066
  /** A readout in the bar (white Oswald value, plaque-label caption, no shadow). */
2929
3067
  function readout(host, label, value, opts = {}) {
@@ -2971,13 +3109,19 @@ class BottomBar extends Container {
2971
3109
  buildWide() {
2972
3110
  const { state, tokens } = this.host;
2973
3111
  const isBase = state.mode === 'base';
2974
- const isFS = state.mode === 'freeSpins';
3112
+ const isFS = state.mode === 'freeSpins' || state.mode === 'bonus';
2975
3113
  const showFsBlocks = isFS || (state.mode === 'replay' && state.freeSpins.total > 0);
2976
3114
  // BUY BONUS — floats outside-left of the panel (base only)
2977
- this.buy = isBase ? this.buildBuy(BUY_W, 13, 3) ?? undefined : undefined;
3115
+ this.buy = isBase ? (this.buildBuy(BUY_W, 13, 3) ?? undefined) : undefined;
2978
3116
  // LEFT info group: menu · balance · (Total win) · (Win)
2979
3117
  const left = new FlexBox({ direction: 'row', align: 'center', gap: ZONE_GAP });
2980
- left.add(new IconButton('menu', { size: 36, glyph: 30, color: '#ffffff', hover: tokens.accent, onTap: () => this.host.actions.openMenu() }));
3118
+ left.add(new IconButton('menu', {
3119
+ size: 36,
3120
+ glyph: 30,
3121
+ color: '#ffffff',
3122
+ hover: tokens.accent,
3123
+ onTap: () => this.host.actions.openMenu(),
3124
+ }));
2981
3125
  if (!state.replay) {
2982
3126
  const bal = readout(this.host, 'Balance', this.host.fmt(state.balance));
2983
3127
  this.balanceValue = bal.valueText;
@@ -3016,16 +3160,30 @@ class BottomBar extends Container {
3016
3160
  if (isBase) {
3017
3161
  bet.eventMode = 'static';
3018
3162
  bet.cursor = 'pointer';
3019
- bet.on('pointertap', () => { if (!this.betLocked())
3020
- this.host.actions.openBetPicker(); });
3163
+ bet.on('pointertap', () => {
3164
+ if (!this.betLocked())
3165
+ this.host.actions.openBetPicker();
3166
+ });
3021
3167
  }
3022
3168
  const betGroup = new FlexBox({ direction: 'row', align: 'center', gap: 8 });
3023
3169
  betGroup.add(bet);
3024
3170
  if (isBase) {
3025
3171
  // plain (borderless) +/- icons, bolder — stacked
3026
3172
  const step = new FlexBox({ direction: 'column', align: 'center', gap: 2 });
3027
- this.betUp = new IconButton('plus', { size: 24, glyph: 22, color: '#ffffff', hover: tokens.accent, onTap: () => this.onBet(1) });
3028
- this.betDown = new IconButton('minus', { size: 24, glyph: 22, color: '#ffffff', hover: tokens.accent, onTap: () => this.onBet(-1) });
3173
+ this.betUp = new IconButton('plus', {
3174
+ size: 24,
3175
+ glyph: 22,
3176
+ color: '#ffffff',
3177
+ hover: tokens.accent,
3178
+ onTap: () => this.onBet(1),
3179
+ });
3180
+ this.betDown = new IconButton('minus', {
3181
+ size: 24,
3182
+ glyph: 22,
3183
+ color: '#ffffff',
3184
+ hover: tokens.accent,
3185
+ onTap: () => this.onBet(-1),
3186
+ });
3029
3187
  step.add(this.betUp);
3030
3188
  step.add(this.betDown);
3031
3189
  betGroup.add(step);
@@ -3033,15 +3191,28 @@ class BottomBar extends Container {
3033
3191
  const spinWrap = new FlexBox({ direction: 'row', align: 'center', gap: 8 });
3034
3192
  if (isBase && config.features.autoplay) {
3035
3193
  this.autoBtn = new IconButton('autoplay', {
3036
- size: DISC, glyph: 25, disc: tokens.btn, color: tokens.btnInk, hover: tokens.accent, activeColor: tokens.accent,
3037
- active: state.autoplay.active, onTap: () => this.onAutoplay(),
3194
+ size: DISC,
3195
+ glyph: 25,
3196
+ disc: tokens.btn,
3197
+ color: tokens.btnInk,
3198
+ hover: tokens.accent,
3199
+ activeColor: tokens.accent,
3200
+ active: state.autoplay.active,
3201
+ onTap: () => this.onAutoplay(),
3038
3202
  });
3039
3203
  if (state.autoplay.active)
3040
3204
  this.autoBtn.setGlow(true);
3041
3205
  spinWrap.add(this.autoBtn);
3042
3206
  }
3043
3207
  if (isBase) {
3044
- this.spin = new SpinDisc({ size: SPIN, glyph: 65, tokens, ticker: this.host.ticker, onSpin: () => this.host.actions.spin(), onStop: () => this.stopAutoplay() });
3208
+ this.spin = new SpinDisc({
3209
+ size: SPIN,
3210
+ glyph: 65,
3211
+ tokens,
3212
+ ticker: this.host.ticker,
3213
+ onSpin: () => this.host.actions.spin(),
3214
+ onStop: () => this.stopAutoplay(),
3215
+ });
3045
3216
  if (state.autoplay.active)
3046
3217
  this.spin.setAutoplay(true, state.autoplay.remaining);
3047
3218
  if (state.busy)
@@ -3050,13 +3221,23 @@ class BottomBar extends Container {
3050
3221
  }
3051
3222
  else if (showFsBlocks) {
3052
3223
  const fs = state.freeSpins;
3053
- const fsText = fs.current == null ? `${fs.total}` : `${fs.current} / ${fs.total}`;
3054
- spinWrap.add(new FsHero({ label: this.host.t('Free spins'), value: fsText, tokens, height: SPIN }));
3224
+ const fsText = state.bonus?.value ?? (fs.current == null ? `${fs.total}` : `${fs.current} / ${fs.total}`);
3225
+ spinWrap.add(new FsHero({
3226
+ label: this.host.t(state.bonus?.label ?? 'Free spins'),
3227
+ value: fsText,
3228
+ tokens,
3229
+ height: SPIN,
3230
+ }));
3055
3231
  }
3056
3232
  if (config.features.turbo > 0) {
3057
3233
  this.turboBtn = new TurboButton({
3058
- size: DISC, glyph: 19, discFill: tokens.btn, discBorder: 2, accent: tokens.accent,
3059
- level: state.turbo, onTap: () => this.onTurbo(),
3234
+ size: DISC,
3235
+ glyph: 19,
3236
+ discFill: tokens.btn,
3237
+ discBorder: 2,
3238
+ accent: tokens.accent,
3239
+ level: state.turbo,
3240
+ onTap: () => this.onTurbo(),
3060
3241
  });
3061
3242
  spinWrap.add(this.turboBtn);
3062
3243
  }
@@ -3074,21 +3255,49 @@ class BottomBar extends Container {
3074
3255
  const feature = state.activeFeature;
3075
3256
  if (feature) {
3076
3257
  const accent = effectiveAccent(feature);
3077
- return new BuyBonusBadge({ size, fontSize, border, bg: accent, fg: contrastText(accent), label: this.host.t('DISABLE'), tokens, ticker: this.host.ticker, onTap: () => this.host.actions.deactivateFeature() });
3258
+ return new BuyBonusBadge({
3259
+ size,
3260
+ fontSize,
3261
+ border,
3262
+ bg: accent,
3263
+ fg: contrastText(accent),
3264
+ label: this.host.t('DISABLE'),
3265
+ tokens,
3266
+ ticker: this.host.ticker,
3267
+ onTap: () => this.host.actions.deactivateFeature(),
3268
+ });
3078
3269
  }
3079
- return new BuyBonusBadge({ size, border, bg: tokens.accent, icon: 'ticket', iconSize: size * 0.55, iconColor: tokens.btnInk, label: '', tokens, ticker: this.host.ticker, onTap: () => this.host.actions.openBuyBonus() });
3270
+ return new BuyBonusBadge({
3271
+ size,
3272
+ border,
3273
+ bg: tokens.accent,
3274
+ icon: 'ticket',
3275
+ iconSize: size * 0.55,
3276
+ iconColor: tokens.btnInk,
3277
+ label: '',
3278
+ tokens,
3279
+ ticker: this.host.ticker,
3280
+ onTap: () => this.host.actions.openBuyBonus(),
3281
+ });
3080
3282
  }
3081
3283
  // ── mobile / portrait ─────────────────────────────────────────────────────
3082
3284
  buildMobile() {
3083
3285
  const { state, config, tokens } = this.host;
3084
3286
  const isBase = state.mode === 'base';
3085
- const isFS = state.mode === 'freeSpins';
3287
+ const isFS = state.mode === 'freeSpins' || state.mode === 'bonus';
3086
3288
  const showFsBlocks = isFS || (state.mode === 'replay' && state.freeSpins.total > 0);
3087
3289
  const W = this.host.screenW - 2 * M_SIDE;
3088
3290
  // hero (centre): SPIN in base, FS counter in free spins
3089
3291
  let hero;
3090
3292
  if (isBase) {
3091
- this.spin = new SpinDisc({ size: SPIN, glyph: 65, tokens, ticker: this.host.ticker, onSpin: () => this.host.actions.spin(), onStop: () => this.stopAutoplay() });
3293
+ this.spin = new SpinDisc({
3294
+ size: SPIN,
3295
+ glyph: 65,
3296
+ tokens,
3297
+ ticker: this.host.ticker,
3298
+ onSpin: () => this.host.actions.spin(),
3299
+ onStop: () => this.stopAutoplay(),
3300
+ });
3092
3301
  if (state.autoplay.active)
3093
3302
  this.spin.setAutoplay(true, state.autoplay.remaining);
3094
3303
  if (state.busy)
@@ -3097,25 +3306,58 @@ class BottomBar extends Container {
3097
3306
  }
3098
3307
  else if (showFsBlocks) {
3099
3308
  const fs = state.freeSpins;
3100
- const fsText = fs.current == null ? `${fs.total}` : `${fs.current} / ${fs.total}`;
3101
- hero = new FsHero({ label: this.host.t('Free spins'), value: fsText, tokens, height: SPIN });
3309
+ const fsText = state.bonus?.value ?? (fs.current == null ? `${fs.total}` : `${fs.current} / ${fs.total}`);
3310
+ hero = new FsHero({
3311
+ label: this.host.t(state.bonus?.label ?? 'Free spins'),
3312
+ value: fsText,
3313
+ tokens,
3314
+ height: SPIN,
3315
+ });
3102
3316
  }
3103
- const menu = new IconButton('menu', { size: 40, glyph: 26, color: '#ffffff', hover: tokens.accent, onTap: () => this.host.actions.openMenu() });
3317
+ const menu = new IconButton('menu', {
3318
+ size: 40,
3319
+ glyph: 26,
3320
+ color: '#ffffff',
3321
+ hover: tokens.accent,
3322
+ onTap: () => this.host.actions.openMenu(),
3323
+ });
3104
3324
  // autoplay is a base-mode control only — hidden in free spins / replay (matches the DOM bar)
3105
3325
  if (isBase && config.features.autoplay) {
3106
- this.autoBtn = new IconButton('autoplay', { size: 40, glyph: 26, color: '#ffffff', hover: tokens.accent, activeColor: tokens.accent, active: state.autoplay.active, onTap: () => this.onAutoplay() });
3326
+ this.autoBtn = new IconButton('autoplay', {
3327
+ size: 40,
3328
+ glyph: 26,
3329
+ color: '#ffffff',
3330
+ hover: tokens.accent,
3331
+ activeColor: tokens.accent,
3332
+ active: state.autoplay.active,
3333
+ onTap: () => this.onAutoplay(),
3334
+ });
3107
3335
  if (state.autoplay.active)
3108
3336
  this.autoBtn.setGlow(true);
3109
3337
  }
3110
3338
  if (config.features.turbo > 0) {
3111
3339
  this.turboBtn = new TurboButton({
3112
- size: 40, glyph: 22, discFill: tokens.btn, discBorder: 2, accent: tokens.accent,
3113
- level: state.turbo, onTap: () => this.onTurbo(),
3340
+ size: 40,
3341
+ glyph: 22,
3342
+ discFill: tokens.btn,
3343
+ discBorder: 2,
3344
+ accent: tokens.accent,
3345
+ level: state.turbo,
3346
+ onTap: () => this.onTurbo(),
3114
3347
  });
3115
3348
  }
3116
- const buy = isBase ? this.buildBuy(M_BUY, 9, 2) ?? undefined : undefined;
3349
+ const buy = isBase ? (this.buildBuy(M_BUY, 9, 2) ?? undefined) : undefined;
3117
3350
  // level 1 — controls bar (dark)
3118
- const controls = new FlexBox({ direction: 'row', align: 'center', justify: 'space-between', gap: 0, width: W, height: M_CTRL_H, padding: { left: 18, right: 18 }, background: { fill: tokens.bar, radius: 16 } });
3351
+ const controls = new FlexBox({
3352
+ direction: 'row',
3353
+ align: 'center',
3354
+ justify: 'space-between',
3355
+ gap: 0,
3356
+ width: W,
3357
+ height: M_CTRL_H,
3358
+ padding: { left: 18, right: 18 },
3359
+ background: { fill: tokens.bar, radius: 16 },
3360
+ });
3119
3361
  if (isBase && hero) {
3120
3362
  const SIDE = 12;
3121
3363
  const lz = new FlexBox({ direction: 'row', align: 'center', gap: SIDE, justify: 'start' });
@@ -3151,7 +3393,12 @@ class BottomBar extends Container {
3151
3393
  const heroW = hero ? (heroSized.measureSize?.().w ?? hero.getLocalBounds().width) : 0;
3152
3394
  const fixed = ICON /* menu */ + (this.autoBtn ? ICON : 0) + heroW + (this.turboBtn ? ICON : 0);
3153
3395
  const twSlot = Math.max(60, W - 2 * PAD - fixed - GAPS);
3154
- controls.add(readout(this.host, 'Total win', this.host.fmtWin(state.freeSpins.totalWin), { color: '#ffffff', muted: '#ffffff', align: 'center', maxWidth: twSlot }));
3396
+ controls.add(readout(this.host, 'Total win', this.host.fmtWin(state.freeSpins.totalWin), {
3397
+ color: '#ffffff',
3398
+ muted: '#ffffff',
3399
+ align: 'center',
3400
+ maxWidth: twSlot,
3401
+ }));
3155
3402
  }
3156
3403
  if (this.turboBtn)
3157
3404
  controls.add(this.turboBtn);
@@ -3162,36 +3409,76 @@ class BottomBar extends Container {
3162
3409
  const feature = state.activeFeature;
3163
3410
  const betShown = feature ? state.bet * feature.priceMultiplier : state.bet;
3164
3411
  if (isBase) {
3165
- this.betDown = new IconButton('minus', { size: 26, glyph: 18, color: '#ffffff', hover: tokens.accent, onTap: () => this.onBet(-1) });
3412
+ this.betDown = new IconButton('minus', {
3413
+ size: 26,
3414
+ glyph: 18,
3415
+ color: '#ffffff',
3416
+ hover: tokens.accent,
3417
+ onTap: () => this.onBet(-1),
3418
+ });
3166
3419
  betGroup.add(this.betDown);
3167
3420
  }
3168
- const bet = new Readout({ label: this.host.t('Bet'), value: this.host.fmt(betShown), muted: feature ? effectiveAccent(feature) : tokens.plaqueLabel, fg: feature ? effectiveAccent(feature) : '#ffffff', valueSize: 11, align: 'center', fixedWidth: 76, shadow: false });
3421
+ const bet = new Readout({
3422
+ label: this.host.t('Bet'),
3423
+ value: this.host.fmt(betShown),
3424
+ muted: feature ? effectiveAccent(feature) : tokens.plaqueLabel,
3425
+ fg: feature ? effectiveAccent(feature) : '#ffffff',
3426
+ valueSize: 11,
3427
+ align: 'center',
3428
+ fixedWidth: 76,
3429
+ shadow: false,
3430
+ });
3169
3431
  this.betReadout = bet;
3170
3432
  if (isBase) {
3171
3433
  bet.eventMode = 'static';
3172
3434
  bet.cursor = 'pointer';
3173
- bet.on('pointertap', () => { if (!this.betLocked())
3174
- this.host.actions.openBetPicker(); });
3435
+ bet.on('pointertap', () => {
3436
+ if (!this.betLocked())
3437
+ this.host.actions.openBetPicker();
3438
+ });
3175
3439
  }
3176
3440
  betGroup.add(bet);
3177
3441
  if (isBase) {
3178
- this.betUp = new IconButton('plus', { size: 26, glyph: 18, color: '#ffffff', hover: tokens.accent, onTap: () => this.onBet(1) });
3442
+ this.betUp = new IconButton('plus', {
3443
+ size: 26,
3444
+ glyph: 18,
3445
+ color: '#ffffff',
3446
+ hover: tokens.accent,
3447
+ onTap: () => this.onBet(1),
3448
+ });
3179
3449
  betGroup.add(this.betUp);
3180
3450
  }
3181
3451
  betGroup.layout();
3182
3452
  const innerW = W - 28; // pad 14 each side
3183
3453
  const slot = Math.max(40, (innerW - betGroup.outerWidth - 20) / 2);
3184
- const info = new FlexBox({ direction: 'row', align: 'center', justify: 'space-between', width: W, height: M_INFO_H, padding: { left: 14, right: 14 }, gap: 10, background: { fill: tokens.plaqueGlass, radius: 12 } });
3454
+ const info = new FlexBox({
3455
+ direction: 'row',
3456
+ align: 'center',
3457
+ justify: 'space-between',
3458
+ width: W,
3459
+ height: M_INFO_H,
3460
+ padding: { left: 14, right: 14 },
3461
+ gap: 10,
3462
+ background: { fill: tokens.plaqueGlass, radius: 12 },
3463
+ });
3185
3464
  // replay is a read-only historical round — no real balance to show (sticky `replay`, so it stays
3186
3465
  // hidden through a replay's free-spins phase too); matches the DOM bar.
3187
3466
  if (!state.replay) {
3188
- const bal = readout(this.host, 'Balance', this.host.fmt(state.balance), { valueSize: 11, align: 'left', fixedWidth: slot });
3467
+ const bal = readout(this.host, 'Balance', this.host.fmt(state.balance), {
3468
+ valueSize: 11,
3469
+ align: 'left',
3470
+ fixedWidth: slot,
3471
+ });
3189
3472
  this.balanceValue = bal.valueText;
3190
3473
  info.add(bal);
3191
3474
  }
3192
3475
  info.add(betGroup);
3193
3476
  // WIN always present (no jiggle on win↔0)
3194
- const win = readout(this.host, 'Win', this.host.fmtWin(state.win), { valueSize: 11, align: 'right', fixedWidth: slot });
3477
+ const win = readout(this.host, 'Win', this.host.fmtWin(state.win), {
3478
+ valueSize: 11,
3479
+ align: 'right',
3480
+ fixedWidth: slot,
3481
+ });
3195
3482
  this.winValue = win.valueText;
3196
3483
  info.add(win);
3197
3484
  info.layout();
@@ -3282,7 +3569,7 @@ class BottomBar extends Container {
3282
3569
  left.position.set(panelX + PANEL_PAD, panelCenterY - left.outerHeight / 2);
3283
3570
  right.position.set(panelRight - PANEL_PAD - right.outerWidth, panelCenterY - right.outerHeight / 2);
3284
3571
  this.inner.scale.set(s);
3285
- this.inner.position.set((W - barW * s) / 2, (H - WIDE_PAD_BOTTOM) - SPIN * s);
3572
+ this.inner.position.set((W - barW * s) / 2, H - WIDE_PAD_BOTTOM - SPIN * s);
3286
3573
  this.measureFootprint();
3287
3574
  }
3288
3575
  applyFitMobile() {
@@ -3688,6 +3975,12 @@ class Slider extends Container {
3688
3975
  this.thumb.circle(cx, cy, r).fill('#ffffff');
3689
3976
  this.hitArea = new Rectangle(0, 0, this.w, h);
3690
3977
  }
3978
+ /** Programmatically move the thumb (0..1) without firing onInput — for live updates driven by
3979
+ * `host.setVolume()` while the overlay is open. */
3980
+ setValue(v) {
3981
+ this._value = Math.max(0, Math.min(1, v));
3982
+ this.draw();
3983
+ }
3691
3984
  setLayoutSize(w) {
3692
3985
  if (w != null)
3693
3986
  this.w = w;
@@ -3776,10 +4069,13 @@ function buildBody$1(host, width) {
3776
4069
  speaker.active = on;
3777
4070
  });
3778
4071
  col.add(glassRow(host, [textNode(host, host.t('Sound')), new Spacer(), speaker]));
3779
- // Volume sliders
3780
- col.add(sliderRow(host, width, 'master', host.t('Master volume')));
3781
- col.add(sliderRow(host, width, 'music', host.t('Music')));
3782
- col.add(sliderRow(host, width, 'sfx', host.t('SFX')));
4072
+ // Volume sliders — positions read from the shell's stored volumes (stateful across opens); the
4073
+ // registered refreshers let `host.setVolume()` from game code move the thumbs live.
4074
+ const updaters = {};
4075
+ col.add(sliderRow(host, width, 'master', host.t('Master volume'), updaters));
4076
+ col.add(sliderRow(host, width, 'music', host.t('Music'), updaters));
4077
+ col.add(sliderRow(host, width, 'sfx', host.t('SFX'), updaters));
4078
+ host.setVolumeRefresh?.((key, v) => updaters[key]?.(v));
3783
4079
  // Game info link
3784
4080
  const infoIcon = makeIcon('info', 22, '#ffffff');
3785
4081
  const infoLabel = textNode(host, host.t('Game info'));
@@ -3823,7 +4119,7 @@ function glassRow(host, children, opts = {}) {
3823
4119
  return row;
3824
4120
  }
3825
4121
  /** A column row: head (label + live % value) over a draggable slider. */
3826
- function sliderRow(host, bodyWidth, key, label) {
4122
+ function sliderRow(host, bodyWidth, key, label, updaters) {
3827
4123
  const row = new FlexBox({
3828
4124
  direction: 'column',
3829
4125
  align: 'stretch',
@@ -3832,14 +4128,19 @@ function sliderRow(host, bodyWidth, key, label) {
3832
4128
  background: { fill: host.tokens.plaqueGlass, radius: 16 },
3833
4129
  });
3834
4130
  const head = new FlexBox({ direction: 'row', align: 'center', justify: 'space-between' });
3835
- const valueText = makeText('100%', { size: 13, weight: '700', color: host.tokens.plaqueLabel });
4131
+ const initial = host.getVolume(key);
4132
+ const valueText = makeText(`${Math.round(initial * 100)}%`, { size: 13, weight: '700', color: host.tokens.plaqueLabel });
3836
4133
  head.add(makeText(label, { size: 14, weight: '600', color: '#ffffff' }));
3837
4134
  head.add(new Spacer(), { grow: 1 });
3838
4135
  head.add(valueText);
3839
- const slider = new Slider(host, 1, (v) => {
4136
+ const slider = new Slider(host, initial, (v) => {
3840
4137
  valueText.text = `${Math.round(v * 100)}%`;
3841
- host.emit('settingChange', { key, value: v });
4138
+ host.setVolume(key, v);
3842
4139
  });
4140
+ updaters[key] = (v) => {
4141
+ valueText.text = `${Math.round(v * 100)}%`;
4142
+ slider.setValue(v);
4143
+ };
3843
4144
  row.add(head);
3844
4145
  row.add(slider);
3845
4146
  return row;
@@ -5869,6 +6170,9 @@ class PixiRenderer {
5869
6170
  notifyResize: (w, h) => host.notifyResize(w, h),
5870
6171
  setSound: (on) => host.setSound(on),
5871
6172
  setSoundRefresh: (fn) => host.setSoundRefresh(fn),
6173
+ getVolume: (key) => host.getVolume(key),
6174
+ setVolume: (key, v) => host.setVolume(key, v),
6175
+ setVolumeRefresh: (fn) => host.setVolumeRefresh(fn),
5872
6176
  // — Pixi-specific surface —
5873
6177
  get ticker() { return self.app.ticker; },
5874
6178
  get canvas() { return self.app.canvas; },