@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.
- package/dist/html.cjs.js +679 -113
- package/dist/html.cjs.js.map +1 -1
- package/dist/html.d.ts +213 -31
- package/dist/html.esm.js +672 -114
- package/dist/html.esm.js.map +1 -1
- package/dist/index.cjs.js +376 -22
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +204 -29
- package/dist/index.esm.js +369 -23
- package/dist/index.esm.js.map +1 -1
- package/dist/pixi.cjs.js +957 -285
- package/dist/pixi.cjs.js.map +1 -1
- package/dist/pixi.d.ts +208 -32
- package/dist/pixi.esm.js +950 -286
- package/dist/pixi.esm.js.map +1 -1
- package/package.json +1 -1
- package/src/core/ShellController.ts +75 -21
- package/src/core/icon-names.ts +30 -0
- package/src/core/index.ts +4 -0
- package/src/core/menu.ts +301 -0
- package/src/core/popover.ts +81 -0
- package/src/core/renderer.ts +17 -12
- package/src/core/state.ts +2 -1
- package/src/core/types.ts +12 -6
- package/src/core/version.ts +1 -1
- package/src/ui/html/HtmlRenderer.ts +35 -12
- package/src/ui/html/components/GameInfo.ts +1 -1
- package/src/ui/html/components/Menu.ts +153 -0
- package/src/ui/html/icons.ts +16 -15
- package/src/ui/html/primitives.ts +142 -0
- package/src/ui/html/shell.css.ts +21 -1
- package/src/ui/pixi/PixiRenderer.ts +41 -21
- package/src/ui/pixi/components/BottomBar.ts +80 -9
- package/src/ui/pixi/components/GameInfo.ts +1 -1
- package/src/ui/pixi/components/Menu.ts +167 -0
- package/src/ui/pixi/context.ts +3 -2
- package/src/ui/pixi/icons.ts +16 -15
- package/src/ui/pixi/pixi-icon.ts +15 -3
- package/src/ui/pixi/primitives/controls.ts +46 -0
- package/src/ui/pixi/primitives/popover.ts +163 -0
- package/src/ui/pixi/primitives/scroll.ts +9 -5
- package/src/ui/html/components/Settings.ts +0 -68
- 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
|
+
}
|
package/src/ui/html/shell.css.ts
CHANGED
|
@@ -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 {
|
|
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 '
|
|
134
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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);
|
|
@@ -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
|
+
}
|