@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.
- package/dist/html.cjs.js +3894 -0
- package/dist/html.cjs.js.map +1 -0
- package/dist/html.d.ts +616 -0
- package/dist/html.esm.js +3879 -0
- package/dist/html.esm.js.map +1 -0
- package/dist/index.cjs.js +1593 -0
- package/dist/index.cjs.js.map +1 -0
- package/dist/index.d.ts +554 -0
- package/dist/index.esm.js +1582 -0
- package/dist/index.esm.js.map +1 -0
- package/dist/pixi.cjs.js +5740 -0
- package/dist/pixi.cjs.js.map +1 -0
- package/dist/pixi.d.ts +682 -0
- package/dist/pixi.esm.js +5726 -0
- package/dist/pixi.esm.js.map +1 -0
- package/package.json +79 -0
- package/src/core/EventEmitter.ts +55 -0
- package/src/core/ShellController.ts +234 -0
- package/src/core/colors.ts +32 -0
- package/src/core/fonts-digits.ts +12 -0
- package/src/core/fonts.ts +13 -0
- package/src/core/format.ts +39 -0
- package/src/core/i18n.ts +96 -0
- package/src/core/index.ts +35 -0
- package/src/core/keyboard.ts +229 -0
- package/src/core/locales.ts +864 -0
- package/src/core/motion.ts +10 -0
- package/src/core/renderer.ts +121 -0
- package/src/core/state.ts +31 -0
- package/src/core/theme.ts +81 -0
- package/src/core/types.ts +269 -0
- package/src/core/version.ts +3 -0
- package/src/ui/html/HtmlRenderer.ts +292 -0
- package/src/ui/html/components/BottomBar.ts +240 -0
- package/src/ui/html/components/BuyBonus.ts +326 -0
- package/src/ui/html/components/GameInfo.ts +384 -0
- package/src/ui/html/components/Modal.ts +36 -0
- package/src/ui/html/components/ReplayModal.ts +58 -0
- package/src/ui/html/components/Settings.ts +59 -0
- package/src/ui/html/components/pickers.ts +146 -0
- package/src/ui/html/icons-preview.svg +224 -0
- package/src/ui/html/icons.ts +31 -0
- package/src/ui/html/index.ts +36 -0
- package/src/ui/html/motion-dom.ts +29 -0
- package/src/ui/html/primitives.ts +85 -0
- package/src/ui/html/shell.css.ts +528 -0
- package/src/ui/html/theme-css.ts +21 -0
- package/src/ui/pixi/PixiRenderer.ts +350 -0
- package/src/ui/pixi/components/BottomBar.ts +477 -0
- package/src/ui/pixi/components/BuyBonus.ts +763 -0
- package/src/ui/pixi/components/GameInfo.ts +559 -0
- package/src/ui/pixi/components/Modal.ts +40 -0
- package/src/ui/pixi/components/ReplayModal.ts +80 -0
- package/src/ui/pixi/components/Settings.ts +117 -0
- package/src/ui/pixi/components/pickers.ts +170 -0
- package/src/ui/pixi/context.ts +52 -0
- package/src/ui/pixi/icons.ts +45 -0
- package/src/ui/pixi/index.ts +56 -0
- package/src/ui/pixi/motion-pixi.ts +58 -0
- package/src/ui/pixi/pixi-icon.ts +119 -0
- package/src/ui/pixi/primitives/card.ts +227 -0
- package/src/ui/pixi/primitives/controls.ts +243 -0
- package/src/ui/pixi/primitives/flex.ts +335 -0
- package/src/ui/pixi/primitives/overlay.ts +181 -0
- package/src/ui/pixi/primitives/scroll.ts +117 -0
- package/src/ui/pixi/primitives/widgets.ts +680 -0
- 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
|
+
}
|