@energy8platform/shell 0.4.1 → 0.5.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 +185 -65
- package/dist/html.cjs.js.map +1 -1
- package/dist/html.d.ts +31 -4
- package/dist/html.esm.js +185 -65
- package/dist/html.esm.js.map +1 -1
- package/dist/index.cjs.js +148 -42
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +31 -4
- package/dist/index.esm.js +148 -42
- package/dist/index.esm.js.map +1 -1
- package/dist/pixi.cjs.js +339 -80
- package/dist/pixi.cjs.js.map +1 -1
- package/dist/pixi.d.ts +31 -4
- package/dist/pixi.esm.js +339 -80
- package/dist/pixi.esm.js.map +1 -1
- package/package.json +1 -1
- package/src/core/ShellController.ts +183 -43
- package/src/core/state.ts +1 -0
- package/src/core/types.ts +40 -4
- package/src/core/version.ts +1 -1
- package/src/ui/html/components/BottomBar.ts +71 -32
- package/src/ui/pixi/components/BottomBar.ts +280 -64
package/package.json
CHANGED
|
@@ -6,14 +6,31 @@ import { createI18n, type I18n } from './i18n';
|
|
|
6
6
|
import { KeyboardController, type KeyboardHost } from './keyboard';
|
|
7
7
|
import { PACKAGE_VERSION } from './version';
|
|
8
8
|
import type {
|
|
9
|
-
ShellConfig,
|
|
10
|
-
|
|
9
|
+
ShellConfig,
|
|
10
|
+
ResolvedShellConfig,
|
|
11
|
+
ShellState,
|
|
12
|
+
ShellEvents,
|
|
13
|
+
ShellMode,
|
|
14
|
+
AutoplayOptions,
|
|
15
|
+
FreeSpinsState,
|
|
16
|
+
BonusReadout,
|
|
17
|
+
BonusOption,
|
|
18
|
+
ThemeConfig,
|
|
19
|
+
ModalOptions,
|
|
20
|
+
ReplayModalOptions,
|
|
11
21
|
} from './types';
|
|
12
22
|
import type {
|
|
13
|
-
ShellRenderer,
|
|
23
|
+
ShellRenderer,
|
|
24
|
+
ShellHost,
|
|
25
|
+
ShellActions,
|
|
26
|
+
OverlayHandle,
|
|
27
|
+
OverlayRequest,
|
|
28
|
+
ShellLayoutMode,
|
|
14
29
|
} from './renderer';
|
|
15
30
|
|
|
16
|
-
export interface CreateShellOptions extends ShellConfig {
|
|
31
|
+
export interface CreateShellOptions extends ShellConfig {
|
|
32
|
+
renderer: ShellRenderer;
|
|
33
|
+
}
|
|
17
34
|
|
|
18
35
|
/** Apply defaults to the raw config (the mount target lives on the renderer, not here). */
|
|
19
36
|
export function resolveConfig(config: ShellConfig): ResolvedShellConfig {
|
|
@@ -79,12 +96,21 @@ export class ShellController extends EventEmitter<ShellEvents> implements ShellH
|
|
|
79
96
|
}
|
|
80
97
|
|
|
81
98
|
// ── ShellHost ──────────────────────────────────────────────────────────────
|
|
82
|
-
t(text: string): string {
|
|
83
|
-
|
|
84
|
-
|
|
99
|
+
t(text: string): string {
|
|
100
|
+
return this.i18n.t(text);
|
|
101
|
+
}
|
|
102
|
+
formatCurrency(n: number, win = false): string {
|
|
103
|
+
return formatCurrency(n, this.config.currency, win);
|
|
104
|
+
}
|
|
105
|
+
formatWin(value: number): string {
|
|
106
|
+
return this.formatCurrency(value, true);
|
|
107
|
+
}
|
|
85
108
|
notifyResize(w: number, h: number): void {
|
|
86
109
|
const layout: ShellLayoutMode = w !== 0 && h > w ? 'mobile' : 'wide';
|
|
87
|
-
if (layout !== this.layout) {
|
|
110
|
+
if (layout !== this.layout) {
|
|
111
|
+
this.layout = layout;
|
|
112
|
+
this.renderer.setLayout(layout);
|
|
113
|
+
}
|
|
88
114
|
this.renderer.renderBar();
|
|
89
115
|
}
|
|
90
116
|
|
|
@@ -94,7 +120,9 @@ export class ShellController extends EventEmitter<ShellEvents> implements ShellH
|
|
|
94
120
|
stepBet: (dir) => {
|
|
95
121
|
const next = stepBet(this.state, dir);
|
|
96
122
|
if (next === this.state.bet) return;
|
|
97
|
-
this.state.bet = next;
|
|
123
|
+
this.state.bet = next;
|
|
124
|
+
this.emit('betChange', next);
|
|
125
|
+
this.renderer.renderBar();
|
|
98
126
|
},
|
|
99
127
|
setBet: (n) => {
|
|
100
128
|
if (n !== this.state.bet) {
|
|
@@ -105,7 +133,9 @@ export class ShellController extends EventEmitter<ShellEvents> implements ShellH
|
|
|
105
133
|
},
|
|
106
134
|
cycleTurbo: () => {
|
|
107
135
|
const next = nextTurbo(this.state.turbo, this.config.features.turbo);
|
|
108
|
-
this.state.turbo = next;
|
|
136
|
+
this.state.turbo = next;
|
|
137
|
+
this.emit('turboChange', next);
|
|
138
|
+
this.renderer.renderBar();
|
|
109
139
|
},
|
|
110
140
|
toggleAutoplay: () => {
|
|
111
141
|
if (this.state.autoplay.active) a.stopAutoplay();
|
|
@@ -113,11 +143,13 @@ export class ShellController extends EventEmitter<ShellEvents> implements ShellH
|
|
|
113
143
|
},
|
|
114
144
|
startAutoplay: (remaining) => {
|
|
115
145
|
this.state.autoplay = { active: true, remaining };
|
|
116
|
-
this.emit('autoplayStart', { active: true, remaining });
|
|
146
|
+
this.emit('autoplayStart', { active: true, remaining });
|
|
147
|
+
this.renderer.renderBar();
|
|
117
148
|
},
|
|
118
149
|
stopAutoplay: () => {
|
|
119
150
|
this.state.autoplay = { active: false, remaining: 0 };
|
|
120
|
-
this.emit('autoplayStop');
|
|
151
|
+
this.emit('autoplayStop');
|
|
152
|
+
this.renderer.renderBar();
|
|
121
153
|
},
|
|
122
154
|
openMenu: () => this.openMenu(),
|
|
123
155
|
openSettings: () => this.openSettings(),
|
|
@@ -138,12 +170,24 @@ export class ShellController extends EventEmitter<ShellEvents> implements ShellH
|
|
|
138
170
|
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
|
139
171
|
const self = this;
|
|
140
172
|
const host: KeyboardHost = {
|
|
141
|
-
get state() {
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
get
|
|
145
|
-
|
|
146
|
-
|
|
173
|
+
get state() {
|
|
174
|
+
return self.state;
|
|
175
|
+
},
|
|
176
|
+
get hotkeysEnabled() {
|
|
177
|
+
return self.config.features.hotkeys !== false;
|
|
178
|
+
},
|
|
179
|
+
get spacebarEnabled() {
|
|
180
|
+
return self.config.features.spacebar !== false;
|
|
181
|
+
},
|
|
182
|
+
get turboLevels() {
|
|
183
|
+
return self.config.features.turbo;
|
|
184
|
+
},
|
|
185
|
+
get autoplayEnabled() {
|
|
186
|
+
return self.config.features.autoplay != null;
|
|
187
|
+
},
|
|
188
|
+
get buyBonusEnabled() {
|
|
189
|
+
return self.config.features.buyBonus !== false;
|
|
190
|
+
},
|
|
147
191
|
hasOpenLayer: () => self.overlay !== null,
|
|
148
192
|
routeToLayer: (e) => self.overlay?.onKey?.(e) ?? false,
|
|
149
193
|
spin: () => self.actions.spin(),
|
|
@@ -160,21 +204,51 @@ export class ShellController extends EventEmitter<ShellEvents> implements ShellH
|
|
|
160
204
|
this.kbd.attach();
|
|
161
205
|
}
|
|
162
206
|
|
|
163
|
-
private pullFocus = (): void => {
|
|
207
|
+
private pullFocus = (): void => {
|
|
208
|
+
try {
|
|
209
|
+
(globalThis as { focus?: () => void }).focus?.();
|
|
210
|
+
} catch {
|
|
211
|
+
/* cross-origin */
|
|
212
|
+
}
|
|
213
|
+
};
|
|
164
214
|
|
|
165
215
|
// ── overlay flow ─────────────────────────────────────────────────────────────
|
|
166
216
|
private show(req: OverlayRequest): void {
|
|
167
217
|
this.closeModal();
|
|
168
218
|
this.overlay = this.renderer.openOverlay(req) ?? null;
|
|
169
219
|
}
|
|
170
|
-
openMenu(): void {
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
220
|
+
openMenu(): void {
|
|
221
|
+
this.emit('menuOpen');
|
|
222
|
+
this.openSettings();
|
|
223
|
+
}
|
|
224
|
+
openSettings(): void {
|
|
225
|
+
this.emit('settingsOpen');
|
|
226
|
+
this.show({ kind: 'settings' });
|
|
227
|
+
}
|
|
228
|
+
openInfo(): void {
|
|
229
|
+
this.emit('infoOpen');
|
|
230
|
+
this.show({ kind: 'gameInfo' });
|
|
231
|
+
}
|
|
232
|
+
openBuyBonus(): void {
|
|
233
|
+
if (this.config.onBonusBuy) {
|
|
234
|
+
this.config.onBonusBuy();
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
this.show({ kind: 'buyBonus' });
|
|
238
|
+
}
|
|
239
|
+
openBetPicker(): void {
|
|
240
|
+
this.show({ kind: 'betPicker' });
|
|
241
|
+
}
|
|
242
|
+
openAutoplayPicker(): void {
|
|
243
|
+
this.show({ kind: 'autoplayPicker' });
|
|
244
|
+
}
|
|
245
|
+
openReplay(opts: ReplayModalOptions): void {
|
|
246
|
+
if (this.destroyed) return;
|
|
247
|
+
this.show({ kind: 'replay', opts });
|
|
248
|
+
}
|
|
249
|
+
openModal(opts: ModalOptions): void {
|
|
250
|
+
this.show({ kind: 'modal', opts });
|
|
251
|
+
}
|
|
178
252
|
/** Programmatically dismiss whatever overlay/modal is open. No-op when nothing is shown. */
|
|
179
253
|
closeModal(): void {
|
|
180
254
|
if (!this.overlay) return;
|
|
@@ -190,16 +264,22 @@ export class ShellController extends EventEmitter<ShellEvents> implements ShellH
|
|
|
190
264
|
this.soundRefresh?.(on);
|
|
191
265
|
this.renderer.refreshSoundIcon?.(on);
|
|
192
266
|
}
|
|
193
|
-
setSoundRefresh(fn: ((on: boolean) => void) | null): void {
|
|
267
|
+
setSoundRefresh(fn: ((on: boolean) => void) | null): void {
|
|
268
|
+
this.soundRefresh = fn;
|
|
269
|
+
}
|
|
194
270
|
|
|
195
271
|
// ── features ─────────────────────────────────────────────────────────────────
|
|
196
272
|
activateFeature(bonus: BonusOption): void {
|
|
197
|
-
this.state.activeFeature = bonus;
|
|
273
|
+
this.state.activeFeature = bonus;
|
|
274
|
+
this.emit('featureActivate', { id: bonus.id });
|
|
275
|
+
this.renderer.renderBar();
|
|
198
276
|
}
|
|
199
277
|
deactivateFeature(): void {
|
|
200
278
|
const prev = this.state.activeFeature;
|
|
201
279
|
if (!prev) return;
|
|
202
|
-
this.state.activeFeature = null;
|
|
280
|
+
this.state.activeFeature = null;
|
|
281
|
+
this.emit('featureDeactivate', { id: prev.id });
|
|
282
|
+
this.renderer.renderBar();
|
|
203
283
|
}
|
|
204
284
|
|
|
205
285
|
// ── game-facing public API (mirrors GameShell/PixiGameShell) ───────────────────
|
|
@@ -207,19 +287,79 @@ export class ShellController extends EventEmitter<ShellEvents> implements ShellH
|
|
|
207
287
|
this.renderer.renderBar();
|
|
208
288
|
if (to !== from) this.renderer.animateMoney(field, from, to);
|
|
209
289
|
}
|
|
210
|
-
setBalance(n: number): void {
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
290
|
+
setBalance(n: number): void {
|
|
291
|
+
const from = this.prevBalance;
|
|
292
|
+
this.state.balance = n;
|
|
293
|
+
this.prevBalance = n;
|
|
294
|
+
this.money('balance', from, n);
|
|
295
|
+
}
|
|
296
|
+
setWin(n: number): void {
|
|
297
|
+
const from = this.prevWin;
|
|
298
|
+
this.state.win = n;
|
|
299
|
+
this.prevWin = n;
|
|
300
|
+
this.money('win', from, n);
|
|
301
|
+
}
|
|
302
|
+
setBet(n: number): void {
|
|
303
|
+
this.state.bet = n;
|
|
304
|
+
this.renderer.renderBar();
|
|
305
|
+
}
|
|
306
|
+
setMode(mode: ShellMode): void {
|
|
307
|
+
if (mode === 'replay') this.state.replay = true;
|
|
308
|
+
this.state.mode = mode;
|
|
309
|
+
this.renderer.renderBar();
|
|
310
|
+
}
|
|
311
|
+
setBusy(busy: boolean): void {
|
|
312
|
+
this.state.busy = busy;
|
|
313
|
+
this.renderer.renderBar();
|
|
314
|
+
this.kbd?.notifyBusyChanged(busy);
|
|
315
|
+
}
|
|
316
|
+
setAutoplay(a: AutoplayOptions): void {
|
|
317
|
+
this.state.autoplay = a;
|
|
318
|
+
this.renderer.renderBar();
|
|
319
|
+
}
|
|
320
|
+
setTurbo(level: number): void {
|
|
321
|
+
this.state.turbo = level;
|
|
322
|
+
this.renderer.renderBar();
|
|
323
|
+
}
|
|
324
|
+
setBuyBonusEnabled(enabled: boolean): void {
|
|
325
|
+
this.state.buyBonusEnabled = enabled;
|
|
326
|
+
this.renderer.renderBar();
|
|
327
|
+
}
|
|
328
|
+
setFreeSpins(fs: FreeSpinsState): void {
|
|
329
|
+
this.state.freeSpins = fs;
|
|
330
|
+
this.state.bonus = null;
|
|
331
|
+
this.renderer.renderBar();
|
|
332
|
+
}
|
|
333
|
+
/** Generic bonus readout (adventure / hold-and-spin / respins). Sets a game-supplied label+value
|
|
334
|
+
* override for the bar hero and folds `totalWin` into the shared accumulator. Pairs with
|
|
335
|
+
* `setMode('bonus')`. `setFreeSpins()` clears the override back to the derived current/total. */
|
|
336
|
+
setBonus(b: BonusReadout): void {
|
|
337
|
+
this.state.bonus = { label: b.label, value: b.value };
|
|
338
|
+
this.state.freeSpins = { ...this.state.freeSpins, totalWin: b.totalWin };
|
|
339
|
+
this.renderer.renderBar();
|
|
340
|
+
}
|
|
341
|
+
setTheme(theme: ThemeConfig): void {
|
|
342
|
+
this.config.theme = theme;
|
|
343
|
+
this.tokens = resolveTheme(theme);
|
|
344
|
+
this.renderer.applyTheme(this.tokens);
|
|
345
|
+
this.renderer.renderBar();
|
|
346
|
+
}
|
|
347
|
+
setLanguage(lang: string): void {
|
|
348
|
+
this.config.language = lang;
|
|
349
|
+
this.i18n = createI18n({ language: lang, isSocial: this.config.isSocial });
|
|
350
|
+
this.renderer.renderBar();
|
|
351
|
+
}
|
|
352
|
+
setSocial(isSocial: boolean): void {
|
|
353
|
+
this.config.isSocial = isSocial;
|
|
354
|
+
this.i18n = createI18n({ language: this.config.language, isSocial });
|
|
355
|
+
this.renderer.renderBar();
|
|
356
|
+
}
|
|
357
|
+
setLayout(layout: ShellLayoutMode): void {
|
|
358
|
+
if (layout === this.layout) return;
|
|
359
|
+
this.layout = layout;
|
|
360
|
+
this.renderer.setLayout(layout);
|
|
361
|
+
this.renderer.renderBar();
|
|
362
|
+
}
|
|
223
363
|
|
|
224
364
|
destroy(): Promise<void> {
|
|
225
365
|
if (this.destroyed) return Promise.resolve();
|
package/src/core/state.ts
CHANGED
package/src/core/types.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
|
|
1
|
+
/** `freeSpins` and `bonus` are the SAME bar layout (host-driven hero + Total Win); `freeSpins` is
|
|
2
|
+
* kept as a back-compat alias for the common case (its readout is derived current/total), while
|
|
3
|
+
* `bonus` pairs with `setBonus()` to show a game-supplied label + value (adventure, hold-and-spin,
|
|
4
|
+
* respins — anything that isn't a plain free-spins counter). */
|
|
5
|
+
export type ShellMode = 'base' | 'bonus' | 'freeSpins' | 'replay';
|
|
2
6
|
|
|
3
7
|
export interface CurrencyConfig {
|
|
4
8
|
symbol: string;
|
|
@@ -162,6 +166,20 @@ export interface FreeSpinsState {
|
|
|
162
166
|
totalWin: number;
|
|
163
167
|
}
|
|
164
168
|
|
|
169
|
+
/** A game-supplied bonus readout for the bar hero, set via `setBonus()`. Generalizes the
|
|
170
|
+
* free-spins counter: the shell renders `label` (localized via the shell translator, so a game
|
|
171
|
+
* i18n entry for it is honoured) + `value` (a pre-formatted string, shown verbatim — "2 / 10",
|
|
172
|
+
* "3", "×5", "7 coins") + the Total Win accumulator. The shell stays free of any per-game bonus
|
|
173
|
+
* concept — the label/value semantics live in the game/host. */
|
|
174
|
+
export interface BonusReadout {
|
|
175
|
+
/** Bar label, e.g. 'Free spins' | 'Adventure' | 'Hold & Spin'. Run through the shell translator. */
|
|
176
|
+
label: string;
|
|
177
|
+
/** Pre-formatted counter value shown verbatim (NOT translated). */
|
|
178
|
+
value: string;
|
|
179
|
+
/** Cumulative bonus win for the Total Win readout. */
|
|
180
|
+
totalWin: number;
|
|
181
|
+
}
|
|
182
|
+
|
|
165
183
|
/** One footer button of a generic modal. Clicking it runs `on` (if any), then closes the modal. */
|
|
166
184
|
export interface ModalAction {
|
|
167
185
|
title: string;
|
|
@@ -229,9 +247,24 @@ export interface ShellConfig {
|
|
|
229
247
|
}
|
|
230
248
|
|
|
231
249
|
/** ShellConfig after the controller applies defaults (version, isSocial, replay, theme). No mount. */
|
|
232
|
-
export type ResolvedShellConfig = Required<
|
|
233
|
-
|
|
234
|
-
|
|
250
|
+
export type ResolvedShellConfig = Required<
|
|
251
|
+
Pick<
|
|
252
|
+
ShellConfig,
|
|
253
|
+
| 'language'
|
|
254
|
+
| 'currency'
|
|
255
|
+
| 'availableBets'
|
|
256
|
+
| 'defaultBet'
|
|
257
|
+
| 'balance'
|
|
258
|
+
| 'win'
|
|
259
|
+
| 'mode'
|
|
260
|
+
| 'features'
|
|
261
|
+
| 'gameInfo'
|
|
262
|
+
| 'version'
|
|
263
|
+
| 'isSocial'
|
|
264
|
+
| 'replay'
|
|
265
|
+
>
|
|
266
|
+
> &
|
|
267
|
+
Pick<ShellConfig, 'currentBet' | 'theme' | 'onBonusBuy'>;
|
|
235
268
|
|
|
236
269
|
export interface ShellState {
|
|
237
270
|
mode: ShellMode;
|
|
@@ -248,6 +281,9 @@ export interface ShellState {
|
|
|
248
281
|
turbo: number;
|
|
249
282
|
buyBonusEnabled: boolean;
|
|
250
283
|
freeSpins: FreeSpinsState;
|
|
284
|
+
/** Game-supplied bonus label + value override (from `setBonus()`). When null the bar derives the
|
|
285
|
+
* readout from `freeSpins` (label 'Free spins', value current/total) — the back-compat default. */
|
|
286
|
+
bonus: { label: string; value: string } | null;
|
|
251
287
|
/** The currently activated `feature` option (e.g. Ante), or null. Drives the
|
|
252
288
|
* effective-bet readout tint and the BUY BONUS → DISABLE toggle on the bar. */
|
|
253
289
|
activeFeature: BonusOption | null;
|
package/src/core/version.ts
CHANGED
|
@@ -9,8 +9,12 @@ function readout(ge: string, label: string, value: string): HTMLElement {
|
|
|
9
9
|
el.className = `ge-rd ge-${ge}`;
|
|
10
10
|
// The value lives in its own inline-block span (.ge-rd-val) so it can be measured & shrunk to fit
|
|
11
11
|
// (see fitReadouts) independently of the label, and so the count-up animates just the number.
|
|
12
|
-
const lbl = document.createElement('span');
|
|
13
|
-
|
|
12
|
+
const lbl = document.createElement('span');
|
|
13
|
+
lbl.className = 'ge-lbl';
|
|
14
|
+
lbl.textContent = label;
|
|
15
|
+
const val = document.createElement('span');
|
|
16
|
+
val.className = 'ge-rd-val';
|
|
17
|
+
val.textContent = value;
|
|
14
18
|
el.append(lbl, val);
|
|
15
19
|
return el;
|
|
16
20
|
}
|
|
@@ -25,12 +29,19 @@ function turboBtn(host: ShellHost, level: number): HTMLButtonElement {
|
|
|
25
29
|
}
|
|
26
30
|
|
|
27
31
|
/** A borderless icon button. */
|
|
28
|
-
function iconBtn(
|
|
32
|
+
function iconBtn(
|
|
33
|
+
ge: string,
|
|
34
|
+
name: IconName,
|
|
35
|
+
onClick: () => void,
|
|
36
|
+
active = false,
|
|
37
|
+
): HTMLButtonElement {
|
|
29
38
|
const b = document.createElement('button');
|
|
30
39
|
b.className = `ge-iconbtn${active ? ' ge-active' : ''}`;
|
|
31
40
|
b.dataset.ge = ge;
|
|
32
41
|
b.innerHTML = icon(name);
|
|
33
|
-
b.addEventListener('click', () => {
|
|
42
|
+
b.addEventListener('click', () => {
|
|
43
|
+
if (!b.disabled) onClick();
|
|
44
|
+
});
|
|
34
45
|
return b;
|
|
35
46
|
}
|
|
36
47
|
|
|
@@ -49,16 +60,14 @@ export function renderBottomBar(host: ShellHost): HTMLElement {
|
|
|
49
60
|
// All three modes share the base plaque layout. FS/replay hide the controls that don't apply
|
|
50
61
|
// and add Free Spins + Total Win blocks on the left; the per-spin WIN uses the base pill.
|
|
51
62
|
const isBase = state.mode === 'base';
|
|
52
|
-
const isFS = state.mode === 'freeSpins';
|
|
63
|
+
const isFS = state.mode === 'freeSpins' || state.mode === 'bonus';
|
|
53
64
|
// FS always shows the spins counter + accumulated Total Win (even €0); a replay shows them
|
|
54
65
|
// only when it's a free-spins replay (freeSpins.total > 0).
|
|
55
66
|
const showFsBlocks = isFS || (state.mode === 'replay' && state.freeSpins.total > 0);
|
|
56
67
|
|
|
57
68
|
// Replay is a read-only historical round — there's no real balance to show, so hide it. Keyed on
|
|
58
69
|
// the sticky `replay` flag (not `mode`) so it stays hidden through a replay's free-spins phase.
|
|
59
|
-
const balance = state.replay
|
|
60
|
-
? null
|
|
61
|
-
: readout('balance', host.t('Balance'), fmt(state.balance));
|
|
70
|
+
const balance = state.replay ? null : readout('balance', host.t('Balance'), fmt(state.balance));
|
|
62
71
|
// With a feature active (e.g. Ante) the BET readout shows the effective stake, tinted with
|
|
63
72
|
// the feature accent; the base state.bet is unchanged and returns once the feature is off.
|
|
64
73
|
const feature = state.activeFeature;
|
|
@@ -75,27 +84,35 @@ export function renderBottomBar(host: ShellHost): HTMLElement {
|
|
|
75
84
|
const turbo = config.features.turbo > 0 ? turboBtn(host, state.turbo) : null;
|
|
76
85
|
|
|
77
86
|
// interactive controls — base mode only
|
|
78
|
-
let betDown: HTMLElement | null = null,
|
|
79
|
-
|
|
87
|
+
let betDown: HTMLElement | null = null,
|
|
88
|
+
betUp: HTMLElement | null = null;
|
|
89
|
+
let spin: HTMLElement | null = null,
|
|
90
|
+
auto: HTMLElement | null = null,
|
|
91
|
+
buy: HTMLElement | null = null;
|
|
80
92
|
if (isBase) {
|
|
81
93
|
betDown = iconBtn('bet-down', 'minus', () => host.actions.stepBet(-1));
|
|
82
94
|
betUp = iconBtn('bet-up', 'plus', () => host.actions.stepBet(1));
|
|
83
|
-
betValue.classList.add('ge-betbtn');
|
|
84
|
-
betValue.addEventListener('click', () => {
|
|
95
|
+
betValue.classList.add('ge-betbtn'); // tap the stake → bet picker
|
|
96
|
+
betValue.addEventListener('click', () => {
|
|
97
|
+
if (!betLocked(host)) host.actions.openBetPicker();
|
|
98
|
+
});
|
|
85
99
|
spin = spinButton(host);
|
|
86
100
|
auto = config.features.autoplay ? autoButton(host) : null;
|
|
87
|
-
buy =
|
|
101
|
+
buy = config.features.buyBonus !== false || config.onBonusBuy ? buyBtn(host) : null;
|
|
88
102
|
}
|
|
89
103
|
|
|
90
104
|
const winEl = state.win > 0 ? readout('win', host.t('Win'), fmtWin(state.win)) : null;
|
|
91
105
|
// FS/replay left blocks: spins counter + accumulated Total Win (shown even at €0).
|
|
92
106
|
// current = number → "current / total"; current = null/undefined → just the (game-driven) total.
|
|
93
107
|
const fs = state.freeSpins;
|
|
94
|
-
const fsText =
|
|
108
|
+
const fsText =
|
|
109
|
+
state.bonus?.value ?? (fs.current == null ? `${fs.total}` : `${fs.current} / ${fs.total}`);
|
|
95
110
|
// In FS the spins counter takes the SPIN slot as a rectangular hero plaque (same white/black-ring
|
|
96
111
|
// style as the SPIN disc); Total Win stays an inline readout.
|
|
97
|
-
const fsHero = showFsBlocks ? fsHeroPlaque(host, fsText) : null;
|
|
98
|
-
const fsTotalWin = showFsBlocks
|
|
112
|
+
const fsHero = showFsBlocks ? fsHeroPlaque(host, fsText, state.bonus?.label) : null;
|
|
113
|
+
const fsTotalWin = showFsBlocks
|
|
114
|
+
? readout('fs-totalwin', host.t('Total win'), fmtWin(fs.totalWin))
|
|
115
|
+
: null;
|
|
99
116
|
// The hero in the centre/spin position: SPIN in base, the FS counter in free spins, nothing else.
|
|
100
117
|
const hero = isBase ? spin : fsHero;
|
|
101
118
|
|
|
@@ -117,12 +134,16 @@ export function renderBottomBar(host: ShellHost): HTMLElement {
|
|
|
117
134
|
// RIGHT (the controls): [bet (+ step)] · |divider| · [auto · SPIN-or-FS · turbo]
|
|
118
135
|
const betKids: HTMLElement[] = [betValue];
|
|
119
136
|
if (betUp && betDown) {
|
|
120
|
-
const step = document.createElement('div');
|
|
137
|
+
const step = document.createElement('div');
|
|
138
|
+
step.className = 'ge-betstep';
|
|
139
|
+
step.append(betUp, betDown);
|
|
121
140
|
betKids.push(step);
|
|
122
141
|
}
|
|
123
142
|
const betGroup = plaque('ge-betgroup', betKids);
|
|
124
|
-
const divider = document.createElement('div');
|
|
125
|
-
|
|
143
|
+
const divider = document.createElement('div');
|
|
144
|
+
divider.className = 'ge-pl-divider';
|
|
145
|
+
const spinWrap = document.createElement('div');
|
|
146
|
+
spinWrap.className = 'ge-spinwrap';
|
|
126
147
|
spinWrap.append(...compact([auto, hero, turbo]));
|
|
127
148
|
const right = zone('ge-zone-right', betGroup, divider, spinWrap);
|
|
128
149
|
|
|
@@ -138,11 +159,16 @@ export function renderBottomBar(host: ShellHost): HTMLElement {
|
|
|
138
159
|
|
|
139
160
|
/** Free-spins hero plaque — takes the SPIN slot in FS: same white disc/black-ring language as SPIN,
|
|
140
161
|
* but a rounded RECTANGLE showing the spins counter ("3 / 10"). */
|
|
141
|
-
function fsHeroPlaque(host: ShellHost, text: string): HTMLElement {
|
|
162
|
+
function fsHeroPlaque(host: ShellHost, text: string, label?: string): HTMLElement {
|
|
142
163
|
const el = document.createElement('div');
|
|
143
|
-
el.className = 'ge-fs-hero';
|
|
144
|
-
|
|
145
|
-
const
|
|
164
|
+
el.className = 'ge-fs-hero';
|
|
165
|
+
el.dataset.ge = 'fs-counter';
|
|
166
|
+
const lbl = document.createElement('span');
|
|
167
|
+
lbl.className = 'ge-fs-lbl';
|
|
168
|
+
lbl.textContent = host.t(label ?? 'Free spins');
|
|
169
|
+
const num = document.createElement('span');
|
|
170
|
+
num.className = 'ge-fs-num';
|
|
171
|
+
num.textContent = text;
|
|
146
172
|
el.append(lbl, num);
|
|
147
173
|
return el;
|
|
148
174
|
}
|
|
@@ -160,24 +186,32 @@ function plaque(cls: string, children: HTMLElement[]): HTMLElement {
|
|
|
160
186
|
d.append(...children);
|
|
161
187
|
return d;
|
|
162
188
|
}
|
|
163
|
-
function compact(items: (HTMLElement | null)[]): HTMLElement[] {
|
|
189
|
+
function compact(items: (HTMLElement | null)[]): HTMLElement[] {
|
|
190
|
+
return items.filter((x): x is HTMLElement => x !== null);
|
|
191
|
+
}
|
|
164
192
|
|
|
165
193
|
function buyBtn(host: ShellHost): HTMLButtonElement {
|
|
166
194
|
const buy = document.createElement('button');
|
|
167
|
-
buy.className = 'ge-shell-buybonus';
|
|
195
|
+
buy.className = 'ge-shell-buybonus';
|
|
196
|
+
buy.dataset.ge = 'buybonus';
|
|
168
197
|
const feature = host.state.activeFeature;
|
|
169
198
|
if (feature) {
|
|
170
199
|
// A feature is active → this button turns into DISABLE (tinted with the feature accent).
|
|
171
200
|
const accent = effectiveAccent(feature);
|
|
172
201
|
buy.classList.add('ge-disable');
|
|
173
202
|
buy.innerHTML = `<span>${host.t('DISABLE')}</span>`;
|
|
174
|
-
buy.style.background = accent;
|
|
175
|
-
buy.
|
|
203
|
+
buy.style.background = accent;
|
|
204
|
+
buy.style.color = contrastText(accent);
|
|
205
|
+
buy.addEventListener('click', () => {
|
|
206
|
+
if (!buy.disabled) host.actions.deactivateFeature();
|
|
207
|
+
});
|
|
176
208
|
} else {
|
|
177
209
|
// Ticket icon (no text); keep the label for screen readers.
|
|
178
210
|
buy.setAttribute('aria-label', host.t('BUY BONUS'));
|
|
179
211
|
buy.innerHTML = `<span class="ge-bb-tk">${icon('ticket')}</span>`;
|
|
180
|
-
buy.addEventListener('click', () => {
|
|
212
|
+
buy.addEventListener('click', () => {
|
|
213
|
+
if (!buy.disabled) host.actions.openBuyBonus();
|
|
214
|
+
});
|
|
181
215
|
}
|
|
182
216
|
return buy;
|
|
183
217
|
}
|
|
@@ -190,17 +224,22 @@ function betLocked(host: ShellHost): boolean {
|
|
|
190
224
|
function spinButton(host: ShellHost): HTMLButtonElement {
|
|
191
225
|
const { state } = host;
|
|
192
226
|
const sp = document.createElement('button');
|
|
193
|
-
sp.className = 'ge-shell-spin';
|
|
227
|
+
sp.className = 'ge-shell-spin';
|
|
228
|
+
sp.dataset.ge = 'spin';
|
|
194
229
|
if (state.autoplay.active) {
|
|
195
230
|
sp.classList.add('ge-stop');
|
|
196
231
|
const rem = state.autoplay.remaining;
|
|
197
232
|
const label = Number.isFinite(rem) ? String(rem) : '∞';
|
|
198
233
|
sp.innerHTML = `<span class="ge-spin-stop">${icon('stop')}</span><span class="ge-spin-count">${label}</span>`;
|
|
199
|
-
sp.addEventListener('click', () => {
|
|
234
|
+
sp.addEventListener('click', () => {
|
|
235
|
+
if (!sp.disabled) host.actions.stopAutoplay();
|
|
236
|
+
});
|
|
200
237
|
} else {
|
|
201
238
|
sp.innerHTML = icon('spin');
|
|
202
239
|
if (state.busy) sp.classList.add('ge-spinning');
|
|
203
|
-
sp.addEventListener('click', () => {
|
|
240
|
+
sp.addEventListener('click', () => {
|
|
241
|
+
if (!sp.disabled) host.actions.spin();
|
|
242
|
+
});
|
|
204
243
|
}
|
|
205
244
|
return sp;
|
|
206
245
|
}
|
|
@@ -230,7 +269,7 @@ function applyBusy(host: ShellHost, bar: HTMLElement): void {
|
|
|
230
269
|
const i = host.state.availableBets.indexOf(host.state.bet);
|
|
231
270
|
disable('bet-up', lockBet || i >= host.state.availableBets.length - 1);
|
|
232
271
|
disable('bet-down', lockBet || i <= 0);
|
|
233
|
-
disable('spin', busy && !auto);
|
|
272
|
+
disable('spin', busy && !auto); // keep the STOP disc clickable through autoplay
|
|
234
273
|
disable('autoplay', busy && !auto); // keep autoplay (stop) clickable through autoplay
|
|
235
274
|
const betVal = bar.querySelector('[data-ge="bet-value"]') as HTMLElement | null;
|
|
236
275
|
if (betVal) betVal.classList.toggle('ge-disabled', lockBet);
|