@energy8platform/shell 0.2.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.
Files changed (67) hide show
  1. package/dist/html.cjs.js +3894 -0
  2. package/dist/html.cjs.js.map +1 -0
  3. package/dist/html.d.ts +616 -0
  4. package/dist/html.esm.js +3879 -0
  5. package/dist/html.esm.js.map +1 -0
  6. package/dist/index.cjs.js +1593 -0
  7. package/dist/index.cjs.js.map +1 -0
  8. package/dist/index.d.ts +554 -0
  9. package/dist/index.esm.js +1582 -0
  10. package/dist/index.esm.js.map +1 -0
  11. package/dist/pixi.cjs.js +5740 -0
  12. package/dist/pixi.cjs.js.map +1 -0
  13. package/dist/pixi.d.ts +682 -0
  14. package/dist/pixi.esm.js +5726 -0
  15. package/dist/pixi.esm.js.map +1 -0
  16. package/package.json +79 -0
  17. package/src/core/EventEmitter.ts +55 -0
  18. package/src/core/ShellController.ts +234 -0
  19. package/src/core/colors.ts +32 -0
  20. package/src/core/fonts-digits.ts +12 -0
  21. package/src/core/fonts.ts +13 -0
  22. package/src/core/format.ts +39 -0
  23. package/src/core/i18n.ts +96 -0
  24. package/src/core/index.ts +35 -0
  25. package/src/core/keyboard.ts +229 -0
  26. package/src/core/locales.ts +864 -0
  27. package/src/core/motion.ts +10 -0
  28. package/src/core/renderer.ts +121 -0
  29. package/src/core/state.ts +31 -0
  30. package/src/core/theme.ts +81 -0
  31. package/src/core/types.ts +269 -0
  32. package/src/core/version.ts +3 -0
  33. package/src/ui/html/HtmlRenderer.ts +292 -0
  34. package/src/ui/html/components/BottomBar.ts +240 -0
  35. package/src/ui/html/components/BuyBonus.ts +326 -0
  36. package/src/ui/html/components/GameInfo.ts +384 -0
  37. package/src/ui/html/components/Modal.ts +36 -0
  38. package/src/ui/html/components/ReplayModal.ts +58 -0
  39. package/src/ui/html/components/Settings.ts +59 -0
  40. package/src/ui/html/components/pickers.ts +146 -0
  41. package/src/ui/html/icons-preview.svg +224 -0
  42. package/src/ui/html/icons.ts +31 -0
  43. package/src/ui/html/index.ts +36 -0
  44. package/src/ui/html/motion-dom.ts +29 -0
  45. package/src/ui/html/primitives.ts +85 -0
  46. package/src/ui/html/shell.css.ts +528 -0
  47. package/src/ui/html/theme-css.ts +21 -0
  48. package/src/ui/pixi/PixiRenderer.ts +350 -0
  49. package/src/ui/pixi/components/BottomBar.ts +477 -0
  50. package/src/ui/pixi/components/BuyBonus.ts +763 -0
  51. package/src/ui/pixi/components/GameInfo.ts +559 -0
  52. package/src/ui/pixi/components/Modal.ts +40 -0
  53. package/src/ui/pixi/components/ReplayModal.ts +80 -0
  54. package/src/ui/pixi/components/Settings.ts +117 -0
  55. package/src/ui/pixi/components/pickers.ts +170 -0
  56. package/src/ui/pixi/context.ts +52 -0
  57. package/src/ui/pixi/icons.ts +45 -0
  58. package/src/ui/pixi/index.ts +56 -0
  59. package/src/ui/pixi/motion-pixi.ts +58 -0
  60. package/src/ui/pixi/pixi-icon.ts +119 -0
  61. package/src/ui/pixi/primitives/card.ts +227 -0
  62. package/src/ui/pixi/primitives/controls.ts +243 -0
  63. package/src/ui/pixi/primitives/flex.ts +335 -0
  64. package/src/ui/pixi/primitives/overlay.ts +181 -0
  65. package/src/ui/pixi/primitives/scroll.ts +117 -0
  66. package/src/ui/pixi/primitives/widgets.ts +680 -0
  67. package/src/ui/pixi/text.ts +131 -0
@@ -0,0 +1,477 @@
1
+ import { Container, Graphics, Rectangle, Text } from 'pixi.js';
2
+ import type { PixiComponentContext } from '../context';
3
+ import { effectiveAccent, contrastText } from '@/core/colors';
4
+ import { FlexBox } from '../primitives/flex';
5
+ import { roundedPath } from '../primitives/flex';
6
+ import {
7
+ IconButton,
8
+ Readout,
9
+ SpinDisc,
10
+ BuyBonusBadge,
11
+ FsHero,
12
+ divider,
13
+ attachHover,
14
+ attachPress,
15
+ } from '../primitives/widgets';
16
+ import { IconView, makeRingedIcon } from '../pixi-icon';
17
+
18
+ // ── design constants (mirror the DOM `.ge-bar-panel` / mobile rules) ──────────
19
+ const BAR_H = 68; // continuous dark panel height
20
+ const SPIN = 84; // hero disc — pops above/below the bar
21
+ const SPIN_POP = (SPIN - BAR_H) / 2; // 8 — how far the disc sticks out top/bottom
22
+ const MAX_BAR_W = 850; // .ge-shell-bottom max-width
23
+ const OUTER_PAD = 14; // .ge-shell-bottom padding
24
+ const PANEL_PAD = 14; // .ge-bar-panel padding
25
+ const ZONE_GAP = 12; // .ge-zone gap
26
+ const ROW_GAP = 10; // BUY BONUS ↔ panel gap
27
+ const MID_GAP = 24; // minimum gap between the left info group and the right controls
28
+ const BAR_REF_W = 840; // zoom reference width
29
+ const BAR_MIN_SCALE = 0.5;
30
+ const WIDE_PAD_BOTTOM = 8;
31
+ export const WIDE_BAR_H = WIDE_PAD_BOTTOM + SPIN; // ≈92
32
+
33
+ const BUY_W = 62; // buy-bonus disc on desktop
34
+ const DISC = 38; // white-disc icon button box (auto/turbo)
35
+ const FS_BOX = 90; // fixed bet-value box width
36
+
37
+ // mobile
38
+ const M_CTRL_H = 62, M_INFO_H = 40, M_GAP = 10, M_PAD_BOTTOM = 8, M_SIDE = 10;
39
+ const M_BUY = 50;
40
+ export const MOBILE_BAR_H = M_PAD_BOTTOM + SPIN + M_GAP + M_INFO_H; // spin pops above the controls
41
+
42
+ /** Three-state turbo button: single `turbo1` bolt on a white disc, with fill+ring treatment
43
+ * matching the DOM redesign. Level is conveyed by fill colour + ring colour (not glyph swap):
44
+ * - off (0): bolt fill #fff + BLACK ring, container alpha .5
45
+ * - L1: bolt fill #fff + ACCENT ring, alpha 1
46
+ * - L≥2: bolt fill ACCENT + ACCENT ring, alpha 1
47
+ * Hover at any level: bolt fill+ring → accent, disc border → accent (standard bar-button hover). */
48
+ class TurboButton extends Container {
49
+ private box: number;
50
+ private view: IconView;
51
+ private discG: Graphics;
52
+ private discBorder: number;
53
+ private level: number;
54
+ private accent: string;
55
+
56
+ constructor(opts: { size: number; glyph: number; discFill: string; discBorder?: number; accent: string; level: number; onTap: () => void }) {
57
+ super();
58
+ this.box = opts.size;
59
+ this.accent = opts.accent;
60
+ this.level = opts.level;
61
+ this.discBorder = opts.discBorder ?? 2;
62
+ this.discG = new Graphics();
63
+ this.addChild(this.discG);
64
+ const { fill, ring } = this._colors(false);
65
+ this.view = makeRingedIcon('turbo1', opts.glyph, fill, ring);
66
+ this.view.position.set((this.box - opts.glyph) / 2, (this.box - opts.glyph) / 2);
67
+ this.addChild(this.view);
68
+ // off is no longer dimmed — it reads as off via the black ring (DOM dropped the opacity:.5 rule)
69
+ this.eventMode = 'static';
70
+ this.cursor = 'pointer';
71
+ this.hitArea = new Rectangle(0, 0, this.box, this.box);
72
+ this._paint(false);
73
+ attachHover(this, () => this._paint(true), () => this._paint(false));
74
+ attachPress(this, 0.92, () => opts.onTap());
75
+ }
76
+
77
+ private _colors(hovering: boolean): { fill: string; ring: string } {
78
+ if (hovering) return { fill: this.accent, ring: this.accent };
79
+ if (this.level === 0) return { fill: '#ffffff', ring: '#000000' };
80
+ if (this.level === 1) return { fill: '#ffffff', ring: this.accent };
81
+ return { fill: this.accent, ring: this.accent };
82
+ }
83
+
84
+ private _paint(hovering: boolean): void {
85
+ const { fill, ring } = this._colors(hovering);
86
+ this.view.setColors(fill, ring);
87
+ const r = this.box / 2 - this.discBorder / 2;
88
+ this.discG.clear();
89
+ this.discG.circle(this.box / 2, this.box / 2, r).fill('#ffffff');
90
+ // border lights accent on hover OR when engaged (level>0) — mirrors DOM .ge-iconbtn.ge-active
91
+ const ringActive = hovering || this.level > 0;
92
+ this.discG.stroke({ color: ringActive ? this.accent : '#000000', width: this.discBorder });
93
+ }
94
+
95
+ /** Update turbo level and repaint (called when the shell re-renders the bar). */
96
+ setLevel(level: number, accent: string): void {
97
+ this.level = level;
98
+ this.accent = accent;
99
+ this._paint(false);
100
+ }
101
+
102
+ measureSize(): { w: number; h: number } { return { w: this.box, h: this.box }; }
103
+ setLayoutSize(): void { /* fixed */ }
104
+ }
105
+
106
+ /** A readout in the bar (white Oswald value, plaque-label caption, no shadow). */
107
+ function readout(host: PixiComponentContext, label: string, value: string, opts: { valueSize?: number; align?: 'left' | 'center' | 'right'; fixedWidth?: number; maxWidth?: number; color?: string; muted?: string } = {}): Readout {
108
+ return new Readout({
109
+ label: host.t(label),
110
+ value,
111
+ muted: opts.muted ?? host.tokens.plaqueLabel,
112
+ fg: opts.color ?? '#ffffff',
113
+ align: opts.align,
114
+ valueSize: opts.valueSize,
115
+ fixedWidth: opts.fixedWidth,
116
+ maxWidth: opts.maxWidth,
117
+ shadow: false,
118
+ });
119
+ }
120
+
121
+ /** The bottom control bar. Rebuilt on every `render()`. Exposes balance/win value Texts for
122
+ * count-up and runs the wide/mobile fit-scale (mirrors the DOM shell). */
123
+ export class BottomBar extends Container {
124
+ balanceValue?: Text;
125
+ winValue?: Text;
126
+ private host: PixiComponentContext;
127
+ private inner = new Container();
128
+
129
+ // wide structural refs
130
+ private buy?: BuyBonusBadge;
131
+ private panelBg = new Graphics();
132
+ private leftZone?: FlexBox;
133
+ private rightZone?: FlexBox;
134
+ // interactive refs (shared wide/mobile)
135
+ private betReadout?: Readout;
136
+ private betUp?: IconButton;
137
+ private betDown?: IconButton;
138
+ private spin?: SpinDisc;
139
+ private autoBtn?: IconButton;
140
+ private turboBtn?: TurboButton;
141
+
142
+ constructor(host: PixiComponentContext) {
143
+ super();
144
+ this.host = host;
145
+ this.addChild(this.inner);
146
+ if (host.layout === 'mobile') this.buildMobile();
147
+ else this.buildWide();
148
+ }
149
+
150
+ // ── wide / landscape ──────────────────────────────────────────────────────
151
+ private buildWide(): void {
152
+ const { state, tokens } = this.host;
153
+ const isBase = state.mode === 'base';
154
+ const isFS = state.mode === 'freeSpins';
155
+ const showFsBlocks = isFS || (state.mode === 'replay' && state.freeSpins.total > 0);
156
+
157
+ // BUY BONUS — floats outside-left of the panel (base only)
158
+ this.buy = isBase ? this.buildBuy(BUY_W, 13, 3) ?? undefined : undefined;
159
+
160
+ // LEFT info group: menu · balance · (Total win) · (Win)
161
+ const left = new FlexBox({ direction: 'row', align: 'center', gap: ZONE_GAP });
162
+ left.add(new IconButton('menu', { size: 36, glyph: 30, color: '#ffffff', hover: tokens.accent, onTap: () => this.host.actions.openMenu() }));
163
+ if (!state.replay) {
164
+ const bal = readout(this.host, 'Balance', this.host.fmt(state.balance));
165
+ this.balanceValue = bal.valueText;
166
+ left.add(bal);
167
+ }
168
+ if (showFsBlocks) left.add(readout(this.host, 'Total win', this.host.fmtWin(state.freeSpins.totalWin)));
169
+ if (state.win > 0) {
170
+ const win = readout(this.host, 'Win', this.host.fmtWin(state.win));
171
+ this.winValue = win.valueText;
172
+ left.add(win);
173
+ }
174
+ left.layout();
175
+ this.leftZone = left;
176
+
177
+ // RIGHT controls group: bet (+ step) · |divider| · auto · SPIN/FS · turbo
178
+ this.rightZone = this.buildRightWide(isBase, showFsBlocks);
179
+
180
+ this.inner.addChild(this.panelBg, left, this.rightZone);
181
+ if (this.buy) this.inner.addChild(this.buy);
182
+ this.applyBusy();
183
+ }
184
+
185
+ private buildRightWide(isBase: boolean, showFsBlocks: boolean): FlexBox {
186
+ const { state, config, tokens } = this.host;
187
+ const feature = state.activeFeature;
188
+ const betShown = feature ? state.bet * feature.priceMultiplier : state.bet;
189
+ const bet = new Readout({
190
+ label: this.host.t('Bet'),
191
+ value: this.host.fmt(betShown),
192
+ muted: feature ? effectiveAccent(feature) : tokens.plaqueLabel,
193
+ fg: feature ? effectiveAccent(feature) : '#ffffff',
194
+ fixedWidth: FS_BOX,
195
+ align: 'left',
196
+ shadow: false,
197
+ });
198
+ this.betReadout = bet;
199
+ if (isBase) {
200
+ bet.eventMode = 'static';
201
+ bet.cursor = 'pointer';
202
+ bet.on('pointertap', () => { if (!this.betLocked()) this.host.actions.openBetPicker(); });
203
+ }
204
+
205
+ const betGroup = new FlexBox({ direction: 'row', align: 'center', gap: 8 });
206
+ betGroup.add(bet);
207
+ if (isBase) {
208
+ // plain (borderless) +/- icons, bolder — stacked
209
+ const step = new FlexBox({ direction: 'column', align: 'center', gap: 2 });
210
+ this.betUp = new IconButton('plus', { size: 24, glyph: 22, color: '#ffffff', hover: tokens.accent, onTap: () => this.onBet(1) });
211
+ this.betDown = new IconButton('minus', { size: 24, glyph: 22, color: '#ffffff', hover: tokens.accent, onTap: () => this.onBet(-1) });
212
+ step.add(this.betUp);
213
+ step.add(this.betDown);
214
+ betGroup.add(step);
215
+ }
216
+
217
+ const spinWrap = new FlexBox({ direction: 'row', align: 'center', gap: 8 });
218
+ if (isBase && config.features.autoplay) {
219
+ this.autoBtn = new IconButton('autoplay', {
220
+ size: DISC, glyph: 25, disc: tokens.btn, color: tokens.btnInk, hover: tokens.accent, activeColor: tokens.accent,
221
+ active: state.autoplay.active, onTap: () => this.onAutoplay(),
222
+ });
223
+ if (state.autoplay.active) this.autoBtn.setGlow(true);
224
+ spinWrap.add(this.autoBtn);
225
+ }
226
+ if (isBase) {
227
+ this.spin = new SpinDisc({ size: SPIN, glyph: 65, tokens, ticker: this.host.ticker, onSpin: () => this.host.actions.spin(), onStop: () => this.stopAutoplay() });
228
+ if (state.autoplay.active) this.spin.setAutoplay(true, state.autoplay.remaining);
229
+ if (state.busy) this.spin.setBusy(true);
230
+ spinWrap.add(this.spin);
231
+ } else if (showFsBlocks) {
232
+ const fs = state.freeSpins;
233
+ const fsText = fs.current == null ? `${fs.total}` : `${fs.current} / ${fs.total}`;
234
+ spinWrap.add(new FsHero({ label: this.host.t('Free spins'), value: fsText, tokens, height: SPIN }));
235
+ }
236
+ if (config.features.turbo > 0) {
237
+ this.turboBtn = new TurboButton({
238
+ size: DISC, glyph: 19, discFill: tokens.btn, discBorder: 2, accent: tokens.accent,
239
+ level: state.turbo, onTap: () => this.onTurbo(),
240
+ });
241
+ spinWrap.add(this.turboBtn);
242
+ }
243
+
244
+ const right = new FlexBox({ direction: 'row', align: 'center', gap: ZONE_GAP });
245
+ right.add(betGroup);
246
+ right.add(divider(this.host.tokens, 22));
247
+ right.add(spinWrap);
248
+ right.layout();
249
+ return right;
250
+ }
251
+
252
+ private buildBuy(size: number, fontSize: number, border: number): BuyBonusBadge | null {
253
+ const { state, config, tokens } = this.host;
254
+ if (config.features.buyBonus === false && !config.onBonusBuy) return null;
255
+ const feature = state.activeFeature;
256
+ if (feature) {
257
+ const accent = effectiveAccent(feature);
258
+ 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() });
259
+ }
260
+ 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() });
261
+ }
262
+
263
+ // ── mobile / portrait ─────────────────────────────────────────────────────
264
+ private buildMobile(): void {
265
+ const { state, config, tokens } = this.host;
266
+ const isBase = state.mode === 'base';
267
+ const isFS = state.mode === 'freeSpins';
268
+ const showFsBlocks = isFS || (state.mode === 'replay' && state.freeSpins.total > 0);
269
+ const W = this.host.screenW - 2 * M_SIDE;
270
+
271
+ // hero (centre): SPIN in base, FS counter in free spins
272
+ let hero: Container | undefined;
273
+ if (isBase) {
274
+ this.spin = new SpinDisc({ size: SPIN, glyph: 65, tokens, ticker: this.host.ticker, onSpin: () => this.host.actions.spin(), onStop: () => this.stopAutoplay() });
275
+ if (state.autoplay.active) this.spin.setAutoplay(true, state.autoplay.remaining);
276
+ if (state.busy) this.spin.setBusy(true);
277
+ hero = this.spin;
278
+ } else if (showFsBlocks) {
279
+ const fs = state.freeSpins;
280
+ const fsText = fs.current == null ? `${fs.total}` : `${fs.current} / ${fs.total}`;
281
+ hero = new FsHero({ label: this.host.t('Free spins'), value: fsText, tokens, height: SPIN });
282
+ }
283
+
284
+ const menu = new IconButton('menu', { size: 40, glyph: 26, color: '#ffffff', hover: tokens.accent, onTap: () => this.host.actions.openMenu() });
285
+ // autoplay is a base-mode control only — hidden in free spins / replay (matches the DOM bar)
286
+ if (isBase && config.features.autoplay) {
287
+ this.autoBtn = new IconButton('autoplay', { size: 40, glyph: 26, color: '#ffffff', hover: tokens.accent, activeColor: tokens.accent, active: state.autoplay.active, onTap: () => this.onAutoplay() });
288
+ if (state.autoplay.active) this.autoBtn.setGlow(true);
289
+ }
290
+ if (config.features.turbo > 0) {
291
+ this.turboBtn = new TurboButton({
292
+ size: 40, glyph: 22, discFill: tokens.btn, discBorder: 2, accent: tokens.accent,
293
+ level: state.turbo, onTap: () => this.onTurbo(),
294
+ });
295
+ }
296
+ const buy = isBase ? this.buildBuy(M_BUY, 9, 2) ?? undefined : undefined;
297
+
298
+ // level 1 — controls bar (dark)
299
+ 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 } });
300
+ if (isBase && hero) {
301
+ const SIDE = 12;
302
+ const lz = new FlexBox({ direction: 'row', align: 'center', gap: SIDE, justify: 'start' });
303
+ lz.add(menu); if (this.autoBtn) lz.add(this.autoBtn);
304
+ const rz = new FlexBox({ direction: 'row', align: 'center', gap: SIDE, justify: 'end' });
305
+ if (this.turboBtn) rz.add(this.turboBtn); if (buy) rz.add(buy);
306
+ const zw = Math.max(lz.measureSize().w, rz.measureSize().w);
307
+ lz.setLayoutSize(zw, undefined); rz.setLayoutSize(zw, undefined);
308
+ controls.add(lz); controls.add(hero); controls.add(rz);
309
+ } else {
310
+ controls.add(menu);
311
+ if (this.autoBtn) controls.add(this.autoBtn);
312
+ if (hero) controls.add(hero);
313
+ if (showFsBlocks) {
314
+ // Cap Total Win at the leftover row width (maxWidth, not fixed): it stays content-sized when
315
+ // it fits — so space-between keeps normal gaps — and only a huge amount shrinks its NUMBER
316
+ // (centred post-scale) instead of widening the row and forcing a whole-bar down-scale. Mirrors
317
+ // the DOM's shrinkable total-win slot. The floor keeps it readable; below that applyFitMobile
318
+ // scales the stack as a last resort.
319
+ const ICON = 40, PAD = 18, GAPS = 24;
320
+ const heroSized = hero as unknown as { measureSize?: () => { w: number; h: number } };
321
+ const heroW = hero ? (heroSized.measureSize?.().w ?? hero.getLocalBounds().width) : 0;
322
+ const fixed = ICON /* menu */ + (this.autoBtn ? ICON : 0) + heroW + (this.turboBtn ? ICON : 0);
323
+ const twSlot = Math.max(60, W - 2 * PAD - fixed - GAPS);
324
+ controls.add(readout(this.host, 'Total win', this.host.fmtWin(state.freeSpins.totalWin), { color: '#ffffff', muted: '#ffffff', align: 'center', maxWidth: twSlot }));
325
+ }
326
+ if (this.turboBtn) controls.add(this.turboBtn);
327
+ }
328
+ controls.layout();
329
+
330
+ // level 2 — small info pill (balance · − bet + · win)
331
+ const betGroup = new FlexBox({ direction: 'row', align: 'center', gap: 6 });
332
+ const feature = state.activeFeature;
333
+ const betShown = feature ? state.bet * feature.priceMultiplier : state.bet;
334
+ if (isBase) {
335
+ this.betDown = new IconButton('minus', { size: 26, glyph: 18, color: '#ffffff', hover: tokens.accent, onTap: () => this.onBet(-1) });
336
+ betGroup.add(this.betDown);
337
+ }
338
+ 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 });
339
+ this.betReadout = bet;
340
+ if (isBase) { bet.eventMode = 'static'; bet.cursor = 'pointer'; bet.on('pointertap', () => { if (!this.betLocked()) this.host.actions.openBetPicker(); }); }
341
+ betGroup.add(bet);
342
+ if (isBase) { this.betUp = new IconButton('plus', { size: 26, glyph: 18, color: '#ffffff', hover: tokens.accent, onTap: () => this.onBet(1) }); betGroup.add(this.betUp); }
343
+ betGroup.layout();
344
+
345
+ const innerW = W - 28; // pad 14 each side
346
+ const slot = Math.max(40, (innerW - betGroup.outerWidth - 20) / 2);
347
+ 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 } });
348
+ // replay is a read-only historical round — no real balance to show (sticky `replay`, so it stays
349
+ // hidden through a replay's free-spins phase too); matches the DOM bar.
350
+ if (!state.replay) {
351
+ const bal = readout(this.host, 'Balance', this.host.fmt(state.balance), { valueSize: 11, align: 'left', fixedWidth: slot });
352
+ this.balanceValue = bal.valueText;
353
+ info.add(bal);
354
+ }
355
+ info.add(betGroup);
356
+ // WIN always present (no jiggle on win↔0)
357
+ const win = readout(this.host, 'Win', this.host.fmtWin(state.win), { valueSize: 11, align: 'right', fixedWidth: slot });
358
+ this.winValue = win.valueText;
359
+ info.add(win);
360
+ info.layout();
361
+
362
+ // stack (positioned in applyFitMobile)
363
+ this.inner.addChild(controls, info);
364
+ this.mobileControls = controls;
365
+ this.mobileInfo = info;
366
+ this.mobileHeroPop = isBase || showFsBlocks ? SPIN_POP_MOBILE() : 0;
367
+ this.applyBusy();
368
+ }
369
+ private mobileControls?: FlexBox;
370
+ private mobileInfo?: FlexBox;
371
+ private mobileHeroPop = 0;
372
+
373
+ // ── interactive handlers ──────────────────────────────────────────────────
374
+ private onBet(dir: 1 | -1): void {
375
+ if (this.host.state.busy) return;
376
+ this.host.actions.stepBet(dir);
377
+ }
378
+ private onTurbo(): void {
379
+ this.host.actions.cycleTurbo();
380
+ }
381
+ private onAutoplay(): void {
382
+ if (this.host.state.autoplay.active) this.stopAutoplay();
383
+ else this.host.actions.openAutoplayPicker();
384
+ }
385
+ private stopAutoplay(): void {
386
+ this.host.actions.stopAutoplay();
387
+ }
388
+ private betLocked(): boolean {
389
+ return this.host.state.busy || this.host.state.autoplay.active;
390
+ }
391
+
392
+ private applyBusy(): void {
393
+ const { state } = this.host;
394
+ const auto = state.autoplay.active;
395
+ const lockBet = state.busy || auto;
396
+ const i = state.availableBets.indexOf(state.bet);
397
+ if (this.betUp) this.betUp.disabled = lockBet || i >= state.availableBets.length - 1;
398
+ if (this.betDown) this.betDown.disabled = lockBet || i <= 0;
399
+ if (this.spin) this.spin.disabled = state.busy && !auto;
400
+ if (this.autoBtn) this.autoBtn.disabled = state.busy && !auto;
401
+ if (this.buy) this.buy.disabled = state.busy || auto || !state.buyBonusEnabled;
402
+ if (this.betReadout && lockBet) { this.betReadout.eventMode = 'none'; this.betReadout.cursor = 'default'; }
403
+ }
404
+
405
+ // ── height / fit ──────────────────────────────────────────────────────────
406
+ get height(): number {
407
+ const nominal = this.host.layout === 'mobile' ? MOBILE_BAR_H : WIDE_BAR_H;
408
+ return this._measured > 0 ? this._measured : nominal;
409
+ }
410
+ private _measured = 0;
411
+ private measureFootprint(): void {
412
+ const occupied = this.host.screenH - this.getBounds().y;
413
+ this._measured = Number.isFinite(occupied) && occupied > 0 ? occupied : 0;
414
+ }
415
+
416
+ applyFit(): void {
417
+ if (this.host.layout === 'mobile') { this.applyFitMobile(); return; }
418
+ const { screenW: W, screenH: H, tokens } = this.host;
419
+ const left = this.leftZone!, right = this.rightZone!;
420
+ const buyW = this.buy ? BUY_W + ROW_GAP : 0;
421
+
422
+ // content area = buy + panel; panel holds left (hard-left) and right (hard-right) with ≥MID_GAP.
423
+ const neededContent = buyW + PANEL_PAD * 2 + left.outerWidth + MID_GAP + right.outerWidth;
424
+ const designContent = Math.max(MAX_BAR_W - 2 * OUTER_PAD, neededContent);
425
+ const barW = designContent + 2 * OUTER_PAD;
426
+ const s = Math.max(BAR_MIN_SCALE, Math.min(1, W / BAR_REF_W, (W - 2 * OUTER_PAD) / barW));
427
+
428
+ // local layout (origin at bar-left = 0)
429
+ const panelCenterY = SPIN_POP + BAR_H / 2;
430
+ const panelX = OUTER_PAD + buyW;
431
+ const panelRight = barW - OUTER_PAD;
432
+ // panel background
433
+ this.panelBg.clear();
434
+ roundedPath(this.panelBg, panelX, SPIN_POP, panelRight - panelX, BAR_H, [12, 12, 12, 12]);
435
+ this.panelBg.fill(tokens.bar);
436
+
437
+ if (this.buy) this.buy.position.set(OUTER_PAD, panelCenterY - BUY_W / 2);
438
+ left.position.set(panelX + PANEL_PAD, panelCenterY - left.outerHeight / 2);
439
+ right.position.set(panelRight - PANEL_PAD - right.outerWidth, panelCenterY - right.outerHeight / 2);
440
+
441
+ this.inner.scale.set(s);
442
+ this.inner.position.set((W - barW * s) / 2, (H - WIDE_PAD_BOTTOM) - SPIN * s);
443
+ this.measureFootprint();
444
+ }
445
+
446
+ private applyFitMobile(): void {
447
+ const { screenW: W, screenH: H } = this.host;
448
+ const controls = this.mobileControls!, info = this.mobileInfo!;
449
+ const pop = this.mobileHeroPop; // how far the hero sticks above the controls bar
450
+ // vertical stack: [controls (hero pops `pop` above)] gap [info]
451
+ const topPad = pop;
452
+ controls.position.set(0, topPad);
453
+ info.position.set(0, topPad + M_CTRL_H + M_GAP);
454
+ const localBottom = topPad + M_CTRL_H + M_GAP + M_INFO_H;
455
+
456
+ // When a row's content overflows the bar width, widen BOTH rows to the common content width
457
+ // (mirrors the DOM `.ge-fit .ge-shell-bottom { width:max-content }`) so space-between spreads to a
458
+ // non-negative gap instead of packing the children on top of each other (turbo over the SPIN disc).
459
+ // Equal widths keep the two rows centred on each other; the whole stack then scales down to fit.
460
+ const avail = W - 2 * M_SIDE;
461
+ const need = Math.max(controls.naturalWidth, info.naturalWidth);
462
+ const rowW = Math.max(avail, need);
463
+ // keep the fixed row heights — passing undefined here would drop them to content height (the 84px
464
+ // SPIN hero), inflating the controls row so it overlaps the info row below it.
465
+ controls.setLayoutSize(rowW, M_CTRL_H);
466
+ info.setLayoutSize(rowW, M_INFO_H);
467
+ const s = rowW > 0 ? Math.max(0.4, Math.min(1, avail / rowW)) : 1;
468
+
469
+ this.inner.scale.set(s);
470
+ this.inner.position.set((W - rowW * s) / 2, H - localBottom * s - M_PAD_BOTTOM);
471
+ this.measureFootprint();
472
+ }
473
+ }
474
+
475
+ function SPIN_POP_MOBILE(): number {
476
+ return (SPIN - M_CTRL_H) / 2; // 11
477
+ }