@energy8platform/shell 0.6.5 → 0.7.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 (43) hide show
  1. package/dist/html.cjs.js +679 -113
  2. package/dist/html.cjs.js.map +1 -1
  3. package/dist/html.d.ts +213 -31
  4. package/dist/html.esm.js +672 -114
  5. package/dist/html.esm.js.map +1 -1
  6. package/dist/index.cjs.js +376 -22
  7. package/dist/index.cjs.js.map +1 -1
  8. package/dist/index.d.ts +204 -29
  9. package/dist/index.esm.js +369 -23
  10. package/dist/index.esm.js.map +1 -1
  11. package/dist/pixi.cjs.js +957 -285
  12. package/dist/pixi.cjs.js.map +1 -1
  13. package/dist/pixi.d.ts +208 -32
  14. package/dist/pixi.esm.js +950 -286
  15. package/dist/pixi.esm.js.map +1 -1
  16. package/package.json +1 -1
  17. package/src/core/ShellController.ts +75 -21
  18. package/src/core/icon-names.ts +30 -0
  19. package/src/core/index.ts +4 -0
  20. package/src/core/menu.ts +301 -0
  21. package/src/core/popover.ts +81 -0
  22. package/src/core/renderer.ts +17 -12
  23. package/src/core/state.ts +2 -1
  24. package/src/core/types.ts +12 -6
  25. package/src/core/version.ts +1 -1
  26. package/src/ui/html/HtmlRenderer.ts +35 -12
  27. package/src/ui/html/components/GameInfo.ts +1 -1
  28. package/src/ui/html/components/Menu.ts +153 -0
  29. package/src/ui/html/icons.ts +16 -15
  30. package/src/ui/html/primitives.ts +142 -0
  31. package/src/ui/html/shell.css.ts +21 -1
  32. package/src/ui/pixi/PixiRenderer.ts +41 -21
  33. package/src/ui/pixi/components/BottomBar.ts +80 -9
  34. package/src/ui/pixi/components/GameInfo.ts +1 -1
  35. package/src/ui/pixi/components/Menu.ts +167 -0
  36. package/src/ui/pixi/context.ts +3 -2
  37. package/src/ui/pixi/icons.ts +16 -15
  38. package/src/ui/pixi/pixi-icon.ts +15 -3
  39. package/src/ui/pixi/primitives/controls.ts +46 -0
  40. package/src/ui/pixi/primitives/popover.ts +163 -0
  41. package/src/ui/pixi/primitives/scroll.ts +9 -5
  42. package/src/ui/html/components/Settings.ts +0 -68
  43. package/src/ui/pixi/components/Settings.ts +0 -132
@@ -1,4 +1,5 @@
1
1
  import { icon } from './icons';
2
+ import { placePopover, popoverWidth, POPOVER, type Rect } from '@/core/popover';
2
3
 
3
4
  /** Render a (possibly socialised) two-word label across two lines — the BUY BONUS badge.
4
5
  * Shared so the bottom-bar button and the Game-info control legend break identically. */
@@ -83,3 +84,144 @@ export function createOverlay(opts: OverlayOpts): { root: HTMLDivElement; body:
83
84
  root.append(head, scroll);
84
85
  return { root, body, scroll };
85
86
  }
87
+
88
+ export interface PopoverOpts {
89
+ ge: string;
90
+ /** The shell root — the popover is placed in its coordinate space and clamped to it. */
91
+ surface: HTMLElement;
92
+ /** The plate: the bar's own plaque (`.ge-bar-panel` wide / `.ge-m-controls` mobile). Drives the
93
+ * card's x, y, maxH and above/below flip, so the card sits flush with the WHOLE bar rather than
94
+ * with whichever control opened it. Falls back to `pointer` when it can't be resolved (no plaque
95
+ * found), and to a centred, arrow-less card when neither resolves. A function is re-resolved on
96
+ * EVERY `position()` call rather than captured once — a renderer that rebuilds its DOM on
97
+ * resize/re-render (e.g. HtmlRenderer's `renderBar()`) replaces the element, so a captured
98
+ * reference would go stale and silently fall back to a centred card. Pass a resolver whenever the
99
+ * element can be rebuilt out from under the popover. */
100
+ plate: HTMLElement | null | (() => HTMLElement | null);
101
+ /** The control the arrow points at (the burger button). Defaults to `plate` when omitted — the
102
+ * historical single-rect behaviour, kept so every caller that only ever had one rect (i.e. every
103
+ * caller before `plate`/`pointer` were split) keeps behaving exactly as it did before. Same
104
+ * re-resolve-per-call rule as `plate`. */
105
+ pointer?: HTMLElement | null | (() => HTMLElement | null);
106
+ /** An element that visually pops out ABOVE the plate's own box — e.g. the mobile SPIN/FS hero,
107
+ * taller than the `.ge-m-controls` row and vertically centred, so it overflows the row's own top
108
+ * edge. When present (and its measured top is above the plate's), the plate rect's TOP edge is
109
+ * extended upward to match it — bottom edge untouched — so `placePopover` sees the row's true
110
+ * visual extent on the side that matters, instead of a card whose bottom (only `gap` above the
111
+ * plate's own top) can clip the popped-out control's arc. Omit/return null when nothing pops out
112
+ * (e.g. the wide layout, whose plate already contains its content) — a no-op. Same
113
+ * re-resolve-per-call rule as `plate`/`pointer`. */
114
+ plateOverflowTop?: HTMLElement | null | (() => HTMLElement | null);
115
+ /** The scale factor the bar currently applies to itself (HtmlRenderer.applyFitScale's `s`). The
116
+ * card matches it so its typography/padding/row-heights scale in lockstep with the bar chrome.
117
+ * Defaults to 1 (no scaling) when omitted. */
118
+ scale?: () => number;
119
+ onClose: () => void;
120
+ }
121
+
122
+ /** A light-dismiss popover: a transparent full-surface layer (closes on pointerdown) holding a
123
+ * card with an arrow that points at `pointer`. Append rows to `body`; call `position()` after the
124
+ * card is in the DOM and again on resize. */
125
+ export function createPopover(opts: PopoverOpts): {
126
+ root: HTMLDivElement;
127
+ card: HTMLDivElement;
128
+ body: HTMLDivElement;
129
+ position(): void;
130
+ } {
131
+ const root = document.createElement('div');
132
+ root.className = 'ge-pop-layer';
133
+ root.dataset.ge = opts.ge;
134
+ const card = document.createElement('div');
135
+ card.className = 'ge-pop';
136
+ card.dataset.ge = 'menu-card';
137
+ const body = document.createElement('div');
138
+ body.className = 'ge-pop-body';
139
+ const arrow = document.createElement('span');
140
+ arrow.className = 'ge-pop-arrow';
141
+ card.append(body, arrow);
142
+ root.appendChild(card);
143
+ // Clicks inside the card must not reach the dismiss layer.
144
+ card.addEventListener('pointerdown', (e) => e.stopPropagation());
145
+ root.addEventListener('pointerdown', opts.onClose);
146
+
147
+ const resolveEl = (v: HTMLElement | null | (() => HTMLElement | null) | undefined): HTMLElement | null =>
148
+ typeof v === 'function' ? v() : (v ?? null);
149
+
150
+ /** A rect in surface coordinates, or null when unresolved/fully zero-sized (a zero-HEIGHT rect —
151
+ * e.g. a not-yet-laid-out anchor — is still considered valid, matching placePopover's own rule). */
152
+ const rectOf = (el: HTMLElement | null, surfaceRect: DOMRect): Rect | null => {
153
+ if (!el) return null;
154
+ const r = el.getBoundingClientRect();
155
+ if (r.width <= 0 && r.height <= 0) return null;
156
+ return { x: r.left - surfaceRect.left, y: r.top - surfaceRect.top, w: r.width, h: r.height };
157
+ };
158
+
159
+ const position = (): void => {
160
+ const surfaceRect = opts.surface.getBoundingClientRect();
161
+ const surface = { w: surfaceRect.width || opts.surface.clientWidth, h: surfaceRect.height || opts.surface.clientHeight };
162
+ if (surface.w <= 0 || surface.h <= 0) return;
163
+ const s = opts.scale?.() ?? 1;
164
+
165
+ // 1. Clear a prior run's constraints (transform + width + max-height + left) before measuring —
166
+ // otherwise scrollWidth/offsetHeight report the already-scaled/clamped box (not the natural
167
+ // content size) on every call after the first, and the card can shrink to fit a
168
+ // narrower/shorter surface but never grow back when the surface widens/grows again. An uncleared
169
+ // max-height is the worse of the two: placePopover positions a card sized for the STALE clamped
170
+ // height, then the un-clamped natural height (restored below) springs back afterward — so the
171
+ // rendered card can overlap the plate or run off the surface edge until reopened. The transform
172
+ // doesn't affect layout either way, but clearing it keeps every pass measuring the same way.
173
+ // `left` matters too: `.ge-pop` is absolutely positioned inside an `inset:0` layer, so with the
174
+ // previous pass's `left` still applied, its shrink-to-fit width is bounded by (layerWidth − left)
175
+ // instead of the card's true natural width. Invisible at scale 1; a scale below 1 lays the card
176
+ // out at up to 1/s its on-screen width, so a stale left — only ever a few hundred px — can clip it.
177
+ card.style.transform = '';
178
+ card.style.width = '';
179
+ card.style.maxHeight = '';
180
+ card.style.left = '0px';
181
+ const naturalW = card.scrollWidth || POPOVER.minW;
182
+
183
+ // 2. Resolve the ON-SCREEN width from the natural (unscaled) width scaled up to screen units,
184
+ // then set the LOCAL style.width so the card renders at that resolved width once scaled — and
185
+ // re-measure the height at that width (wrapping may have changed it).
186
+ const resolvedW = popoverWidth(surface.w, naturalW * s);
187
+ card.style.width = `${resolvedW / s}px`;
188
+ const naturalH = card.offsetHeight || POPOVER.minH;
189
+
190
+ // 3. Resolve plate/pointer in surface coordinates and place using SCREEN-space size —
191
+ // placePopover works in surface pixels throughout, so both the anchor rects and `size` here are
192
+ // screen units even though the card's own layout (width/height above) is still LOCAL/unscaled.
193
+ const plateEl = resolveEl(opts.plate);
194
+ const pointerEl = resolveEl(opts.pointer);
195
+ let plate = rectOf(plateEl, surfaceRect) ?? rectOf(pointerEl, surfaceRect);
196
+ // Extend the plate's TOP edge upward to a popped-out hero's true top (e.g. the mobile SPIN/FS
197
+ // control, taller than its row) — bottom edge untouched. Both rects are already in the SAME
198
+ // surface-coordinate space (post any bar-scale transform), so this is a plain coordinate compare,
199
+ // no separate unit conversion needed.
200
+ const overflowEl = resolveEl(opts.plateOverflowTop);
201
+ if (plate) {
202
+ const overflowRect = rectOf(overflowEl, surfaceRect);
203
+ if (overflowRect && overflowRect.y < plate.y) {
204
+ plate = { ...plate, h: plate.h + (plate.y - overflowRect.y), y: overflowRect.y };
205
+ }
206
+ }
207
+ const pointer = rectOf(pointerEl, surfaceRect);
208
+ const p = placePopover(plate, surface, { w: resolvedW, h: naturalH * s }, pointer);
209
+
210
+ // 4. Apply. maxHeight and the arrow's offset are LOCAL (unscaled) too, since both live inside the
211
+ // scaled card; transform-origin:top left keeps left/top (screen units) as the card's visual
212
+ // top-left regardless of `s`, and — since a transform doesn't affect layout — the next
213
+ // measurement pass stays clean without needing any extra bookkeeping.
214
+ card.style.left = `${p.x}px`;
215
+ card.style.top = `${p.y}px`;
216
+ card.style.maxHeight = `${p.maxH / s}px`;
217
+ card.style.transformOrigin = 'top left';
218
+ card.style.transform = Math.abs(s - 1) > 0.001 ? `scale(${s})` : '';
219
+ card.classList.toggle('ge-pop-below', p.below);
220
+ if (p.arrowX < 0) arrow.style.display = 'none';
221
+ else {
222
+ arrow.style.display = '';
223
+ arrow.style.left = `${p.arrowX / s}px`;
224
+ }
225
+ };
226
+ return { root, card, body, position };
227
+ }
@@ -172,7 +172,10 @@ export const SHELL_CSS = SHELL_FONT_CSS + SHELL_DIGIT_FONT_CSS + `
172
172
  border-radius:16px; background:var(--shell-plaque-glass); color:#fff; font-size:14px; font-weight:600; }
173
173
  #${SHELL_ROOT_ID} .ge-ov-row .ge-grow { flex:1; text-align:left; }
174
174
  #${SHELL_ROOT_ID} button.ge-ov-row { cursor:pointer; font-family:inherit; transition:background .12s ease, color .12s ease; }
175
- #${SHELL_ROOT_ID} button.ge-ov-row:hover { background:var(--shell-plaque-glass-hover); color:var(--shell-accent); }
175
+ #${SHELL_ROOT_ID} button.ge-ov-row:hover:not([disabled]) { background:var(--shell-plaque-glass-hover); color:var(--shell-accent); }
176
+ /* disabled rows — mirrors Pixi's box.alpha = 0.5 for all three row kinds. A <button> row carries
177
+ the native attribute directly; a toggle/range row is a <div> wrapper, so it gets .ge-disabled. */
178
+ #${SHELL_ROOT_ID} .ge-ov-row[disabled], #${SHELL_ROOT_ID} .ge-ov-row.ge-disabled { opacity:.5; cursor:default; }
176
179
  #${SHELL_ROOT_ID} .ge-ov-row.ge-col { flex-direction:column; align-items:stretch; gap:10px; }
177
180
  #${SHELL_ROOT_ID} .ge-ov-row .ge-row-head { display:flex; justify-content:space-between; align-items:center; }
178
181
  #${SHELL_ROOT_ID} .ge-ov-row .ge-row-head .ge-val { color:var(--shell-plaque-label); font-variant-numeric:tabular-nums; font-weight:700; }
@@ -183,6 +186,7 @@ export const SHELL_CSS = SHELL_FONT_CSS + SHELL_DIGIT_FONT_CSS + `
183
186
  #${SHELL_ROOT_ID} .ge-toggle i { position:absolute; top:2px; left:2px; width:20px; height:20px; border-radius:50%;
184
187
  background:#fff; transition:left .12s ease; }
185
188
  #${SHELL_ROOT_ID} .ge-toggle.ge-on i { left:20px; }
189
+ #${SHELL_ROOT_ID} .ge-toggle:disabled { cursor:default; }
186
190
  /* sound on/off — speaker icon button (replaces the toggle) */
187
191
  #${SHELL_ROOT_ID} .ge-snd { pointer-events:auto; cursor:pointer; border:none; background:none; padding:0;
188
192
  width:36px; height:36px; display:flex; align-items:center; justify-content:center; font-size:24px;
@@ -191,6 +195,22 @@ export const SHELL_CSS = SHELL_FONT_CSS + SHELL_DIGIT_FONT_CSS + `
191
195
  #${SHELL_ROOT_ID} .ge-snd:hover { color:var(--shell-accent); }
192
196
  #${SHELL_ROOT_ID} .ge-snd:active { transform:scale(.92); }
193
197
 
198
+ /* bar menu popover — light dismiss (no dim, no blur), card anchored to the burger */
199
+ #${SHELL_ROOT_ID} .ge-pop-layer { position:absolute; inset:0; z-index:55; pointer-events:auto; }
200
+ #${SHELL_ROOT_ID} .ge-pop { position:absolute; box-sizing:border-box; display:flex; flex-direction:column;
201
+ padding:8px; border-radius:18px; background:var(--shell-plaque-dark);
202
+ box-shadow:0 14px 38px rgba(0,0,0,.5); backdrop-filter:blur(12px) saturate(120%);
203
+ -webkit-backdrop-filter:blur(12px) saturate(120%); animation:ge-ov-in .12s ease-out; }
204
+ #${SHELL_ROOT_ID} .ge-pop-body { overflow-y:auto; overflow-x:hidden; min-height:0; }
205
+ #${SHELL_ROOT_ID} .ge-pop .ge-ov-row { padding:10px 12px; margin-bottom:6px; font-size:13px; }
206
+ #${SHELL_ROOT_ID} .ge-pop .ge-ov-row:last-child { margin-bottom:0; }
207
+ #${SHELL_ROOT_ID} .ge-pop-sep { height:1px; margin:6px 4px; background:var(--shell-plaque-line); opacity:.5; }
208
+ #${SHELL_ROOT_ID} .ge-pop-arrow { position:absolute; bottom:-7px; width:14px; height:14px; margin-left:-7px;
209
+ background:var(--shell-plaque-dark); transform:rotate(45deg); border-radius:3px; }
210
+ #${SHELL_ROOT_ID} .ge-pop.ge-pop-below .ge-pop-arrow { bottom:auto; top:-7px; }
211
+ #${SHELL_ROOT_ID} .ge-pop .ge-mi-icon { flex:0 0 auto; width:20px; font-size:20px; display:flex; }
212
+ #${SHELL_ROOT_ID} .ge-pop .ge-mi-chev { flex:0 0 auto; width:16px; font-size:16px; color:var(--shell-muted); display:flex; }
213
+
194
214
  /* game info — each section is its own glass plaque; body text sized for comfortable reading */
195
215
  #${SHELL_ROOT_ID} .ge-gi-sec { margin-bottom:12px; background:var(--shell-plaque-glass);
196
216
  border-radius:16px; padding:16px 18px; }
@@ -6,6 +6,7 @@ import {
6
6
  RenderTexture,
7
7
  Sprite,
8
8
  type Application,
9
+ type Text,
9
10
  type Ticker,
10
11
  } from 'pixi.js';
11
12
  import type { ShellRenderer, ShellHost, OverlayRequest, OverlayHandle } from '@/core/renderer';
@@ -14,7 +15,7 @@ import type { PixiComponentContext, ShellLayer, LayerHandle } from './context';
14
15
  import { installShellFont, whenFontReady } from './text';
15
16
  import { countUpText, tween } from './motion-pixi';
16
17
  import { BottomBar } from './components/BottomBar';
17
- import { openSettings } from './components/Settings';
18
+ import { openMenu } from './components/Menu';
18
19
  import { openGameInfo } from './components/GameInfo';
19
20
  import { openBuyBonus } from './components/BuyBonus';
20
21
  import { openBetPicker, openAutoplayPicker } from './components/pickers';
@@ -95,6 +96,15 @@ export class PixiRenderer implements ShellRenderer {
95
96
  this.bar = new BottomBar(this.ctx);
96
97
  this.barLayer.addChild(this.bar);
97
98
  this.bar.applyFit();
99
+ // renderBar() runs on every resize AND on ~20 other state changes (bet/win/turbo/mode/…), any of
100
+ // which can change the bar's own fitScale()/menuPlate() (e.g. a WIN pill appearing mid-autoplay
101
+ // retriggers the wide layout's overflow-tightening branch). Only onResize used to reposition the
102
+ // open layer, so a live bar-content change while the menu was open left it at the stale
103
+ // scale/position until the next resize. Every ShellLayer's resize() only re-reads current
104
+ // geometry and re-fits/re-centres itself — none of them call back into renderBar() (verified:
105
+ // Popover, CardModal, Overlay, BuyBonusOverlay) — so this cannot recurse; `currentLayer` is
106
+ // `null` whenever nothing is open, so this cannot throw either.
107
+ this.currentLayer?.resize?.(this.screenW, this.screenH);
98
108
  }
99
109
 
100
110
  setLayout(): void {
@@ -109,16 +119,17 @@ export class PixiRenderer implements ShellRenderer {
109
119
 
110
120
  /** Count a money readout from→to on the freshly-rendered bar's value Text node. Mirrors
111
121
  * PixiGameShell.animateMoney (which counted on the just-built bar's value Texts). */
112
- animateMoney(field: 'balance' | 'win', from: number, to: number): void {
122
+ animateMoney(field: 'balance' | 'win', from: number, to: number, durationMs?: number): void {
113
123
  if (!this.bar) return;
124
+ // countUpText defaults durationMs to 450 — pass it only when the caller overrode it.
125
+ const count = (text: Text, fmt: (n: number) => string) =>
126
+ durationMs == null
127
+ ? countUpText(this.ticker, text, from, to, fmt)
128
+ : countUpText(this.ticker, text, from, to, fmt, durationMs);
114
129
  if (field === 'balance' && this.bar.balanceValue) {
115
- this.moneyAnims.push(
116
- countUpText(this.ticker, this.bar.balanceValue, from, to, (n) => this.ctx.fmt(n)),
117
- );
130
+ this.moneyAnims.push(count(this.bar.balanceValue, (n) => this.ctx.fmt(n)));
118
131
  } else if (field === 'win' && this.bar.winValue) {
119
- this.moneyAnims.push(
120
- countUpText(this.ticker, this.bar.winValue, from, to, (n) => this.ctx.fmtWin(n)),
121
- );
132
+ this.moneyAnims.push(count(this.bar.winValue, (n) => this.ctx.fmtWin(n)));
122
133
  }
123
134
  }
124
135
 
@@ -130,8 +141,18 @@ export class PixiRenderer implements ShellRenderer {
130
141
  openOverlay(req: OverlayRequest): OverlayHandle | void {
131
142
  let layer: ShellLayer | null = null;
132
143
  switch (req.kind) {
133
- case 'settings':
134
- layer = openSettings(this.ctx);
144
+ case 'menu':
145
+ // Getters, not `this.bar` by value: renderBar() destroys/rebuilds the bar on every resize
146
+ // and ~20 other state changes, in the same resize handler that then repositions this popover
147
+ // — see Menu.ts's openMenu doc comment for why this must stay lazy. menuAnchor (the burger)
148
+ // is the arrow's pointer; menuPlate (the plaque) drives placement; fitScale is the same
149
+ // factor BottomBar applies to its own content via `inner.scale`.
150
+ layer = openMenu(
151
+ this.ctx,
152
+ () => this.bar?.menuAnchor() ?? null,
153
+ () => this.bar?.menuPlate() ?? null,
154
+ () => this.bar?.fitScale() ?? 1,
155
+ );
135
156
  break;
136
157
  case 'gameInfo':
137
158
  layer = openGameInfo(this.ctx);
@@ -153,7 +174,7 @@ export class PixiRenderer implements ShellRenderer {
153
174
  break;
154
175
  }
155
176
  if (!layer) return;
156
- this.pushLayer(layer);
177
+ this.pushLayer(layer, { backdrop: req.kind !== 'menu' });
157
178
  const built = layer;
158
179
  return {
159
180
  onKey: built.onKey ? built.onKey.bind(built) : undefined,
@@ -165,11 +186,6 @@ export class PixiRenderer implements ShellRenderer {
165
186
  this.closeLayer();
166
187
  }
167
188
 
168
- refreshSoundIcon(_on: boolean): void {
169
- // No-op: the open Settings overlay registers its icon updater via host.setSoundRefresh, which
170
- // the controller's setSound path drives. The renderer has nothing extra to refresh.
171
- }
172
-
173
189
  /** Fade out (≈250ms, like GameShell's REMOVE_FADE_MS) then tear down; resolves when removed. */
174
190
  destroy(): Promise<void> {
175
191
  if (this.destroyed) return Promise.resolve();
@@ -193,9 +209,11 @@ export class PixiRenderer implements ShellRenderer {
193
209
  }
194
210
 
195
211
  // ── layer stack ────────────────────────────────────────────────────────────
196
- pushLayer(node: ShellLayer): LayerHandle {
212
+ pushLayer(node: ShellLayer, opts?: { backdrop?: boolean }): LayerHandle {
197
213
  this.clearLayer();
198
- this.makeBackdrop(); // frosted snapshot of the scene behind (the DOM's backdrop-filter:blur)
214
+ // Light-dismiss layers (the menu popover) opt out with `{ backdrop: false }` no frosted
215
+ // snapshot, the game stays visible behind them. Every other caller is unaffected (defaults on).
216
+ if (opts?.backdrop !== false) this.makeBackdrop(); // frosted snapshot (the DOM's backdrop-filter:blur)
199
217
  this.currentLayer = node;
200
218
  this.modalLayer.addChild(node);
201
219
  this.fitModals();
@@ -322,6 +340,7 @@ export class PixiRenderer implements ShellRenderer {
322
340
  get tokens() { return host.tokens; },
323
341
  get layout() { return host.layout; },
324
342
  get soundOn() { return host.soundOn; },
343
+ get menu() { return host.menu; },
325
344
  get actions() { return host.actions; },
326
345
  openReplay: (opts) => host.openReplay(opts),
327
346
  t: (s) => host.t(s),
@@ -329,17 +348,18 @@ export class PixiRenderer implements ShellRenderer {
329
348
  emit: host.emit.bind(host),
330
349
  notifyResize: (w, h) => host.notifyResize(w, h),
331
350
  setSound: (on) => host.setSound(on),
332
- setSoundRefresh: (fn) => host.setSoundRefresh(fn),
333
351
  getVolume: (key) => host.getVolume(key),
334
352
  setVolume: (key, v) => host.setVolume(key, v),
335
- setVolumeRefresh: (fn) => host.setVolumeRefresh(fn),
353
+ getMenuValue: (id) => host.getMenuValue(id),
354
+ setMenuValue: (id, v) => host.setMenuValue(id, v),
355
+ setMenuRefresh: (fn) => host.setMenuRefresh(fn),
336
356
  // — Pixi-specific surface —
337
357
  get ticker() { return self.app.ticker; },
338
358
  get canvas() { return self.app.canvas as HTMLCanvasElement | undefined; },
339
359
  get screenW() { return self.app.screen.width; },
340
360
  get screenH() { return self.app.screen.height; },
341
361
  render: () => self.renderBar(),
342
- pushLayer: (node) => self.pushLayer(node),
362
+ pushLayer: (node, opts) => self.pushLayer(node, opts),
343
363
  // Route component-initiated closes through the controller (not straight to self.closeLayer) so
344
364
  // it clears its OverlayHandle. Otherwise the handle goes stale: hasOpenLayer() stays true and
345
365
  // keydowns keep routing to onKey on a destroyed overlay → write to a torn-down ScrollBox.
@@ -1,5 +1,6 @@
1
1
  import { Container, Graphics, Text } from 'pixi.js';
2
2
  import type { PixiComponentContext } from '../context';
3
+ import type { Rect } from '@/core/popover';
3
4
  import { effectiveAccent } from '@/core/colors';
4
5
  import { BUY_BONUS_ART, BUY_BONUS_SOCIAL_ART, BUY_BONUS_DISABLED_ART } from '../../buy-bonus-art';
5
6
  import { FlexBox } from '../primitives/flex';
@@ -118,6 +119,11 @@ export class BottomBar extends Container {
118
119
  private spin?: SpinDisc;
119
120
  private autoBtn?: IconButton;
120
121
  private turboBtn?: IconButton;
122
+ private menuBtn?: IconButton;
123
+ /** The menu's PLATE, in LOCAL coordinates relative to `inner` — the continuous dark panel (wide)
124
+ * or the controls row (mobile). Set at the point `applyFit()`/`applyFitMobile()` compute it;
125
+ * `menuPlate()` converts it to screen space lazily (see that method's doc comment for why). */
126
+ private plateRect: Rect | null = null;
121
127
 
122
128
  constructor(host: PixiComponentContext) {
123
129
  super();
@@ -127,6 +133,57 @@ export class BottomBar extends Container {
127
133
  else this.buildWide();
128
134
  }
129
135
 
136
+ /** Screen-space rect of the burger — the menu popover's POINTER (the arrow's target only; see
137
+ * `menuPlate` for what drives placement). `measureSize()` (the button's own nominal box, e.g.
138
+ * 36×36) rather than the built-in `getSize()` (the glyph's ink bounds only) so a burger whose icon
139
+ * doesn't fill its box still reports its true hit-box size; multiplied by `inner.scale` — `getSize`
140
+ * /`measureSize` only reflect the node's OWN scale (always 1 here), not the ancestor `inner.scale`
141
+ * the bar's whole fit-scale lives on, so the UNMULTIPLIED width/height would silently overstate the
142
+ * burger's true on-screen footprint on a scaled-down bar and skew the arrow off-centre. */
143
+ menuAnchor(): Rect | null {
144
+ if (!this.menuBtn || this.menuBtn.destroyed) return null;
145
+ const p = this.menuBtn.getGlobalPosition();
146
+ const s = this.inner.scale.x;
147
+ const size = this.menuBtn.measureSize();
148
+ return { x: p.x, y: p.y, w: size.w * s, h: size.h * s };
149
+ }
150
+
151
+ /** Screen-space rect of the menu's PLATE — the continuous dark panel (wide) or the controls row
152
+ * (mobile), NOT the burger itself and NOT (mobile) the info pill below it. Drives the popover's
153
+ * x/y/maxH/below; the burger (`menuAnchor`) drives only the arrow. Resolved lazily on every call,
154
+ * like `menuAnchor` — `PixiRenderer.renderBar()` destroys and rebuilds this BottomBar on every
155
+ * resize and ~20 other state changes, so a value captured once would go stale. `plateRect` is
156
+ * local to `inner`; `getGlobalPosition` + `inner.scale` convert it to screen space (`outer*` on the
157
+ * mobile FlexBox and the wide panel's own drawn rect are both LOCAL sizes, unaffected by `inner`'s
158
+ * ancestor scale, same reasoning as `menuAnchor` above). */
159
+ menuPlate(): Rect | null {
160
+ // Pixi v8's Container.destroy() nulls `_position`/`_scale`, so `inner.scale.x` /
161
+ // `inner.getGlobalPosition()` on a destroyed bar throws — guard for symmetry with menuAnchor()
162
+ // above, which already returns null once its button is destroyed. Not reachable today
163
+ // (PixiRenderer.renderBar() destroys and reassigns `this.bar` synchronously, in the same call),
164
+ // but the asymmetry is a trap for a future caller that holds a reference across a render.
165
+ if (this.destroyed) return null;
166
+ if (!this.plateRect) return null;
167
+ const s = this.inner.scale.x;
168
+ const origin = this.inner.getGlobalPosition();
169
+ return {
170
+ x: origin.x + this.plateRect.x * s,
171
+ y: origin.y + this.plateRect.y * s,
172
+ w: this.plateRect.w * s,
173
+ h: this.plateRect.h * s,
174
+ };
175
+ }
176
+
177
+ /** The scale factor the bar currently applies to itself (`inner.scale`) — shared with the menu
178
+ * popover so its typography/padding/row-heights scale in lockstep with the bar's own chrome,
179
+ * instead of ignoring the viewport like a fixed-local-size card would. */
180
+ fitScale(): number {
181
+ // Same destroyed guard as menuPlate()/menuAnchor() above, and the same neutral fallback a
182
+ // scale factor should have (1 = no scaling) rather than throwing.
183
+ if (this.destroyed) return 1;
184
+ return this.inner.scale.x;
185
+ }
186
+
130
187
  // ── wide / landscape ──────────────────────────────────────────────────────
131
188
  private buildWide(): void {
132
189
  const { state, tokens } = this.host;
@@ -139,15 +196,14 @@ export class BottomBar extends Container {
139
196
 
140
197
  // LEFT info group: menu · balance · (Total win) · (Win)
141
198
  const left = new FlexBox({ direction: 'row', align: 'center', gap: ZONE_GAP });
142
- left.add(
143
- new IconButton('menu', {
144
- size: 36,
145
- glyph: 30,
146
- color: '#ffffff',
147
- hover: tokens.accent,
148
- onTap: () => this.host.actions.openMenu(),
149
- }),
150
- );
199
+ this.menuBtn = new IconButton('menu', {
200
+ size: 36,
201
+ glyph: 30,
202
+ color: '#ffffff',
203
+ hover: tokens.accent,
204
+ onTap: () => this.host.actions.openMenu(),
205
+ });
206
+ left.add(this.menuBtn);
151
207
  if (!state.replay) {
152
208
  const bal = readout(this.host, 'Balance', this.host.fmt(state.balance));
153
209
  this.balanceValue = bal.valueText;
@@ -340,6 +396,7 @@ export class BottomBar extends Container {
340
396
  hover: tokens.accent,
341
397
  onTap: () => this.host.actions.openMenu(),
342
398
  });
399
+ this.menuBtn = menu;
343
400
  // autoplay is a base-mode control only — hidden in free spins / replay (matches the DOM bar)
344
401
  if (isBase && config.features.autoplay) {
345
402
  this.autoBtn = new IconButton('autoplay', {
@@ -574,6 +631,8 @@ export class BottomBar extends Container {
574
631
  this.panelBg.clear();
575
632
  roundedPath(this.panelBg, panelX, SPIN_POP, panelRight - panelX, BAR_H, [12, 12, 12, 12]);
576
633
  this.panelBg.fill(tokens.bar);
634
+ // The menu's plate — the whole continuous dark panel, local to `inner` (see menuPlate()).
635
+ this.plateRect = { x: panelX, y: SPIN_POP, w: panelRight - panelX, h: BAR_H };
577
636
 
578
637
  if (this.buy) this.buy.position.set(OUTER_PAD, panelCenterY - BUY_W / 2);
579
638
  left.position.set(panelX + PANEL_PAD, panelCenterY - left.outerHeight / 2);
@@ -613,6 +672,18 @@ export class BottomBar extends Container {
613
672
  // SPIN hero), inflating the controls row so it overlaps the info row below it.
614
673
  controls.setLayoutSize(rowW, M_CTRL_H);
615
674
  info.setLayoutSize(rowW, M_INFO_H);
675
+ // The menu's plate — the controls row (NOT the info pill below it), local to `inner`. Read from
676
+ // `controls.outerWidth/outerHeight` (its own nominal box), NOT the FlexBox's own bounds (which
677
+ // would include the SPIN/FS hero popping out both above AND below it — see menuPlate()'s doc
678
+ // comment) — but the TOP edge is deliberately extended upward by `pop`, the same hero pop-out
679
+ // amount reserved as `topPad` above, so the plate the popover math sees is the row's true visual
680
+ // extent on the side that matters: without this, the card's bottom (only `gap` above the plate's
681
+ // top) can clip the top ~(pop-gap)px of the hero's arc, since 62px M_CTRL_H is 11px shorter than
682
+ // the 84px hero. The BOTTOM edge is left alone — the hero's symmetric under-pop is a separate,
683
+ // unrelated concern (the info pill sits right below with its own small gap) and extending it too
684
+ // would perturb the `below`-placement branch (spaceBelow) for no benefit. A no-op when `pop` is 0
685
+ // (no hero shown — e.g. replay without free spins).
686
+ this.plateRect = { x: 0, y: topPad - pop, w: controls.outerWidth, h: controls.outerHeight + pop };
616
687
  const s = rowW > 0 ? Math.max(0.4, Math.min(1, avail / rowW)) : 1;
617
688
 
618
689
  this.inner.scale.set(s);
@@ -29,7 +29,7 @@ export function openGameInfo(host: PixiComponentContext): ShellLayer {
29
29
  onClose: () => host.closeLayer(),
30
30
  onBack: () => {
31
31
  host.closeLayer();
32
- host.actions.openSettings();
32
+ host.actions.openMenu();
33
33
  },
34
34
  build: (w) => buildBody(host, w),
35
35
  });
@@ -0,0 +1,167 @@
1
+ import { Container, Graphics, Text } from 'pixi.js';
2
+ import { resolveMenu, type MenuRow } from '@/core/menu';
3
+ import type { Rect } from '@/core/popover';
4
+ import type { PixiComponentContext, ShellLayer } from '../context';
5
+ import { Popover } from '../primitives/popover';
6
+ import { FlexBox } from '../primitives/flex';
7
+ import { Slider, Spacer, Toggle } from '../primitives/controls';
8
+ import { makeText } from '../text';
9
+ import { makeIcon } from '../pixi-icon';
10
+ import { attachHover } from '../primitives/widgets';
11
+
12
+ /** The bar menu as a Pixi popover. Same rows, same order as the DOM — both come from resolveMenu.
13
+ *
14
+ * `getAnchor` (the burger — the arrow's POINTER), `getPlate` (the bar's plaque — drives placement)
15
+ * and `getScale` (the bar's own fit-scale) are all FUNCTIONS, resolved lazily on every reposition —
16
+ * never a captured `BottomBar` instance. `PixiRenderer.renderBar()` destroys and rebuilds the
17
+ * BottomBar on every resize AND on ~20 other state changes, in the SAME resize handler that then
18
+ * repositions this popover. A captured instance would already be destroyed by the time `resize()`
19
+ * next runs, so `menuAnchor()`/`menuPlate()` on it would return `null` and the card would silently
20
+ * recentre with its arrow hidden — the exact bug the DOM renderer shipped and fixed the same way
21
+ * (see `html/Menu.ts`'s plate/pointer callbacks). Passing getters means every call reads whichever
22
+ * bar is CURRENT. `getPlate`/`getScale` are optional so every pre-existing caller (which only ever
23
+ * passed `getAnchor`) keeps behaving exactly as it did before the plate/pointer/scale split. */
24
+ export function openMenu(
25
+ host: PixiComponentContext,
26
+ getAnchor?: () => Rect | null,
27
+ getPlate?: () => Rect | null,
28
+ getScale?: () => number,
29
+ ): ShellLayer {
30
+ const updaters: Record<string, (v: boolean | number) => void> = {};
31
+ const layer = new Popover(host, {
32
+ tag: 'menu',
33
+ plate: () => getPlate?.() ?? null,
34
+ pointer: () => getAnchor?.() ?? null,
35
+ scale: () => getScale?.() ?? 1,
36
+ onClose: () => host.closeLayer(),
37
+ build: (width) => {
38
+ const col = new FlexBox({ direction: 'column', align: 'stretch', gap: 6 });
39
+ for (const row of resolveMenu(host)) col.add(buildRow(host, row, width, updaters));
40
+ return col;
41
+ },
42
+ });
43
+ host.setMenuRefresh((id, v) => updaters[id]?.(v));
44
+ return layer;
45
+ }
46
+
47
+ function label(text: string): Text {
48
+ return makeText(text, { size: 13, weight: '600', color: '#ffffff' }) as Text;
49
+ }
50
+
51
+ function rowBox(host: PixiComponentContext, name: string, column = false): FlexBox {
52
+ const box = new FlexBox({
53
+ direction: column ? 'column' : 'row',
54
+ align: column ? 'stretch' : 'center',
55
+ gap: column ? 8 : 10,
56
+ padding: { top: 10, bottom: 10, left: 12, right: 12 },
57
+ minHeight: column ? undefined : 44,
58
+ background: { fill: host.tokens.plaqueGlass, radius: 14 },
59
+ });
60
+ box.label = name;
61
+ return box;
62
+ }
63
+
64
+ function buildRow(
65
+ host: PixiComponentContext,
66
+ row: MenuRow,
67
+ width: number,
68
+ updaters: Record<string, (v: boolean | number) => void>,
69
+ ): Container {
70
+ if (row.kind === 'separator') {
71
+ const sep = new Container();
72
+ sep.label = 'menu-sep';
73
+ const line = new Graphics().rect(4, 6, width - 8, 1).fill(host.tokens.plaqueLine);
74
+ line.alpha = 0.5;
75
+ sep.addChild(line);
76
+ return sep;
77
+ }
78
+ if (row.kind === 'button') {
79
+ const box = rowBox(host, `menu-row-${row.id}`);
80
+ if (row.icon) box.add(makeIcon(row.icon, 20, '#ffffff'));
81
+ const text = label(row.label);
82
+ box.add(text);
83
+ box.add(new Spacer(), { grow: 1 });
84
+ if (row.chevron) box.add(makeIcon('chevronRight', 16, host.tokens.muted));
85
+ if (!row.disabled) {
86
+ box.setInteractive(true);
87
+ box.on('pointertap', () => {
88
+ host.closeLayer();
89
+ row.select();
90
+ });
91
+ attachHover(
92
+ box,
93
+ () => { box.setBgFill(host.tokens.plaqueGlassHover); text.style.fill = host.tokens.accent; },
94
+ () => { box.setBgFill(host.tokens.plaqueGlass); text.style.fill = '#ffffff'; },
95
+ );
96
+ } else {
97
+ box.alpha = 0.5;
98
+ }
99
+ return box;
100
+ }
101
+ if (row.kind === 'toggle') {
102
+ const box = rowBox(host, `menu-row-${row.id}`);
103
+ const glyph = row.icon(row.get());
104
+ const iconNode = glyph ? makeIcon(glyph, 20, '#ffffff') : null;
105
+ if (iconNode) box.add(iconNode);
106
+ box.add(label(row.label));
107
+ box.add(new Spacer(), { grow: 1 });
108
+ // Disabled: neutralise the write-through at the source (a no-op onChange) rather than relying
109
+ // solely on eventMode — Toggle wires its own pointertap listener unconditionally in its
110
+ // constructor, so a stray direct emit must still be harmless, not just unreachable by real hits.
111
+ const toggle = new Toggle(
112
+ row.get(),
113
+ row.disabled ? () => {} : (v) => row.set(v),
114
+ host.tokens.accent,
115
+ host.tokens.plaqueLine,
116
+ );
117
+ box.add(toggle);
118
+ updaters[row.id] = (v) => {
119
+ const on = v === true;
120
+ toggle.setValue(on);
121
+ const next = row.icon(on);
122
+ if (iconNode && next) iconNode.setIcon(next);
123
+ };
124
+ if (row.disabled) {
125
+ box.alpha = 0.5;
126
+ toggle.eventMode = 'none'; // dimmed + inert — mirrors the button branch's disabled treatment
127
+ }
128
+ return box;
129
+ }
130
+ const box = rowBox(host, `menu-row-${row.id}`, true);
131
+ const head = new FlexBox({ direction: 'row', align: 'center' });
132
+ const value = makeText(row.format(row.get()), { size: 12, weight: '700', color: host.tokens.plaqueLabel });
133
+ head.add(label(row.label));
134
+ head.add(new Spacer(), { grow: 1 });
135
+ head.add(value);
136
+ // Slider works in 0..1; map to the row's declared bounds, snapping to the MIN-anchored lattice
137
+ // (min + k·step). Snapping to a bare multiple of step instead (Math.round(raw/step)*step) drifts
138
+ // off `min` whenever min isn't itself a multiple of step — the common case, since the default step
139
+ // is (max-min)/20: e.g. min:1,max:10 gives step 0.45, and the bare-multiple formula would emit 0.9
140
+ // at the far left, BELOW the declared min. Clamp guards any float overshoot past either end.
141
+ const toUnit = (v: number): number => (row.max === row.min ? 0 : (v - row.min) / (row.max - row.min));
142
+ const fromUnit = (u: number): number => {
143
+ const raw = row.min + u * (row.max - row.min);
144
+ const snapped = row.min + Math.round((raw - row.min) / row.step) * row.step;
145
+ return Math.max(row.min, Math.min(row.max, snapped));
146
+ };
147
+ // Disabled: same reasoning as the toggle branch above — a no-op onInput, not just eventMode.
148
+ const slider = new Slider(host, toUnit(row.get()), row.disabled ? () => {} : (u) => {
149
+ const v = fromUnit(u);
150
+ value.text = row.format(v);
151
+ head.layout(); // the readout's text just changed width (e.g. "5%" → "100%") — reposition it
152
+ row.set(v);
153
+ });
154
+ updaters[row.id] = (v) => {
155
+ const n = Number(v);
156
+ value.text = row.format(n);
157
+ head.layout();
158
+ slider.setValue(toUnit(n));
159
+ };
160
+ box.add(head);
161
+ box.add(slider);
162
+ if (row.disabled) {
163
+ box.alpha = 0.5;
164
+ slider.eventMode = 'none';
165
+ }
166
+ return box;
167
+ }