@energy8platform/shell 0.4.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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'); lbl.className = 'ge-lbl'; lbl.textContent = label;
13
- const val = document.createElement('span'); val.className = 'ge-rd-val'; val.textContent = value;
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(ge: string, name: IconName, onClick: () => void, active = false): HTMLButtonElement {
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', () => { if (!b.disabled) onClick(); });
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, betUp: HTMLElement | null = null;
79
- let spin: HTMLElement | null = null, auto: HTMLElement | null = null, buy: HTMLElement | null = null;
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'); // tap the stake → bet picker
84
- betValue.addEventListener('click', () => { if (!betLocked(host)) host.actions.openBetPicker(); });
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 = (config.features.buyBonus !== false || config.onBonusBuy) ? buyBtn(host) : null;
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 = fs.current == null ? `${fs.total}` : `${fs.current} / ${fs.total}`;
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 ? readout('fs-totalwin', host.t('Total win'), fmtWin(fs.totalWin)) : null;
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'); step.className = 'ge-betstep'; step.append(betUp, betDown);
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'); divider.className = 'ge-pl-divider';
125
- const spinWrap = document.createElement('div'); spinWrap.className = 'ge-spinwrap';
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'; el.dataset.ge = 'fs-counter';
144
- const lbl = document.createElement('span'); lbl.className = 'ge-fs-lbl'; lbl.textContent = host.t('Free spins');
145
- const num = document.createElement('span'); num.className = 'ge-fs-num'; num.textContent = text;
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[] { return items.filter((x): x is HTMLElement => x !== null); }
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'; buy.dataset.ge = '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; buy.style.color = contrastText(accent);
175
- buy.addEventListener('click', () => { if (!buy.disabled) host.actions.deactivateFeature(); });
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', () => { if (!buy.disabled) host.actions.openBuyBonus(); });
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'; sp.dataset.ge = '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', () => { if (!sp.disabled) host.actions.stopAutoplay(); });
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', () => { if (!sp.disabled) host.actions.spin(); });
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); // keep the STOP disc clickable through autoplay
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);
@@ -1,4 +1,5 @@
1
1
  import type { ShellHost } from '@/core/renderer';
2
+ import type { VolumeKey } from '@/core/types';
2
3
  import { createOverlay } from '../primitives';
3
4
  import { icon } from '../icons';
4
5
 
@@ -27,25 +28,33 @@ export function openSettingsModal(host: ShellHost): HTMLElement {
27
28
  })();
28
29
  body.appendChild(sound);
29
30
 
30
- // Volume sliders — full-width column rows with a live value readout
31
- const slider = (key: string, label: string) => {
31
+ // Volume sliders — full-width column rows with a live value readout. Positions are read from the
32
+ // shell's stored volumes (not hardcoded to 100%), so reopening the overlay reflects the last set
33
+ // value, and `host.setVolume()` from game code updates them live via the registered refreshers.
34
+ const updaters: Partial<Record<VolumeKey, (v: number) => void>> = {};
35
+ const slider = (key: VolumeKey, label: string) => {
32
36
  const row = document.createElement('div'); row.className = 'ge-ov-row ge-col';
33
37
  const head = document.createElement('div'); head.className = 'ge-row-head';
34
- const val = document.createElement('span'); val.className = 'ge-val'; val.textContent = '100%';
38
+ const val = document.createElement('span'); val.className = 'ge-val';
35
39
  head.innerHTML = `<span>${label}</span>`; head.appendChild(val);
36
40
  const input = document.createElement('input');
37
- input.type = 'range'; input.min = '0'; input.max = '1'; input.step = '0.05'; input.value = '1';
41
+ input.type = 'range'; input.min = '0'; input.max = '1'; input.step = '0.05';
38
42
  input.className = 'ge-slider'; input.dataset.ge = `setting-${key}`;
43
+ const paint = (v: number) => { input.value = String(v); val.textContent = `${Math.round(v * 100)}%`; };
44
+ paint(host.getVolume(key));
39
45
  input.addEventListener('input', () => {
40
46
  val.textContent = `${Math.round(Number(input.value) * 100)}%`;
41
- host.emit('settingChange', { key, value: Number(input.value) });
47
+ host.setVolume(key, Number(input.value));
42
48
  });
49
+ updaters[key] = paint;
43
50
  row.append(head, input);
44
51
  return row;
45
52
  };
46
53
  body.appendChild(slider('master', host.t('Master volume')));
47
54
  body.appendChild(slider('music', host.t('Music')));
48
55
  body.appendChild(slider('sfx', host.t('SFX')));
56
+ // Live-update sliders when volume changes via host.setVolume (shell clears on close).
57
+ host.setVolumeRefresh((key, v) => updaters[key]?.(v));
49
58
 
50
59
  // Game info — full-width row button that opens its own overlay
51
60
  const gameInfo = document.createElement('button');
@@ -333,6 +333,9 @@ export class PixiRenderer implements ShellRenderer {
333
333
  notifyResize: (w, h) => host.notifyResize(w, h),
334
334
  setSound: (on) => host.setSound(on),
335
335
  setSoundRefresh: (fn) => host.setSoundRefresh(fn),
336
+ getVolume: (key) => host.getVolume(key),
337
+ setVolume: (key, v) => host.setVolume(key, v),
338
+ setVolumeRefresh: (fn) => host.setVolumeRefresh(fn),
336
339
  // — Pixi-specific surface —
337
340
  get ticker() { return self.app.ticker; },
338
341
  get canvas() { return self.app.canvas as HTMLCanvasElement | undefined; },