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