@energy8platform/shell 0.6.6 → 0.7.1
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 +677 -109
- package/dist/html.cjs.js.map +1 -1
- package/dist/html.d.ts +207 -27
- package/dist/html.esm.js +670 -110
- package/dist/html.esm.js.map +1 -1
- package/dist/index.cjs.js +378 -22
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +199 -26
- package/dist/index.esm.js +371 -23
- package/dist/index.esm.js.map +1 -1
- package/dist/pixi.cjs.js +952 -282
- package/dist/pixi.cjs.js.map +1 -1
- package/dist/pixi.d.ts +202 -28
- package/dist/pixi.esm.js +945 -283
- package/dist/pixi.esm.js.map +1 -1
- package/package.json +1 -1
- package/src/core/ShellController.ts +68 -16
- package/src/core/format.ts +8 -4
- 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 +13 -10
- package/src/core/state.ts +2 -1
- package/src/core/types.ts +15 -6
- package/src/core/version.ts +1 -1
- package/src/ui/html/HtmlRenderer.ts +31 -8
- 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 +32 -14
- 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
package/package.json
CHANGED
|
@@ -4,6 +4,7 @@ import { resolveTheme, type ShellTokens } from './theme';
|
|
|
4
4
|
import { formatCurrency } from './format';
|
|
5
5
|
import { createI18n, type I18n } from './i18n';
|
|
6
6
|
import { KeyboardController, type KeyboardHost } from './keyboard';
|
|
7
|
+
import { DEFAULT_MENU, rangeBounds, seedMenuValues, type MenuItem, type MenuRangeItem } from './menu';
|
|
7
8
|
import { PACKAGE_VERSION } from './version';
|
|
8
9
|
import type {
|
|
9
10
|
ShellConfig,
|
|
@@ -49,6 +50,7 @@ export function resolveConfig(config: ShellConfig): ResolvedShellConfig {
|
|
|
49
50
|
theme: config.theme,
|
|
50
51
|
onBonusBuy: config.onBonusBuy,
|
|
51
52
|
volumes: config.volumes,
|
|
53
|
+
menu: config.menu,
|
|
52
54
|
version: config.version ?? '1.0.0',
|
|
53
55
|
isSocial: config.isSocial ?? false,
|
|
54
56
|
replay: config.replay ?? config.mode === 'replay',
|
|
@@ -71,8 +73,9 @@ export class ShellController extends EventEmitter<ShellEvents> implements ShellH
|
|
|
71
73
|
private i18n: I18n;
|
|
72
74
|
private kbd?: KeyboardController;
|
|
73
75
|
private overlay: OverlayHandle | null = null;
|
|
74
|
-
private
|
|
75
|
-
private
|
|
76
|
+
private menuItems: MenuItem[];
|
|
77
|
+
private menuRefresh: ((id: string, value: boolean | number) => void) | null = null;
|
|
78
|
+
private overlayKind: OverlayRequest['kind'] | null = null;
|
|
76
79
|
private prevBalance: number;
|
|
77
80
|
private prevWin: number;
|
|
78
81
|
private destroyed = false;
|
|
@@ -84,6 +87,7 @@ export class ShellController extends EventEmitter<ShellEvents> implements ShellH
|
|
|
84
87
|
this.config = resolveConfig(config);
|
|
85
88
|
this.i18n = createI18n({ language: this.config.language, isSocial: this.config.isSocial });
|
|
86
89
|
this.state = createInitialState(this.config);
|
|
90
|
+
this.menuItems = this.config.menu ?? DEFAULT_MENU;
|
|
87
91
|
this.tokens = resolveTheme(this.config.theme);
|
|
88
92
|
this.prevBalance = this.state.balance;
|
|
89
93
|
this.prevWin = this.state.win;
|
|
@@ -219,14 +223,21 @@ export class ShellController extends EventEmitter<ShellEvents> implements ShellH
|
|
|
219
223
|
private show(req: OverlayRequest): void {
|
|
220
224
|
this.closeModal();
|
|
221
225
|
this.overlay = this.renderer.openOverlay(req) ?? null;
|
|
226
|
+
this.overlayKind = this.overlay ? req.kind : null;
|
|
222
227
|
}
|
|
228
|
+
/** Open the bar menu. Called again while it is open, it closes it — the burger toggles. */
|
|
223
229
|
openMenu(): void {
|
|
230
|
+
if (this.overlayKind === 'menu') {
|
|
231
|
+
this.closeModal();
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
224
234
|
this.emit('menuOpen');
|
|
225
|
-
this.
|
|
235
|
+
this.show({ kind: 'menu' });
|
|
226
236
|
}
|
|
237
|
+
/** @deprecated The Settings overlay is gone — this opens the bar menu. */
|
|
227
238
|
openSettings(): void {
|
|
228
239
|
this.emit('settingsOpen');
|
|
229
|
-
this.
|
|
240
|
+
this.openMenu();
|
|
230
241
|
}
|
|
231
242
|
openInfo(): void {
|
|
232
243
|
this.emit('infoOpen');
|
|
@@ -256,8 +267,8 @@ export class ShellController extends EventEmitter<ShellEvents> implements ShellH
|
|
|
256
267
|
closeModal(): void {
|
|
257
268
|
if (!this.overlay) return;
|
|
258
269
|
this.overlay = null;
|
|
259
|
-
this.
|
|
260
|
-
this.
|
|
270
|
+
this.overlayKind = null;
|
|
271
|
+
this.menuRefresh = null;
|
|
261
272
|
this.renderer.closeOverlay();
|
|
262
273
|
}
|
|
263
274
|
|
|
@@ -265,11 +276,7 @@ export class ShellController extends EventEmitter<ShellEvents> implements ShellH
|
|
|
265
276
|
setSound(on: boolean): void {
|
|
266
277
|
this.soundOn = on;
|
|
267
278
|
this.emit('settingChange', { key: 'sound', value: on });
|
|
268
|
-
this.
|
|
269
|
-
this.renderer.refreshSoundIcon?.(on);
|
|
270
|
-
}
|
|
271
|
-
setSoundRefresh(fn: ((on: boolean) => void) | null): void {
|
|
272
|
-
this.soundRefresh = fn;
|
|
279
|
+
this.menuRefresh?.('sound', on);
|
|
273
280
|
}
|
|
274
281
|
|
|
275
282
|
// ── volume ─────────────────────────────────────────────────────────────────
|
|
@@ -277,16 +284,56 @@ export class ShellController extends EventEmitter<ShellEvents> implements ShellH
|
|
|
277
284
|
return this.state.volumes[key];
|
|
278
285
|
}
|
|
279
286
|
/** Set a volume slider (0..1). Shared by the slider control (drag) and game code (public API):
|
|
280
|
-
* clamps, stores so a reopened
|
|
281
|
-
* live-updates the slider if the
|
|
287
|
+
* clamps, stores so a reopened menu popover reflects it, emits `settingChange`, and
|
|
288
|
+
* live-updates the slider if the menu is currently open. */
|
|
282
289
|
setVolume(key: VolumeKey, value: number): void {
|
|
283
290
|
const v = Math.max(0, Math.min(1, value));
|
|
284
291
|
this.state.volumes[key] = v;
|
|
285
292
|
this.emit('settingChange', { key, value: v });
|
|
286
|
-
this.
|
|
293
|
+
this.menuRefresh?.(key, v);
|
|
287
294
|
}
|
|
288
|
-
|
|
289
|
-
|
|
295
|
+
|
|
296
|
+
// ── menu ───────────────────────────────────────────────────────────────────
|
|
297
|
+
get menu(): MenuItem[] {
|
|
298
|
+
return this.menuItems;
|
|
299
|
+
}
|
|
300
|
+
/** Replace the item list. Values of ids already in state are kept; new ids are seeded. */
|
|
301
|
+
setMenu(items: MenuItem[]): void {
|
|
302
|
+
this.menuItems = items;
|
|
303
|
+
this.state.menu = seedMenuValues(items, this.state.menu);
|
|
304
|
+
if (this.overlayKind === 'menu') this.show({ kind: 'menu' });
|
|
305
|
+
}
|
|
306
|
+
getMenuValue(id: string): boolean | number | undefined {
|
|
307
|
+
if (id === 'sound') return this.soundOn;
|
|
308
|
+
if (id === 'music' || id === 'sfx') return this.state.volumes[id];
|
|
309
|
+
return this.state.menu[id];
|
|
310
|
+
}
|
|
311
|
+
/** Set a menu value. Presets route to their own homes so there is never a second copy. */
|
|
312
|
+
setMenuValue(id: string, value: boolean | number): void {
|
|
313
|
+
if (id === 'sound') {
|
|
314
|
+
this.setSound(value !== false);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
if (id === 'music' || id === 'sfx') {
|
|
318
|
+
this.setVolume(id, Number(value));
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
const next = typeof value === 'number' ? this.clampRange(id, value) : value;
|
|
322
|
+
this.state.menu[id] = next;
|
|
323
|
+
this.emit('settingChange', { key: id, value: next });
|
|
324
|
+
this.menuRefresh?.(id, next);
|
|
325
|
+
}
|
|
326
|
+
setMenuRefresh(fn: ((id: string, value: boolean | number) => void) | null): void {
|
|
327
|
+
this.menuRefresh = fn;
|
|
328
|
+
}
|
|
329
|
+
/** Clamp to the declared bounds of a custom `range` item (a non-range id passes through). */
|
|
330
|
+
private clampRange(id: string, value: number): number {
|
|
331
|
+
const item = this.menuItems.find((i) => (i as { id?: string }).id === id) as
|
|
332
|
+
| MenuRangeItem
|
|
333
|
+
| undefined;
|
|
334
|
+
if (!item || (item as { type?: string }).type !== 'range') return value;
|
|
335
|
+
const { min, max } = rangeBounds(item);
|
|
336
|
+
return Math.max(min, Math.min(max, value));
|
|
290
337
|
}
|
|
291
338
|
|
|
292
339
|
// ── features ─────────────────────────────────────────────────────────────────
|
|
@@ -394,6 +441,11 @@ export class ShellController extends EventEmitter<ShellEvents> implements ShellH
|
|
|
394
441
|
destroy(): Promise<void> {
|
|
395
442
|
if (this.destroyed) return Promise.resolve();
|
|
396
443
|
this.destroyed = true;
|
|
444
|
+
// With the menu (or any overlay) open at teardown, `menuRefresh` / `overlay` / `overlayKind`
|
|
445
|
+
// would otherwise survive the renderer's destroy — so a later setVolume()/setSound() call
|
|
446
|
+
// invokes a stale row updater against already-destroyed Pixi Graphics (or a detached DOM node)
|
|
447
|
+
// and throws. Run BEFORE the renderer teardown below, while it can still close cleanly.
|
|
448
|
+
this.closeModal();
|
|
397
449
|
if (typeof document !== 'undefined') {
|
|
398
450
|
this.kbd?.detach();
|
|
399
451
|
document.removeEventListener('pointerdown', this.pullFocus, true);
|
package/src/core/format.ts
CHANGED
|
@@ -8,15 +8,19 @@ import type { CurrencyConfig } from './types';
|
|
|
8
8
|
* trailing zeros are trimmed down to (but never past) `minDecimals`, so small wins keep their
|
|
9
9
|
* significant digits. Balance / bet / prices stay fixed at `minDecimals`.
|
|
10
10
|
*
|
|
11
|
+
* Separators default to the platform convention — `.` decimal, `,` thousands (`€1,234.50`).
|
|
12
|
+
* A comma decimal is NOT safe as a default: players read "2,50" as 250 and believe they were
|
|
13
|
+
* paid far more than they were. Games that need another convention pass `currency.separator`.
|
|
14
|
+
*
|
|
11
15
|
* Example with `maxDecimals: 4, minDecimals: 2`:
|
|
12
|
-
* fixed → 0.0673 → 0
|
|
13
|
-
* variable → 0.0673 → 0
|
|
16
|
+
* fixed → 0.0673 → 0.07 0.3 → 0.30
|
|
17
|
+
* variable → 0.0673 → 0.0673 0.067 → 0.067 0.3 → 0.30 0 → 0.00
|
|
14
18
|
*/
|
|
15
19
|
export function formatCurrency(value: number, currency: CurrencyConfig, variableDecimals = false): string {
|
|
16
20
|
const maxDecimals = currency.maxDecimals ?? 2;
|
|
17
21
|
const minDecimals = Math.max(0, Math.min(maxDecimals, currency.minDecimals ?? maxDecimals));
|
|
18
|
-
const thousands = currency.separator?.thousands ?? '
|
|
19
|
-
const decimal = currency.separator?.decimal ?? '
|
|
22
|
+
const thousands = currency.separator?.thousands ?? ',';
|
|
23
|
+
const decimal = currency.separator?.decimal ?? '.';
|
|
20
24
|
const safe = Number.isFinite(value) ? value : 0;
|
|
21
25
|
|
|
22
26
|
// fixed callers round at minDecimals; variable callers round at maxDecimals then trim back down.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// AUTO-GENERATED from ../../icons.svg by scripts/gen-icons-from-svg.mjs — do not edit by hand.
|
|
2
|
+
// Sharp monochrome icon set: each glyph is a 24×24 viewBox fragment using currentColor
|
|
3
|
+
// (hollow shapes use fill-rule="evenodd"). Coordinates are baked into the 24×24 space (no
|
|
4
|
+
// <g transform>), so the same fragment renders identically in the DOM <svg> and in Pixi's
|
|
5
|
+
// GraphicsContext.svg. To change an icon: edit icons.svg, then run
|
|
6
|
+
// `node scripts/gen-icons-from-svg.mjs`.
|
|
7
|
+
// The single glyph-name union, shared by core (menu items) and both renderers.
|
|
8
|
+
export const ICON_NAMES = [
|
|
9
|
+
'spin',
|
|
10
|
+
'turbo1',
|
|
11
|
+
'autoplay',
|
|
12
|
+
'stop',
|
|
13
|
+
'menu',
|
|
14
|
+
'minus',
|
|
15
|
+
'plus',
|
|
16
|
+
'gift',
|
|
17
|
+
'info',
|
|
18
|
+
'soundOn',
|
|
19
|
+
'soundOff',
|
|
20
|
+
'close',
|
|
21
|
+
'back',
|
|
22
|
+
'chevronRight',
|
|
23
|
+
'ticket',
|
|
24
|
+
'turbo2',
|
|
25
|
+
'turboOff',
|
|
26
|
+
'chevronUp',
|
|
27
|
+
'chevronDown',
|
|
28
|
+
] as const;
|
|
29
|
+
|
|
30
|
+
export type IconName = (typeof ICON_NAMES)[number];
|
package/src/core/index.ts
CHANGED
|
@@ -28,6 +28,10 @@ export { ShellController, resolveConfig } from './ShellController';
|
|
|
28
28
|
export type { CreateShellOptions } from './ShellController';
|
|
29
29
|
export * from './renderer';
|
|
30
30
|
export * from './types';
|
|
31
|
+
export { DEFAULT_MENU, resolveMenu, seedMenuValues, rangeBounds, isPresetId } from './menu';
|
|
32
|
+
export type { MenuItem, MenuRow, MenuHost, MenuPresetId } from './menu';
|
|
33
|
+
export { placePopover, popoverWidth, POPOVER } from './popover';
|
|
34
|
+
export type { PopoverPlacement, Rect as PopoverRect } from './popover';
|
|
31
35
|
export { resolveTheme, SCHEMES, DEFAULT_ACCENT } from './theme';
|
|
32
36
|
export type { ShellTokens } from './theme';
|
|
33
37
|
export { createI18n, socialize, normalizeLang } from './i18n';
|
package/src/core/menu.ts
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import { ICON_NAMES, type IconName } from './icon-names';
|
|
2
|
+
|
|
3
|
+
/** Built-in presets: the id alone is enough — the shell knows the label, icon and behaviour. */
|
|
4
|
+
export type MenuPresetId = 'sound' | 'music' | 'sfx' | 'gameInfo';
|
|
5
|
+
const PRESET_IDS: readonly string[] = ['sound', 'music', 'sfx', 'gameInfo'];
|
|
6
|
+
|
|
7
|
+
export function isPresetId(id: string): id is MenuPresetId {
|
|
8
|
+
return PRESET_IDS.includes(id);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface MenuItemBase {
|
|
12
|
+
id: string;
|
|
13
|
+
/** Overrides the preset/default label. Run through the shell translator. */
|
|
14
|
+
label?: string;
|
|
15
|
+
icon?: IconName;
|
|
16
|
+
disabled?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type MenuPresetItem = { id: MenuPresetId } & Omit<MenuItemBase, 'id'>;
|
|
20
|
+
export type MenuToggleItem = { type: 'toggle'; value?: boolean; onChange?(v: boolean): void } & MenuItemBase;
|
|
21
|
+
export type MenuRangeItem = {
|
|
22
|
+
type: 'range';
|
|
23
|
+
min?: number;
|
|
24
|
+
max?: number;
|
|
25
|
+
step?: number;
|
|
26
|
+
value?: number;
|
|
27
|
+
/** Right-hand readout. Defaults to percent for a 0..1 range, else the raw number. */
|
|
28
|
+
format?(v: number): string;
|
|
29
|
+
onChange?(v: number): void;
|
|
30
|
+
} & MenuItemBase;
|
|
31
|
+
export type MenuButtonItem = { type: 'button'; chevron?: boolean; onSelect?(): void } & MenuItemBase;
|
|
32
|
+
export type MenuSeparatorItem = { type: 'separator' };
|
|
33
|
+
|
|
34
|
+
export type MenuItem =
|
|
35
|
+
| MenuPresetItem
|
|
36
|
+
| MenuToggleItem
|
|
37
|
+
| MenuRangeItem
|
|
38
|
+
| MenuButtonItem
|
|
39
|
+
| MenuSeparatorItem;
|
|
40
|
+
|
|
41
|
+
/** The rows shown when `ShellConfig.menu` is omitted — today's Settings content, minus master. */
|
|
42
|
+
export const DEFAULT_MENU: MenuItem[] = [
|
|
43
|
+
{ id: 'sound' },
|
|
44
|
+
{ id: 'music' },
|
|
45
|
+
{ id: 'sfx' },
|
|
46
|
+
{ type: 'separator' },
|
|
47
|
+
{ id: 'gameInfo' },
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
/** What `resolveMenu` reads. `ShellController` satisfies it; tests can supply a small literal. */
|
|
51
|
+
export interface MenuHost {
|
|
52
|
+
readonly menu: MenuItem[];
|
|
53
|
+
t(text: string): string;
|
|
54
|
+
getMenuValue(id: string): boolean | number | undefined;
|
|
55
|
+
setMenuValue(id: string, value: boolean | number): void;
|
|
56
|
+
readonly actions: { openInfo(): void };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** A row, ready to draw: no preset knowledge left, no config shapes, just kind + accessors. */
|
|
60
|
+
export type MenuRow =
|
|
61
|
+
| { kind: 'separator' }
|
|
62
|
+
| {
|
|
63
|
+
kind: 'toggle';
|
|
64
|
+
id: string;
|
|
65
|
+
label: string;
|
|
66
|
+
disabled: boolean;
|
|
67
|
+
/** Glyph for the current value (the sound preset swaps speaker on/off). */
|
|
68
|
+
icon(value: boolean): IconName | undefined;
|
|
69
|
+
get(): boolean;
|
|
70
|
+
set(value: boolean): void;
|
|
71
|
+
}
|
|
72
|
+
| {
|
|
73
|
+
kind: 'range';
|
|
74
|
+
id: string;
|
|
75
|
+
label: string;
|
|
76
|
+
icon?: IconName;
|
|
77
|
+
disabled: boolean;
|
|
78
|
+
min: number;
|
|
79
|
+
max: number;
|
|
80
|
+
step: number;
|
|
81
|
+
get(): number;
|
|
82
|
+
set(value: number): void;
|
|
83
|
+
format(value: number): string;
|
|
84
|
+
}
|
|
85
|
+
| {
|
|
86
|
+
kind: 'button';
|
|
87
|
+
id: string;
|
|
88
|
+
label: string;
|
|
89
|
+
icon?: IconName;
|
|
90
|
+
disabled: boolean;
|
|
91
|
+
chevron: boolean;
|
|
92
|
+
select(): void;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const isSeparator = (i: MenuItem): i is MenuSeparatorItem =>
|
|
96
|
+
(i as { type?: string }).type === 'separator';
|
|
97
|
+
|
|
98
|
+
/** Range bounds with defaults: 0..1 like a volume slider, step = a twentieth of the span. */
|
|
99
|
+
export function rangeBounds(item: { min?: number; max?: number; step?: number }): {
|
|
100
|
+
min: number;
|
|
101
|
+
max: number;
|
|
102
|
+
step: number;
|
|
103
|
+
} {
|
|
104
|
+
const min = item.min ?? 0;
|
|
105
|
+
const max = item.max ?? 1;
|
|
106
|
+
const derivedStep = (max - min) / 20;
|
|
107
|
+
// A declared `step` must be a genuinely positive number. `??` alone doesn't catch this: an
|
|
108
|
+
// explicit 0 (or a negative value) is not null/undefined, so it would sail through unchanged —
|
|
109
|
+
// and a renderer's position math divides by it (Pixi's fromUnit: `(raw-min)/step`), turning the
|
|
110
|
+
// slider's value into NaN, which then reaches `state.menu` and the `settingChange` payload.
|
|
111
|
+
const step = item.step != null && item.step > 0 ? item.step : derivedStep;
|
|
112
|
+
return { min, max, step };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Initial values for CUSTOM items (presets keep their own homes). Values already in `prev` win, so
|
|
116
|
+
* a later `setMenu()` with the same ids does not reset what the player has changed. */
|
|
117
|
+
export function seedMenuValues(
|
|
118
|
+
items: MenuItem[],
|
|
119
|
+
prev: Record<string, boolean | number> = {},
|
|
120
|
+
): Record<string, boolean | number> {
|
|
121
|
+
const out: Record<string, boolean | number> = {};
|
|
122
|
+
for (const item of items) {
|
|
123
|
+
if (isSeparator(item)) continue;
|
|
124
|
+
const type = (item as { type?: string }).type;
|
|
125
|
+
if (!type || isPresetId(item.id)) continue;
|
|
126
|
+
// A previous value only carries over when its runtime type still matches this item's kind.
|
|
127
|
+
// Without this guard, reconfiguring the same id from a toggle to a range (or back) would seed
|
|
128
|
+
// a boolean into a slider's position math (or a stale number into a checkbox) — a legitimate
|
|
129
|
+
// reconfiguration, not a config error, so it falls through to the item's own default instead
|
|
130
|
+
// of warning.
|
|
131
|
+
const prevValue = prev[item.id];
|
|
132
|
+
if (type === 'toggle') {
|
|
133
|
+
out[item.id] = typeof prevValue === 'boolean' ? prevValue : ((item as MenuToggleItem).value ?? false);
|
|
134
|
+
} else if (type === 'range') {
|
|
135
|
+
const r = item as MenuRangeItem;
|
|
136
|
+
out[item.id] = typeof prevValue === 'number' ? prevValue : (r.value ?? rangeBounds(r).min);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const percent = (v: number): string => `${Math.round(v * 100)}%`;
|
|
143
|
+
|
|
144
|
+
function safeIcon(name: string | undefined): IconName | undefined {
|
|
145
|
+
return name && (ICON_NAMES as readonly string[]).includes(name) ? (name as IconName) : undefined;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Every drop/collision warning goes through here so the message style stays uniform. */
|
|
149
|
+
function warn(message: string): void {
|
|
150
|
+
console.warn(`[shell] ${message}`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Expand the configured list into render-ready rows. A config mistake — an unknown preset id, an
|
|
154
|
+
* unrecognized custom `type`, or an invalid range span — is dropped with one warning rather than
|
|
155
|
+
* silently misbehaving: a typo must be visible, not silently invisible. A custom id that collides
|
|
156
|
+
* with a reserved preset id also warns, but keeps its row (see `custom()`). */
|
|
157
|
+
export function resolveMenu(host: MenuHost): MenuRow[] {
|
|
158
|
+
const rows: MenuRow[] = [];
|
|
159
|
+
for (const item of host.menu) {
|
|
160
|
+
if (isSeparator(item)) {
|
|
161
|
+
rows.push({ kind: 'separator' });
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
const type = (item as { type?: string }).type;
|
|
165
|
+
if (!type) {
|
|
166
|
+
const row = preset(host, item as MenuPresetItem);
|
|
167
|
+
if (row) rows.push(row);
|
|
168
|
+
else warn(`unknown menu preset id "${item.id}" — item skipped`);
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
const row = custom(host, item as MenuToggleItem | MenuRangeItem | MenuButtonItem, type);
|
|
172
|
+
if (row) rows.push(row);
|
|
173
|
+
}
|
|
174
|
+
return rows;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function preset(host: MenuHost, item: MenuPresetItem): MenuRow | null {
|
|
178
|
+
const disabled = item.disabled ?? false;
|
|
179
|
+
const label = host.t(item.label ?? DEFAULT_LABELS[item.id] ?? item.id);
|
|
180
|
+
switch (item.id) {
|
|
181
|
+
case 'sound':
|
|
182
|
+
return {
|
|
183
|
+
kind: 'toggle',
|
|
184
|
+
id: 'sound',
|
|
185
|
+
label,
|
|
186
|
+
disabled,
|
|
187
|
+
icon: (v) => safeIcon(item.icon) ?? (v ? 'soundOn' : 'soundOff'),
|
|
188
|
+
get: () => host.getMenuValue('sound') !== false,
|
|
189
|
+
set: (v) => host.setMenuValue('sound', v),
|
|
190
|
+
};
|
|
191
|
+
case 'music':
|
|
192
|
+
case 'sfx': {
|
|
193
|
+
const id = item.id;
|
|
194
|
+
return {
|
|
195
|
+
kind: 'range',
|
|
196
|
+
id,
|
|
197
|
+
label,
|
|
198
|
+
icon: safeIcon(item.icon),
|
|
199
|
+
disabled,
|
|
200
|
+
min: 0,
|
|
201
|
+
max: 1,
|
|
202
|
+
step: 0.05,
|
|
203
|
+
get: () => Number(host.getMenuValue(id) ?? 1),
|
|
204
|
+
set: (v) => host.setMenuValue(id, v),
|
|
205
|
+
format: percent,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
case 'gameInfo':
|
|
209
|
+
return {
|
|
210
|
+
kind: 'button',
|
|
211
|
+
id: 'gameInfo',
|
|
212
|
+
label,
|
|
213
|
+
icon: safeIcon(item.icon) ?? 'info',
|
|
214
|
+
disabled,
|
|
215
|
+
chevron: true,
|
|
216
|
+
select: () => host.actions.openInfo(),
|
|
217
|
+
};
|
|
218
|
+
default:
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const DEFAULT_LABELS: Record<string, string> = {
|
|
224
|
+
sound: 'Sound',
|
|
225
|
+
music: 'Music',
|
|
226
|
+
sfx: 'SFX',
|
|
227
|
+
gameInfo: 'Game info',
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
function custom(
|
|
231
|
+
host: MenuHost,
|
|
232
|
+
item: MenuToggleItem | MenuRangeItem | MenuButtonItem,
|
|
233
|
+
type: string,
|
|
234
|
+
): MenuRow | null {
|
|
235
|
+
// Same store, different semantics: the real preset's get() and a custom row's get() disagree
|
|
236
|
+
// (see the preset cases below vs. the toggle case here). Routing does not change — Task 4's
|
|
237
|
+
// ShellHost is what actually owns the value — this warning just makes the clash visible.
|
|
238
|
+
if (isPresetId(item.id)) {
|
|
239
|
+
warn(`menu item id "${item.id}" collides with a built-in preset id — preset ids are reserved`);
|
|
240
|
+
}
|
|
241
|
+
const disabled = item.disabled ?? false;
|
|
242
|
+
const label = host.t(item.label ?? item.id);
|
|
243
|
+
const icon = safeIcon(item.icon);
|
|
244
|
+
if (type === 'toggle') {
|
|
245
|
+
const it = item as MenuToggleItem;
|
|
246
|
+
return {
|
|
247
|
+
kind: 'toggle',
|
|
248
|
+
id: it.id,
|
|
249
|
+
label,
|
|
250
|
+
disabled,
|
|
251
|
+
icon: () => icon,
|
|
252
|
+
get: () => host.getMenuValue(it.id) === true,
|
|
253
|
+
set: (v) => {
|
|
254
|
+
host.setMenuValue(it.id, v);
|
|
255
|
+
it.onChange?.(v);
|
|
256
|
+
},
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
if (type === 'range') {
|
|
260
|
+
const it = item as MenuRangeItem;
|
|
261
|
+
const { min, max, step } = rangeBounds(it);
|
|
262
|
+
if (max <= min) {
|
|
263
|
+
// A renderer's position math ((value - min) / (max - min)) turns this into Infinity/NaN —
|
|
264
|
+
// drop the row instead, exactly like an unknown preset id.
|
|
265
|
+
warn(`menu item "${it.id}" has invalid range bounds (min ${min}, max ${max}) — item skipped`);
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
return {
|
|
269
|
+
kind: 'range',
|
|
270
|
+
id: it.id,
|
|
271
|
+
label,
|
|
272
|
+
icon,
|
|
273
|
+
disabled,
|
|
274
|
+
min,
|
|
275
|
+
max,
|
|
276
|
+
step,
|
|
277
|
+
get: () => Number(host.getMenuValue(it.id) ?? min),
|
|
278
|
+
set: (v) => {
|
|
279
|
+
host.setMenuValue(it.id, v);
|
|
280
|
+
it.onChange?.(v);
|
|
281
|
+
},
|
|
282
|
+
format: it.format ?? (min === 0 && max === 1 ? percent : (v) => String(v)),
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
if (type === 'button') {
|
|
286
|
+
const it = item as MenuButtonItem;
|
|
287
|
+
return {
|
|
288
|
+
kind: 'button',
|
|
289
|
+
id: it.id,
|
|
290
|
+
label,
|
|
291
|
+
icon,
|
|
292
|
+
disabled,
|
|
293
|
+
chevron: it.chevron ?? false,
|
|
294
|
+
select: () => it.onSelect?.(),
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
// Neither toggle, range, nor button — a typo'd `type` used to fall through to an unconditional
|
|
298
|
+
// button row with no onSelect. Drop it visibly instead.
|
|
299
|
+
warn(`menu item "${item.id}" has unknown type "${type}" — item skipped`);
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/** Geometry for the bar-menu popover. Pure math over rectangles so the DOM and Pixi renderers
|
|
2
|
+
* place it identically — the renderers only supply measured sizes and apply the result. */
|
|
3
|
+
|
|
4
|
+
export interface Rect { x: number; y: number; w: number; h: number }
|
|
5
|
+
export interface Surface { w: number; h: number }
|
|
6
|
+
|
|
7
|
+
export interface PopoverPlacement {
|
|
8
|
+
/** Top-left of the popover card, in surface coordinates. */
|
|
9
|
+
x: number;
|
|
10
|
+
y: number;
|
|
11
|
+
/** True space on the chosen side, in surface coordinates; may be less than minH on a very short surface. The row list scrolls inside it. */
|
|
12
|
+
maxH: number;
|
|
13
|
+
/** Arrow centre, relative to the card's left edge. `-1` when there is no anchor to point at. */
|
|
14
|
+
arrowX: number;
|
|
15
|
+
/** True when the card opens below the anchor (arrow flips to the top edge). */
|
|
16
|
+
below: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const POPOVER = {
|
|
20
|
+
/** Keep-out from the surface edges. */
|
|
21
|
+
margin: 8,
|
|
22
|
+
/** Space between the anchor and the card. */
|
|
23
|
+
gap: 8,
|
|
24
|
+
/** Minimum distance from the arrow tip to either rounded corner. */
|
|
25
|
+
arrowInset: 14,
|
|
26
|
+
/** A card shorter than this does not fit — flip to the other side instead. */
|
|
27
|
+
minH: 120,
|
|
28
|
+
minW: 220,
|
|
29
|
+
maxW: 320,
|
|
30
|
+
} as const;
|
|
31
|
+
|
|
32
|
+
const clamp = (v: number, lo: number, hi: number): number => Math.max(lo, Math.min(hi, v));
|
|
33
|
+
|
|
34
|
+
/** Card width: content width clamped to [minW, maxW] and never wider than the surface. */
|
|
35
|
+
export function popoverWidth(surfaceW: number, contentW: number): number {
|
|
36
|
+
const hi = Math.min(POPOVER.maxW, surfaceW - POPOVER.margin * 2);
|
|
37
|
+
return Math.max(0, clamp(contentW, Math.min(POPOVER.minW, hi), hi));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Place the card above the `anchor` (below if it does not fit), left-aligned to it and clamped
|
|
41
|
+
* inside the surface. `anchor === null` (no bar / hidden shell) centres it, arrow off.
|
|
42
|
+
*
|
|
43
|
+
* `anchor` drives PLACEMENT (x, y, maxH, below) — normally the bar's whole plaque ("plate"), so the
|
|
44
|
+
* card sits flush with the bar as a whole rather than with whichever control opened it. `pointer` is
|
|
45
|
+
* the (optional) rect the ARROW points at — normally the burger button, which can sit anywhere
|
|
46
|
+
* inside the plate. Defaults to `anchor` when omitted, so every caller that only ever had one rect
|
|
47
|
+
* (i.e. every caller before `pointer` existed) keeps behaving exactly as it did before. */
|
|
48
|
+
export function placePopover(
|
|
49
|
+
anchor: Rect | null,
|
|
50
|
+
surface: Surface,
|
|
51
|
+
size: { w: number; h: number },
|
|
52
|
+
pointer: Rect | null = null,
|
|
53
|
+
): PopoverPlacement {
|
|
54
|
+
const { margin, gap, arrowInset, minH } = POPOVER;
|
|
55
|
+
if (!anchor) {
|
|
56
|
+
const maxH = Math.max(0, surface.h - margin * 2);
|
|
57
|
+
const h = Math.min(size.h, maxH);
|
|
58
|
+
const rawY = (surface.h - h) / 2;
|
|
59
|
+
const y = clamp(rawY, margin, Math.max(margin, surface.h - h - margin));
|
|
60
|
+
return {
|
|
61
|
+
x: Math.max(margin, (surface.w - size.w) / 2),
|
|
62
|
+
y,
|
|
63
|
+
maxH,
|
|
64
|
+
arrowX: -1,
|
|
65
|
+
below: false,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
const spaceAbove = anchor.y - gap - margin;
|
|
69
|
+
const spaceBelow = surface.h - (anchor.y + anchor.h) - gap - margin;
|
|
70
|
+
// Prefer above; flip only when the card would be squeezed below its usable minimum AND there is
|
|
71
|
+
// genuinely more room on the other side.
|
|
72
|
+
const below = spaceAbove < Math.min(size.h, minH) && spaceBelow > spaceAbove;
|
|
73
|
+
const maxH = Math.max(0, below ? spaceBelow : spaceAbove);
|
|
74
|
+
const h = Math.min(size.h, maxH);
|
|
75
|
+
const x = clamp(anchor.x, margin, Math.max(margin, surface.w - size.w - margin));
|
|
76
|
+
const rawY = below ? anchor.y + anchor.h + gap : anchor.y - gap - h;
|
|
77
|
+
const y = clamp(rawY, margin, Math.max(margin, surface.h - h - margin));
|
|
78
|
+
const arrowAnchor = pointer ?? anchor;
|
|
79
|
+
const arrowX = clamp(arrowAnchor.x + arrowAnchor.w / 2 - x, arrowInset, Math.max(arrowInset, size.w - arrowInset));
|
|
80
|
+
return { x, y, maxH, arrowX, below };
|
|
81
|
+
}
|
package/src/core/renderer.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type {
|
|
|
4
4
|
ResolvedShellConfig, ShellState, ShellEvents, BonusOption,
|
|
5
5
|
ModalOptions, ReplayModalOptions, VolumeKey,
|
|
6
6
|
} from './types';
|
|
7
|
+
import type { MenuItem } from './menu';
|
|
7
8
|
|
|
8
9
|
export type ShellLayoutMode = 'wide' | 'mobile';
|
|
9
10
|
|
|
@@ -27,8 +28,6 @@ export interface ShellRenderer {
|
|
|
27
28
|
openOverlay(req: OverlayRequest): OverlayHandle | void;
|
|
28
29
|
/** Tear down any open overlay. */
|
|
29
30
|
closeOverlay(): void;
|
|
30
|
-
/** If the open overlay registered a sound-icon refresher, the controller calls this to refresh it. */
|
|
31
|
-
refreshSoundIcon?(on: boolean): void;
|
|
32
31
|
/** Fade out + remove all nodes; resolve when gone. */
|
|
33
32
|
destroy(): Promise<void> | void;
|
|
34
33
|
|
|
@@ -75,17 +74,21 @@ export interface ShellHost {
|
|
|
75
74
|
emit: EventEmitter<ShellEvents>['emit'];
|
|
76
75
|
/** Renderer reports its surface size; the controller recomputes layout + re-renders. */
|
|
77
76
|
notifyResize(w: number, h: number): void;
|
|
78
|
-
/** Flip shared sound state (emits settingChange + refreshes an open
|
|
77
|
+
/** Flip shared sound state (emits settingChange + refreshes an open menu's sound icon). */
|
|
79
78
|
setSound(on: boolean): void;
|
|
80
|
-
/**
|
|
81
|
-
setSoundRefresh(fn: ((on: boolean) => void) | null): void;
|
|
82
|
-
/** Current volume slider position (0..1) for master/music/sfx. */
|
|
79
|
+
/** Current volume slider position (0..1) for music/sfx. */
|
|
83
80
|
getVolume(key: VolumeKey): number;
|
|
84
81
|
/** Set a volume slider (0..1): clamps, stores, emits `settingChange`, and live-updates an open
|
|
85
|
-
*
|
|
82
|
+
* menu. Called by the slider control on drag AND by game code as the public API. */
|
|
86
83
|
setVolume(key: VolumeKey, value: number): void;
|
|
87
|
-
/**
|
|
88
|
-
|
|
84
|
+
/** The configured menu items (see core/menu.ts). */
|
|
85
|
+
readonly menu: MenuItem[];
|
|
86
|
+
/** Current value of a menu item — presets included (sound → soundOn, music/sfx → volumes). */
|
|
87
|
+
getMenuValue(id: string): boolean | number | undefined;
|
|
88
|
+
/** Set a menu value: clamps ranges, stores, emits `settingChange`, refreshes an open menu. */
|
|
89
|
+
setMenuValue(id: string, value: boolean | number): void;
|
|
90
|
+
/** An open menu registers a row updater here (null clears it on close). */
|
|
91
|
+
setMenuRefresh(fn: ((id: string, value: boolean | number) => void) | null): void;
|
|
89
92
|
/** Logic-bearing actions invoked by renderer controls. */
|
|
90
93
|
readonly actions: ShellActions;
|
|
91
94
|
/** Re-show the replay summary modal through the controller (keeps its OverlayHandle in sync).
|
|
@@ -126,7 +129,7 @@ export interface OverlayHandle {
|
|
|
126
129
|
}
|
|
127
130
|
|
|
128
131
|
export type OverlayRequest =
|
|
129
|
-
| { kind: '
|
|
132
|
+
| { kind: 'menu' }
|
|
130
133
|
| { kind: 'gameInfo' }
|
|
131
134
|
| { kind: 'buyBonus' }
|
|
132
135
|
| { kind: 'betPicker' }
|