@energy8platform/platform-core 0.27.0 → 0.28.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 (39) hide show
  1. package/dist/index.cjs.js +0 -3682
  2. package/dist/index.cjs.js.map +1 -1
  3. package/dist/index.d.ts +2 -403
  4. package/dist/index.esm.js +1 -3680
  5. package/dist/index.esm.js.map +1 -1
  6. package/package.json +1 -7
  7. package/src/index.ts +2 -17
  8. package/dist/shell.cjs.js +0 -3739
  9. package/dist/shell.cjs.js.map +0 -1
  10. package/dist/shell.d.ts +0 -436
  11. package/dist/shell.esm.js +0 -3732
  12. package/dist/shell.esm.js.map +0 -1
  13. package/scripts/gen-version.mjs +0 -21
  14. package/src/shell/GameShell.ts +0 -431
  15. package/src/shell/INTER-LICENSE.txt +0 -93
  16. package/src/shell/colors.ts +0 -32
  17. package/src/shell/components/BottomBar.ts +0 -255
  18. package/src/shell/components/BuyBonus.ts +0 -329
  19. package/src/shell/components/GameInfo.ts +0 -384
  20. package/src/shell/components/Modal.ts +0 -36
  21. package/src/shell/components/ReplayModal.ts +0 -57
  22. package/src/shell/components/Settings.ts +0 -59
  23. package/src/shell/components/icons.svg +0 -139
  24. package/src/shell/components/icons.ts +0 -45
  25. package/src/shell/components/pickers.ts +0 -150
  26. package/src/shell/components/primitives.ts +0 -85
  27. package/src/shell/fonts-digits.ts +0 -12
  28. package/src/shell/fonts.ts +0 -13
  29. package/src/shell/format.ts +0 -39
  30. package/src/shell/i18n.ts +0 -56
  31. package/src/shell/index.ts +0 -22
  32. package/src/shell/keyboard.ts +0 -229
  33. package/src/shell/locales.ts +0 -864
  34. package/src/shell/motion.ts +0 -43
  35. package/src/shell/shell.css.ts +0 -499
  36. package/src/shell/state.ts +0 -31
  37. package/src/shell/theme.ts +0 -59
  38. package/src/shell/types.ts +0 -262
  39. package/src/shell/version.ts +0 -3
@@ -1,255 +0,0 @@
1
- import type { GameShell } from '../GameShell';
2
- import { formatCurrency } from '../format';
3
- import { stepBet, nextTurbo } from '../state';
4
- import { effectiveAccent, contrastText } from '../colors';
5
- import { icon, type IconName } from './icons';
6
-
7
- /** A floating labelled money readout (balance/win/bet). */
8
- function readout(ge: string, label: string, value: string): HTMLElement {
9
- const el = document.createElement('div');
10
- el.dataset.ge = ge;
11
- el.className = `ge-rd ge-${ge}`;
12
- // The value lives in its own inline-block span (.ge-rd-val) so it can be measured & shrunk to fit
13
- // (see GameShell.fitBet) independently of the label, and so the count-up animates just the number.
14
- const lbl = document.createElement('span'); lbl.className = 'ge-lbl'; lbl.textContent = label;
15
- const val = document.createElement('span'); val.className = 'ge-rd-val'; val.textContent = value;
16
- el.append(lbl, val);
17
- return el;
18
- }
19
-
20
- // Resting icon is turbo1 (1 line, grey via .ge-iconbtn); engaging turbo adds the
21
- // .ge-active class which paints it white. Higher levels add more speed lines.
22
- // level: 0 → turbo1 (grey), 1 → turbo1 (white), 2 → turbo2, 3 → turbo3.
23
- function turboIcon(level: number): IconName {
24
- return (['turbo1', 'turbo1', 'turbo2', 'turbo3'] as const)[Math.max(0, Math.min(3, level))];
25
- }
26
-
27
- /** A borderless icon button. */
28
- function iconBtn(ge: string, name: IconName, onClick: () => void, active = false): HTMLButtonElement {
29
- const b = document.createElement('button');
30
- b.className = `ge-iconbtn${active ? ' ge-active' : ''}`;
31
- b.dataset.ge = ge;
32
- b.innerHTML = icon(name);
33
- b.addEventListener('click', () => { if (!b.disabled) onClick(); });
34
- return b;
35
- }
36
-
37
- export function renderBottomBar(shell: GameShell): HTMLElement {
38
- const { state, config } = shell;
39
- const fmt = (n: number) => formatCurrency(n, config.currency);
40
- const fmtWin = (n: number) => formatCurrency(n, config.currency, true); // win / total-win: variable decimals
41
- const mobile = shell.layout === 'mobile';
42
- const bar = document.createElement('div');
43
- bar.className = 'ge-shell-bottom';
44
- bar.dataset.geMode = state.mode;
45
-
46
- // menu icon button (always)
47
- const menu = iconBtn('menu', 'menu', () => shell.openMenu());
48
-
49
- // All three modes share the base plaque layout. FS/replay hide the controls that don't apply
50
- // and add Free Spins + Total Win blocks on the left; the per-spin WIN uses the base pill.
51
- const isBase = state.mode === 'base';
52
- const isFS = state.mode === 'freeSpins';
53
- // FS always shows the spins counter + accumulated Total Win (even €0); a replay shows them
54
- // only when it's a free-spins replay (freeSpins.total > 0).
55
- const showFsBlocks = isFS || (state.mode === 'replay' && state.freeSpins.total > 0);
56
-
57
- // Replay is a read-only historical round — there's no real balance to show, so hide it. Keyed on
58
- // 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', shell.t('Balance'), fmt(state.balance));
62
- // With a feature active (e.g. Ante) the BET readout shows the effective stake, tinted with
63
- // the feature accent; the base state.bet is unchanged and returns once the feature is off.
64
- const feature = state.activeFeature;
65
- const betShown = feature ? state.bet * feature.priceMultiplier : state.bet;
66
- const betValue = readout('bet-value', shell.t('Bet'), fmt(betShown));
67
- if (feature) {
68
- const accent = effectiveAccent(feature);
69
- betValue.classList.add('ge-bet-feature');
70
- betValue.style.color = accent;
71
- // tint the "BET" label too (its .ge-lbl colour is set in CSS, so override inline)
72
- const lbl = betValue.querySelector('.ge-lbl') as HTMLElement | null;
73
- if (lbl) lbl.style.color = accent;
74
- }
75
- const turbo = config.features.turbo > 0
76
- ? iconBtn('turbo', turboIcon(state.turbo), () => onTurbo(shell), state.turbo > 0) : null;
77
-
78
- // interactive controls — base mode only
79
- let betDown: HTMLElement | null = null, betUp: HTMLElement | null = null;
80
- let spin: HTMLElement | null = null, auto: HTMLElement | null = null, buy: HTMLElement | null = null;
81
- if (isBase) {
82
- betDown = iconBtn('bet-down', 'minus', () => onBet(shell, -1));
83
- betUp = iconBtn('bet-up', 'plus', () => onBet(shell, 1));
84
- betValue.classList.add('ge-betbtn'); // tap the stake → bet picker
85
- betValue.addEventListener('click', () => { if (!betLocked(shell)) shell.openBetPicker(); });
86
- spin = spinButton(shell);
87
- auto = config.features.autoplay ? autoButton(shell) : null;
88
- buy = (config.features.buyBonus !== false || config.onBonusBuy) ? buyBtn(shell) : null;
89
- }
90
-
91
- const winEl = state.win > 0 ? readout('win', shell.t('Win'), fmtWin(state.win)) : null;
92
- // FS/replay left blocks: spins counter + accumulated Total Win (shown even at €0).
93
- // current = number → "current / total"; current = null/undefined → just the (game-driven) total.
94
- const fs = state.freeSpins;
95
- const fsText = fs.current == null ? `${fs.total}` : `${fs.current} / ${fs.total}`;
96
- // In FS the spins counter takes the SPIN slot as a rectangular hero plaque (same white/black-ring
97
- // style as the SPIN disc); Total Win stays an inline readout.
98
- const fsHero = showFsBlocks ? fsHeroPlaque(shell, fsText) : null;
99
- const fsTotalWin = showFsBlocks ? readout('fs-totalwin', shell.t('Total win'), fmtWin(fs.totalWin)) : null;
100
- // The hero in the centre/spin position: SPIN in base, the FS counter in free spins, nothing else.
101
- const hero = isBase ? spin : fsHero;
102
-
103
- if (mobile) {
104
- // Two levels:
105
- // 1) controls bar — [menu · auto · SPIN-or-FS · Total Win · turbo · buy]
106
- // 2) a small info pill below — [balance · − bet + · win]
107
- bar.appendChild(plaque('ge-m-controls', compact([menu, auto, hero, fsTotalWin, turbo, buy])));
108
- const betGroup = plaque('ge-m-betgroup', compact([betDown, betValue, betUp]));
109
- // WIN always occupies its slot (shows €0 between wins) so the pill never reflows on win↔0.
110
- const mWin = readout('win', shell.t('Win'), fmtWin(state.win));
111
- bar.appendChild(plaque('ge-m-info', compact([balance, betGroup, mWin])));
112
- } else {
113
- // DESKTOP: BUY BONUS floats OUTSIDE, to the left of one continuous dark bar panel.
114
- // LEFT (all the info): [menu] · [balance] · [Total Win] · [WIN]
115
- // (Total Win only in FS / a fs replay; WIN only when there's a win this spin)
116
- const left = zone('ge-zone-left', ...compact([menu, balance, fsTotalWin, winEl]));
117
-
118
- // RIGHT (the controls): [bet (+ step)] · |divider| · [auto · SPIN-or-FS · turbo]
119
- const betKids: HTMLElement[] = [betValue];
120
- if (betUp && betDown) {
121
- const step = document.createElement('div'); step.className = 'ge-betstep'; step.append(betUp, betDown);
122
- betKids.push(step);
123
- }
124
- const betGroup = plaque('ge-betgroup', betKids);
125
- const divider = document.createElement('div'); divider.className = 'ge-pl-divider';
126
- const spinWrap = document.createElement('div'); spinWrap.className = 'ge-spinwrap';
127
- spinWrap.append(...compact([auto, hero, turbo]));
128
- const right = zone('ge-zone-right', betGroup, divider, spinWrap);
129
-
130
- // One continuous dark panel: info group hard-left, controls hard-right (space-between).
131
- // BUY BONUS sits to its left, outside the panel.
132
- const panel = plaque('ge-bar-panel', [left, right]);
133
- bar.append(...compact([buy, panel]));
134
- }
135
-
136
- applyBusy(shell, bar);
137
- return bar;
138
- }
139
-
140
- /** Free-spins hero plaque — takes the SPIN slot in FS: same white disc/black-ring language as SPIN,
141
- * but a rounded RECTANGLE showing the spins counter ("3 / 10"). */
142
- function fsHeroPlaque(shell: GameShell, text: string): HTMLElement {
143
- const el = document.createElement('div');
144
- el.className = 'ge-fs-hero'; el.dataset.ge = 'fs-counter';
145
- const lbl = document.createElement('span'); lbl.className = 'ge-fs-lbl'; lbl.textContent = shell.t('Free spins');
146
- const num = document.createElement('span'); num.className = 'ge-fs-num'; num.textContent = text;
147
- el.append(lbl, num);
148
- return el;
149
- }
150
-
151
- function zone(cls: string, ...children: HTMLElement[]): HTMLElement {
152
- const z = document.createElement('div');
153
- z.className = `ge-zone ${cls}`;
154
- z.append(...children);
155
- return z;
156
- }
157
- /** A rounded background panel ("plaque") grouping a set of controls. */
158
- function plaque(cls: string, children: HTMLElement[]): HTMLElement {
159
- const d = document.createElement('div');
160
- d.className = cls;
161
- d.append(...children);
162
- return d;
163
- }
164
- function compact(items: (HTMLElement | null)[]): HTMLElement[] { return items.filter((x): x is HTMLElement => x !== null); }
165
-
166
- function buyBtn(shell: GameShell): HTMLButtonElement {
167
- const buy = document.createElement('button');
168
- buy.className = 'ge-shell-buybonus'; buy.dataset.ge = 'buybonus';
169
- const feature = shell.state.activeFeature;
170
- if (feature) {
171
- // A feature is active → this button turns into DISABLE (tinted with the feature accent).
172
- const accent = effectiveAccent(feature);
173
- buy.classList.add('ge-disable');
174
- buy.innerHTML = `<span>${shell.t('DISABLE')}</span>`;
175
- buy.style.background = accent; buy.style.color = contrastText(accent);
176
- buy.addEventListener('click', () => { if (!buy.disabled) shell.deactivateFeature(); });
177
- } else {
178
- // Ticket icon (no text); keep the label for screen readers.
179
- buy.setAttribute('aria-label', shell.t('BUY BONUS'));
180
- buy.innerHTML = `<span class="ge-bb-tk">${icon('ticket')}</span>`;
181
- buy.addEventListener('click', () => { if (!buy.disabled) shell.openBuyBonus(); });
182
- }
183
- return buy;
184
- }
185
-
186
- function onBet(shell: GameShell, dir: 1 | -1): void {
187
- if (shell.state.busy) return;
188
- const next = stepBet(shell.state, dir);
189
- if (next !== shell.state.bet) { shell.state.bet = next; shell.emit('betChange', next); shell.render(); }
190
- }
191
- function onTurbo(shell: GameShell): void {
192
- const next = nextTurbo(shell.state.turbo, shell.config.features.turbo);
193
- shell.state.turbo = next; shell.emit('turboChange', next); shell.render();
194
- }
195
- function betLocked(shell: GameShell): boolean {
196
- return shell.state.busy || shell.state.autoplay.active;
197
- }
198
-
199
- /** SPIN disc — rotates while busy; becomes a STOP + countdown while autoplay runs. */
200
- function spinButton(shell: GameShell): HTMLButtonElement {
201
- const { state } = shell;
202
- const sp = document.createElement('button');
203
- sp.className = 'ge-shell-spin'; sp.dataset.ge = 'spin';
204
- if (state.autoplay.active) {
205
- sp.classList.add('ge-stop');
206
- const rem = state.autoplay.remaining;
207
- const label = Number.isFinite(rem) ? String(rem) : '∞';
208
- sp.innerHTML = `<span class="ge-spin-stop">${icon('stop')}</span><span class="ge-spin-count">${label}</span>`;
209
- sp.addEventListener('click', () => { if (!sp.disabled) stopAutoplay(shell); });
210
- } else {
211
- sp.innerHTML = icon('spin');
212
- if (state.busy) sp.classList.add('ge-spinning');
213
- sp.addEventListener('click', () => { if (!sp.disabled) shell.emit('spin'); });
214
- }
215
- return sp;
216
- }
217
-
218
- /** Autoplay icon button — opens the count picker; glows accent while running. */
219
- function autoButton(shell: GameShell): HTMLButtonElement {
220
- const active = shell.state.autoplay.active;
221
- const b = iconBtn('autoplay', 'autoplay', () => onAutoplay(shell), active);
222
- if (active) b.classList.add('ge-glow');
223
- return b;
224
- }
225
-
226
- function onAutoplay(shell: GameShell): void {
227
- if (shell.state.autoplay.active) stopAutoplay(shell);
228
- else shell.openAutoplayPicker();
229
- }
230
- function stopAutoplay(shell: GameShell): void {
231
- shell.state.autoplay = { active: false, remaining: 0 };
232
- shell.emit('autoplayStop');
233
- shell.render();
234
- }
235
-
236
- function applyBusy(shell: GameShell, bar: HTMLElement): void {
237
- const { busy } = shell.state;
238
- const auto = shell.state.autoplay.active;
239
- const lockBet = busy || auto;
240
- const disable = (ge: string, off: boolean) => {
241
- const el = bar.querySelector(`[data-ge="${ge}"]`) as HTMLButtonElement | null;
242
- if (el) el.disabled = off;
243
- };
244
- // also disable the stepper that's already at the end of the bet range
245
- const i = shell.state.availableBets.indexOf(shell.state.bet);
246
- disable('bet-up', lockBet || i >= shell.state.availableBets.length - 1);
247
- disable('bet-down', lockBet || i <= 0);
248
- disable('spin', busy && !auto); // keep the STOP disc clickable through autoplay
249
- disable('autoplay', busy && !auto); // keep autoplay (stop) clickable through autoplay
250
- const betVal = bar.querySelector('[data-ge="bet-value"]') as HTMLElement | null;
251
- if (betVal) betVal.classList.toggle('ge-disabled', lockBet);
252
- const buy = bar.querySelector('[data-ge="buybonus"]') as HTMLButtonElement | null;
253
- // disabled for the whole autoplay run (not just per-spin busy) so it doesn't flicker/pulse
254
- if (buy) buy.disabled = busy || auto || !shell.state.buyBonusEnabled;
255
- }
@@ -1,329 +0,0 @@
1
- import type { GameShell } from '../GameShell';
2
- import type { BonusOption } from '../types';
3
- import { formatCurrency } from '../format';
4
- import { stepBet } from '../state';
5
- import { betDir } from '../keyboard';
6
- import { effectiveAccent, contrastText } from '../colors';
7
- import { createOverlay, createCardModal } from './primitives';
8
- import { icon, type IconName } from './icons';
9
-
10
- /** Mutable state shared between the overlay DOM and the onKey handler. */
11
- interface OverlayState {
12
- /** Index into the affordable-card subset; -1 = none (no affordable cards). */
13
- focusIndex: number;
14
- /** The bonus whose confirm dialog is currently open, or undefined. */
15
- confirmBonus: BonusOption | undefined;
16
- }
17
-
18
- /** Buy-bonus overlay — a grid of art-forward cards, one per option.
19
- * Returns the overlay element + a keyboard handler for the shell's `showModal`. */
20
- export function openBuyBonusOverlay(shell: GameShell): { root: HTMLElement; onKey: (e: KeyboardEvent) => boolean } | null {
21
- const bonuses = shell.config.features.buyBonus;
22
- if (bonuses === false || bonuses.length === 0) return null;
23
-
24
- const st: OverlayState = { focusIndex: -1, confirmBonus: undefined };
25
-
26
- const { root, body } = createOverlay({ title: shell.t('Buy bonus'), onClose: () => shell.closeModal() });
27
- root.dataset.ge = 'buybonus-overlay';
28
-
29
- // Re-render the grid whenever the bet changes so every card's price stays live.
30
- const renderGrid = (): void => {
31
- body.innerHTML = '';
32
- const grid = document.createElement('div'); grid.className = 'ge-bb-grid';
33
- // Card count drives the width-fit clamp in CSS (each card is 18em; N cards must fit the frame
34
- // width), so the row scales to the available width instead of overflowing into an X-scroll.
35
- grid.style.setProperty('--ge-bb-n', String(bonuses.length));
36
- const affordable: BonusOption[] = [];
37
- for (const bonus of bonuses) {
38
- const card = buildCard(shell, bonus, root, st);
39
- grid.appendChild(card);
40
- if (isAffordable(shell, bonus)) affordable.push(bonus);
41
- }
42
- body.appendChild(grid);
43
- // Initialize or restore focus index
44
- if (affordable.length > 0) {
45
- if (st.focusIndex < 0) st.focusIndex = 0;
46
- else st.focusIndex = Math.min(st.focusIndex, affordable.length - 1);
47
- applyFocusClass(root, bonuses, affordable, st.focusIndex);
48
- } else {
49
- st.focusIndex = -1;
50
- }
51
- };
52
-
53
- renderGrid();
54
- root.appendChild(buildBetBar(shell, renderGrid)); // thin bottom footer, only as tall as the pill
55
-
56
- /** Step the bet by `dir` and re-render the grid (live prices + affordability) when it changed.
57
- * Shared by the keyboard bet keys (the footer ± buttons keep their own copy). */
58
- const stepBetBy = (dir: 1 | -1): void => {
59
- const next = stepBet(shell.state, dir);
60
- if (next === shell.state.bet) return;
61
- shell.state.bet = next; shell.emit('betChange', next); shell.render();
62
- renderGrid();
63
- };
64
-
65
- /** Keyboard handler for both browse and confirm phases. */
66
- const onKey = (e: KeyboardEvent): boolean => {
67
- const affordable = bonuses.filter((b) => isAffordable(shell, b));
68
-
69
- // ── Confirm phase ──
70
- if (st.confirmBonus) {
71
- switch (e.code) {
72
- case 'Enter':
73
- case 'Space': {
74
- const bonus = st.confirmBonus;
75
- if (!isAffordable(shell, bonus)) return true;
76
- if (bonus.type === 'feature') shell.activateFeature(bonus);
77
- else shell.emit('buyBonusSelect', { id: bonus.id });
78
- shell.closeModal();
79
- return true;
80
- }
81
- case 'Escape':
82
- // Remove the confirm dialog, return to browse
83
- closeConfirm(root, st);
84
- return true;
85
- default:
86
- return false;
87
- }
88
- }
89
-
90
- // ── Browse phase ──
91
- const last = affordable.length - 1;
92
- const mobile = shell.layout === 'mobile';
93
-
94
- // Bet stepping mirrors the bar's keys (Shift+↑/↓, Shift+=/-, Numpad ±). Checked BEFORE arrow
95
- // navigation so a bare arrow still moves card focus while a Shift+arrow changes the bet.
96
- const bet = betDir(e);
97
- if (bet !== null) { stepBetBy(bet); return true; }
98
-
99
- // Determine navigation direction from key code + layout (mobile uses vertical arrows)
100
- const fwdKey = e.code === 'ArrowRight' || (mobile && e.code === 'ArrowDown');
101
- const bwdKey = e.code === 'ArrowLeft' || (mobile && e.code === 'ArrowUp');
102
-
103
- if (fwdKey) {
104
- if (last < 0) return true;
105
- if (st.focusIndex < last) {
106
- st.focusIndex++;
107
- applyFocusClass(root, bonuses, affordable, st.focusIndex);
108
- }
109
- return true;
110
- }
111
- if (bwdKey) {
112
- if (last < 0) return true;
113
- if (st.focusIndex > 0) {
114
- st.focusIndex--;
115
- applyFocusClass(root, bonuses, affordable, st.focusIndex);
116
- }
117
- return true;
118
- }
119
-
120
- switch (e.code) {
121
- case 'Enter':
122
- case 'Space':
123
- if (last < 0 || st.focusIndex < 0) return true;
124
- {
125
- const bonus = affordable[st.focusIndex];
126
- openConfirm(shell, bonus, root, st);
127
- }
128
- return true;
129
- // Bare =/- also step the bet (the Shift+=/- and Numpad variants are handled by betDir above).
130
- case 'Equal':
131
- stepBetBy(1);
132
- return true;
133
- case 'Minus':
134
- stepBetBy(-1);
135
- return true;
136
- case 'Escape':
137
- shell.closeModal();
138
- return true;
139
- default:
140
- return false;
141
- }
142
- };
143
-
144
- return { root, onKey };
145
- }
146
-
147
- /** Apply a CSS keyboard-focus class to the currently focused affordable card. */
148
- function applyFocusClass(overlay: HTMLElement, bonuses: BonusOption[], affordable: BonusOption[], focusIndex: number): void {
149
- for (const b of bonuses) {
150
- const card = overlay.querySelector(`[data-ge="bonus-card-${b.id}"]`) as HTMLElement | null;
151
- if (!card) continue;
152
- card.classList.remove('ge-bonus-card--kbd-focus');
153
- }
154
- const focused = affordable[focusIndex];
155
- if (!focused) return;
156
- const card = overlay.querySelector(`[data-ge="bonus-card-${focused.id}"]`) as HTMLElement | null;
157
- if (card) card.classList.add('ge-bonus-card--kbd-focus');
158
- }
159
-
160
- /** Open the confirm dialog for the given bonus and track it in overlay state. */
161
- function openConfirm(shell: GameShell, bonus: BonusOption, overlay: HTMLElement, st: OverlayState): void {
162
- closeConfirm(overlay, st); // remove any existing confirm
163
- st.confirmBonus = bonus;
164
- overlay.appendChild(buildConfirm(shell, bonus, overlay, st));
165
- shell.fitModals();
166
- }
167
-
168
- /** Remove the confirm dialog and clear the overlay state. */
169
- function closeConfirm(overlay: HTMLElement, st: OverlayState): void {
170
- // The confirm dialog is a .ge-sheet with data-ge="bonus-confirm" appended directly to overlay.
171
- const sheet = overlay.querySelector('[data-ge="bonus-confirm"]') as HTMLElement | null;
172
- if (sheet) sheet.remove();
173
- st.confirmBonus = undefined;
174
- }
175
-
176
- /** Bet control — a compact −/+ pill around the live stake, in a thin footer at the screen bottom.
177
- * Stepping repaints the value, re-prices the cards, and updates the control bar. */
178
- function buildBetBar(shell: GameShell, onChange: () => void): HTMLElement {
179
- const bar = document.createElement('div'); bar.className = 'ge-bb-betbar';
180
- const pill = document.createElement('div'); pill.className = 'ge-bb-betpill';
181
- const val = document.createElement('div'); val.className = 'ge-bb-betval';
182
- const down = stepButton('bb-bet-down', 'minus');
183
- const up = stepButton('bb-bet-up', 'plus');
184
- // Mirror the control bar: disable a stepper at the end of the bet range, and lock both
185
- // while busy — so changing the stake behaves identically here and on the bottom bar.
186
- const paint = () => {
187
- val.innerHTML = `<span>${shell.t('Bet')}</span><b>${formatCurrency(shell.state.bet, shell.config.currency)}</b>`;
188
- const i = shell.state.availableBets.indexOf(shell.state.bet);
189
- down.disabled = shell.state.busy || i <= 0;
190
- up.disabled = shell.state.busy || i >= shell.state.availableBets.length - 1;
191
- };
192
- const step = (dir: 1 | -1) => () => {
193
- const next = stepBet(shell.state, dir);
194
- if (next === shell.state.bet) return;
195
- shell.state.bet = next; shell.emit('betChange', next); shell.render();
196
- paint(); onChange();
197
- };
198
- down.addEventListener('click', step(-1));
199
- up.addEventListener('click', step(1));
200
- paint();
201
- pill.append(down, val, up);
202
- bar.appendChild(pill);
203
- return bar;
204
- }
205
-
206
- function stepButton(ge: string, name: IconName): HTMLButtonElement {
207
- const b = document.createElement('button');
208
- b.className = 'ge-bb-betstep'; b.dataset.ge = ge; b.innerHTML = icon(name);
209
- return b;
210
- }
211
-
212
- /** A grid card: title → thumbnail → description → volatility → price → full-bleed CTA.
213
- * Clicking (when affordable) opens the confirmation modal. */
214
- function buildCard(shell: GameShell, bonus: BonusOption, overlay: HTMLElement, st: OverlayState): HTMLElement {
215
- const accent = effectiveAccent(bonus);
216
- const card = document.createElement('div');
217
- card.className = 'ge-bonus-card'; card.dataset.ge = `bonus-card-${bonus.id}`;
218
- card.style.setProperty('--card-acc', accent);
219
- card.style.setProperty('--card-ink', contrastText(accent));
220
-
221
- const enabled = isAffordable(shell, bonus);
222
- // Stack the confirm on top of the overlay grid (cancel returns to the grid). Re-checks
223
- // affordability at click time, so it's a safe no-op when the option can't be bought.
224
- const select = (): void => {
225
- if (!isAffordable(shell, bonus)) return;
226
- openConfirm(shell, bonus, overlay, st);
227
- };
228
-
229
- // Game-supplied card UI: the shell keeps the wrapper (grid sizing + accent vars) and runs the
230
- // buy flow when the game calls ctx.select(); the game owns everything inside.
231
- if (bonus.custom) {
232
- card.classList.add('ge-bonus-card--custom');
233
- const price = bonus.priceMultiplier * shell.state.bet;
234
- card.appendChild(bonus.custom({
235
- bonus, bet: shell.state.bet, price,
236
- priceText: formatCurrency(price, shell.config.currency),
237
- disabled: !enabled, accent, select,
238
- }));
239
- return card;
240
- }
241
-
242
- card.appendChild(cardBody(shell, bonus));
243
- const cta = document.createElement('button');
244
- cta.className = 'ge-bonus-cta'; cta.dataset.ge = `bonus-cta-${bonus.id}`;
245
- cta.textContent = shell.t(actionLabel(bonus));
246
- card.appendChild(cta);
247
-
248
- if (!enabled) {
249
- card.classList.add('ge-bonus-off');
250
- cta.disabled = true;
251
- } else {
252
- card.addEventListener('click', select);
253
- }
254
- return card;
255
- }
256
-
257
- /** The shared card interior (everything above the action area), reused by the confirm modal. */
258
- function cardBody(shell: GameShell, bonus: BonusOption): HTMLElement {
259
- const price = bonus.priceMultiplier * shell.state.bet;
260
- const wrap = document.createElement('div'); wrap.className = 'ge-bonus-body';
261
- wrap.innerHTML =
262
- `<div class="ge-bonus-title">${bonus.title}</div>` +
263
- `<div class="ge-bonus-thumb">${thumb(bonus)}</div>` +
264
- `<div class="ge-bonus-desc">${bonus.description}</div>` +
265
- `<div class="ge-bonus-spacer"></div>` +
266
- (bonus.volatility ? `<div class="ge-bonus-vol">${volatility(bonus.volatility)}</div>` : '') +
267
- `<div class="ge-bonus-price">${formatCurrency(price, shell.config.currency)}</div>`;
268
- return wrap;
269
- }
270
-
271
- /** Confirmation modal — the shared card chrome (accent title heading, no ✕) with a bonus
272
- * preview body and a full-bleed Cancel + action footer. */
273
- function buildConfirm(shell: GameShell, bonus: BonusOption, overlay: HTMLElement, st: OverlayState): HTMLElement {
274
- const accent = effectiveAccent(bonus);
275
- const ui = createCardModal({ ge: 'bonus-confirm', title: bonus.title, accent, onClose: () => { closeConfirm(overlay, st); } });
276
-
277
- const price = bonus.priceMultiplier * shell.state.bet;
278
- const preview = document.createElement('div'); preview.className = 'ge-confirm-preview';
279
- preview.innerHTML =
280
- `<div class="ge-bonus-thumb">${thumb(bonus)}</div>` +
281
- `<div class="ge-bonus-desc">${bonus.description}</div>` +
282
- (bonus.volatility ? `<div class="ge-bonus-vol">${volatility(bonus.volatility)}</div>` : '') +
283
- `<div class="ge-bonus-price">${formatCurrency(price, shell.config.currency)}</div>`;
284
- ui.body.appendChild(preview);
285
-
286
- const actions = document.createElement('div'); actions.className = 'ge-modal-actions';
287
- const cancel = document.createElement('button');
288
- cancel.className = 'ge-modal-btn ge-modal-btn--ghost'; cancel.dataset.ge = 'bonus-confirm-cancel';
289
- cancel.textContent = shell.t('Cancel');
290
- cancel.addEventListener('click', () => closeConfirm(overlay, st));
291
- const buy = document.createElement('button');
292
- buy.className = 'ge-modal-btn ge-modal-btn--accent'; buy.dataset.ge = 'bonus-confirm-buy';
293
- buy.textContent = shell.t(actionLabel(bonus));
294
- buy.style.color = contrastText(accent); // bg comes from --card-acc on the card
295
- buy.addEventListener('click', () => {
296
- // Re-check at click time: the confirm modal stays open across state changes, so a spin
297
- // starting (busy), buy-bonus being disabled, or the balance dropping must block the purchase.
298
- if (!isAffordable(shell, bonus)) return;
299
- if (bonus.type === 'feature') shell.activateFeature(bonus);
300
- else shell.emit('buyBonusSelect', { id: bonus.id });
301
- shell.closeModal();
302
- });
303
- actions.append(cancel, buy);
304
- ui.card.appendChild(actions);
305
-
306
- return ui.root;
307
- }
308
-
309
- function thumb(bonus: BonusOption): string {
310
- if (bonus.thumbnail) return `<img src="${bonus.thumbnail}" alt="${bonus.title}">`;
311
- return `<span class="ge-bonus-thumb-ph">${icon('gift')}</span>`;
312
- }
313
-
314
- /** Volatility as five lightning bolts (the supplied SVG); `level` lit in the accent, rest dimmed. */
315
- function volatility(level: number): string {
316
- const n = Math.max(0, Math.min(5, level));
317
- const bolt = icon('lightning');
318
- return `<span class="ge-bonus-vol-on">${bolt.repeat(n)}</span>` +
319
- `<span class="ge-bonus-vol-off">${bolt.repeat(5 - n)}</span>`;
320
- }
321
-
322
- function actionLabel(bonus: BonusOption): string {
323
- return bonus.type === 'feature' ? 'Activate' : 'Buy';
324
- }
325
-
326
- function isAffordable(shell: GameShell, bonus: BonusOption): boolean {
327
- if (shell.state.busy || !shell.state.buyBonusEnabled) return false;
328
- return bonus.priceMultiplier * shell.state.bet <= shell.state.balance;
329
- }