@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.
Files changed (44) hide show
  1. package/dist/html.cjs.js +677 -109
  2. package/dist/html.cjs.js.map +1 -1
  3. package/dist/html.d.ts +207 -27
  4. package/dist/html.esm.js +670 -110
  5. package/dist/html.esm.js.map +1 -1
  6. package/dist/index.cjs.js +378 -22
  7. package/dist/index.cjs.js.map +1 -1
  8. package/dist/index.d.ts +199 -26
  9. package/dist/index.esm.js +371 -23
  10. package/dist/index.esm.js.map +1 -1
  11. package/dist/pixi.cjs.js +952 -282
  12. package/dist/pixi.cjs.js.map +1 -1
  13. package/dist/pixi.d.ts +202 -28
  14. package/dist/pixi.esm.js +945 -283
  15. package/dist/pixi.esm.js.map +1 -1
  16. package/package.json +1 -1
  17. package/src/core/ShellController.ts +68 -16
  18. package/src/core/format.ts +8 -4
  19. package/src/core/icon-names.ts +30 -0
  20. package/src/core/index.ts +4 -0
  21. package/src/core/menu.ts +301 -0
  22. package/src/core/popover.ts +81 -0
  23. package/src/core/renderer.ts +13 -10
  24. package/src/core/state.ts +2 -1
  25. package/src/core/types.ts +15 -6
  26. package/src/core/version.ts +1 -1
  27. package/src/ui/html/HtmlRenderer.ts +31 -8
  28. package/src/ui/html/components/GameInfo.ts +1 -1
  29. package/src/ui/html/components/Menu.ts +153 -0
  30. package/src/ui/html/icons.ts +16 -15
  31. package/src/ui/html/primitives.ts +142 -0
  32. package/src/ui/html/shell.css.ts +21 -1
  33. package/src/ui/pixi/PixiRenderer.ts +32 -14
  34. package/src/ui/pixi/components/BottomBar.ts +80 -9
  35. package/src/ui/pixi/components/GameInfo.ts +1 -1
  36. package/src/ui/pixi/components/Menu.ts +167 -0
  37. package/src/ui/pixi/context.ts +3 -2
  38. package/src/ui/pixi/icons.ts +16 -15
  39. package/src/ui/pixi/pixi-icon.ts +15 -3
  40. package/src/ui/pixi/primitives/controls.ts +46 -0
  41. package/src/ui/pixi/primitives/popover.ts +163 -0
  42. package/src/ui/pixi/primitives/scroll.ts +9 -5
  43. package/src/ui/html/components/Settings.ts +0 -68
  44. package/src/ui/pixi/components/Settings.ts +0 -132
@@ -0,0 +1,163 @@
1
+ import { Container, Graphics, Rectangle } from 'pixi.js';
2
+ import { placePopover, popoverWidth, POPOVER, type Rect } from '@/core/popover';
3
+ import type { PixiComponentContext, ShellLayer } from '../context';
4
+ import { ScrollBox } from './scroll';
5
+ import { FlexBox } from './flex';
6
+
7
+ export interface PopoverOpts {
8
+ tag?: string;
9
+ /** The plate: drives x, y, maxH, below — screen coordinates, re-read on every layout (the bar
10
+ * rebuilds often). Falls back to `pointer` when it resolves to null (no distinct plaque), and to
11
+ * the centred, arrow-less layout when neither resolves. */
12
+ plate(): Rect | null;
13
+ /** The control the arrow points at (e.g. the burger) — drives arrowX only. Defaults to `plate`
14
+ * (today's single-rect behaviour) when omitted. */
15
+ pointer?(): Rect | null;
16
+ /** Scale factor the card matches to the bar's own fit-scale (`BottomBar.fitScale()`), so its
17
+ * typography/padding/row-heights carry the same visual weight the bar's chrome has. Defaults to 1. */
18
+ scale?(): number;
19
+ onClose(): void;
20
+ /** Build the card content for a given inner width (LOCAL/unscaled units — see `scale`). */
21
+ build(width: number): Container;
22
+ }
23
+
24
+ /** Light-dismiss popover: a transparent full-screen hit rect + a rounded card with an arrow.
25
+ * No veil, no frosted snapshot — the game stays visible and unblurred behind it. */
26
+ export class Popover extends Container implements ShellLayer {
27
+ readonly tag?: string;
28
+ readonly dismissLayer = new Graphics();
29
+ readonly card = new Container();
30
+ private bg = new Graphics();
31
+ private arrow = new Graphics();
32
+ private scroll: ScrollBox;
33
+ private host: PixiComponentContext;
34
+ private opts: PopoverOpts;
35
+ private _cardX = 0;
36
+ private _cardY = 0;
37
+ private _cardW = 0;
38
+ private _arrowX = -1;
39
+
40
+ constructor(host: PixiComponentContext, opts: PopoverOpts) {
41
+ super();
42
+ this.host = host;
43
+ this.opts = opts;
44
+ this.tag = opts.tag;
45
+ this.scroll = new ScrollBox(host.canvas);
46
+ this.card.addChild(this.bg, this.arrow, this.scroll);
47
+ this.addChild(this.dismissLayer, this.card);
48
+ this.dismissLayer.eventMode = 'static';
49
+ this.dismissLayer.on('pointertap', () => this.opts.onClose());
50
+ // Taps on the card must not fall through to the dismiss layer.
51
+ this.card.eventMode = 'static';
52
+ // `e` itself is undefined when a caller (or test) emits the event with no payload, so guard the
53
+ // property access, not just the call.
54
+ this.card.on('pointertap', (e?: { stopPropagation?: () => void }) => e?.stopPropagation?.());
55
+ this.resize(host.screenW, host.screenH);
56
+ }
57
+
58
+ get cardX(): number { return this._cardX; }
59
+ get cardY(): number { return this._cardY; }
60
+ get cardWidth(): number { return this._cardW; }
61
+ get arrowX(): number { return this._arrowX; }
62
+ get arrowVisible(): boolean { return this.arrow.visible; }
63
+
64
+ resize(w: number, h: number): void {
65
+ this.dismissLayer.clear();
66
+ this.dismissLayer.rect(0, 0, w, h).fill({ color: 0x000000, alpha: 0 });
67
+ this.dismissLayer.hitArea = new Rectangle(0, 0, w, h);
68
+
69
+ const s = this.opts.scale?.() ?? 1;
70
+ const pad = 8;
71
+ // Width is content-driven (spec: clamped to [220, min(320, surfaceWidth-16)]), so the real
72
+ // content has to be measured before we know the final width to lay it out at. Probe-build once
73
+ // at the SMALLEST allowed inner width purely to measure natural size, then throw that copy away
74
+ // and build the kept one at the resolved final width. Measured in LOCAL (unscaled) units, like
75
+ // every other Pixi size in this file — `s` only converts to screen units where placement needs
76
+ // it (the card carries the scale as a single transform, same as the DOM's card).
77
+ //
78
+ // The probe deliberately measures at the MINIMUM, not the maximum: a decorative row (the menu's
79
+ // separator) draws its divider line at exactly the width `build()` is called with — it has no
80
+ // content-driven size of its own, unlike every real row, which ignores that parameter and sizes
81
+ // from its icon/label/control regardless. Probing at the maximum would make that separator line
82
+ // alone measure near-maximum and dominate `naturalWidth`, pegging the card at ~maxW for every
83
+ // menu that has a separator — which the default menu always does. Probing at the minimum cannot
84
+ // distort the result the other way: content narrower than the probe still clamps to minW either
85
+ // way (nothing above changes), and content wider than the probe always wins the max() the layout
86
+ // takes over row widths, so real content still drives growth past minW correctly.
87
+ const probeW = POPOVER.minW - pad * 2;
88
+ const probe = this.opts.build(probeW);
89
+ const measured = probe instanceof FlexBox ? probe.measureSize().w : probe.getSize().width;
90
+ probe.destroy({ children: true });
91
+
92
+ // Resolve the ON-SCREEN width (screen units, clamped against the surface), then convert back to
93
+ // LOCAL for the actual content layout — mirrors the DOM's naturalW·s → resolvedW → style.width÷s.
94
+ const screenW = popoverWidth(w, (measured + pad * 2) * s);
95
+ const localW = s > 0 ? screenW / s : screenW;
96
+ const content = this.opts.build(localW - pad * 2);
97
+ if (content instanceof FlexBox) content.setLayoutSize(localW - pad * 2, undefined);
98
+ const contentH = content.getSize().height; // local units
99
+
100
+ // The plate falls back to the pointer when it can't be resolved (no distinct plaque), and to the
101
+ // centred/arrow-less layout when neither resolves — placePopover itself already defaults the
102
+ // ARROW to `pointer ?? plate`, so passing the pointer through unconditionally is enough there.
103
+ // A DEGENERATE (fully zero-sized) plate counts as unresolved too, matching the DOM renderer's
104
+ // rectOf(): `??` only falls through on null/undefined, so a real-but-{w:0,h:0} rect (e.g. read
105
+ // before the bar's first layout pass) would otherwise silently win over a perfectly good pointer.
106
+ const pointerRect = this.opts.pointer?.() ?? null;
107
+ const rawPlate = this.opts.plate();
108
+ const plateRect = rawPlate && !(rawPlate.w <= 0 && rawPlate.h <= 0) ? rawPlate : pointerRect;
109
+ const p = placePopover(plateRect, { w, h }, { w: screenW, h: (contentH + pad * 2) * s }, pointerRect);
110
+ const maxHLocal = s > 0 ? p.maxH / s : p.maxH;
111
+ const cardH = Math.min(contentH + pad * 2, maxHLocal); // local units
112
+
113
+ this.scroll.content.removeChildren().forEach((c) => c.destroy({ children: true }));
114
+ this.scroll.position.set(pad, pad);
115
+ this.scroll.setViewport(localW - pad * 2, cardH - pad * 2);
116
+ this.scroll.content.addChild(content);
117
+ this.scroll.refresh();
118
+
119
+ this.bg.clear();
120
+ this.bg.roundRect(0, 0, localW, cardH, 18);
121
+ this.bg.fill(this.host.tokens.plaqueDark);
122
+
123
+ // Arrow: a 14×7 triangle (local units — it lives inside the scaled card) on the edge that faces
124
+ // the plate.
125
+ this.arrow.clear();
126
+ this.arrow.visible = p.arrowX >= 0;
127
+ if (this.arrow.visible) {
128
+ const arrowXLocal = s > 0 ? p.arrowX / s : p.arrowX;
129
+ const edge = p.below ? 0 : cardH; // the card edge the arrow sits on
130
+ const tip = p.below ? -7 : cardH + 7; // the tip, pointing at the pointer (or the plate)
131
+ this.arrow.moveTo(arrowXLocal - 7, edge);
132
+ this.arrow.lineTo(arrowXLocal + 7, edge);
133
+ this.arrow.lineTo(arrowXLocal, tip);
134
+ this.arrow.fill(this.host.tokens.plaqueDark);
135
+ }
136
+
137
+ // The card carries the scale as ONE transform around its own (0,0) local origin — equivalent to
138
+ // the DOM's transform-origin:top left — so `p.x`/`p.y` (screen units) remain its visual top-left
139
+ // regardless of `s`, and every local size above (background rect, scroll viewport, arrow) scales
140
+ // with it automatically.
141
+ this.card.scale.set(s);
142
+ this.card.position.set(p.x, p.y);
143
+ this._cardX = p.x;
144
+ this._cardY = p.y;
145
+ this._cardW = screenW;
146
+ this._arrowX = p.arrowX;
147
+ }
148
+
149
+ /** Arrow keys scroll a long list; everything else (Escape included) goes to the controller. */
150
+ onKey(e: KeyboardEvent): boolean {
151
+ if (e.code === 'ArrowDown') { this.scroll.scrollBy(40); return true; }
152
+ if (e.code === 'ArrowUp') { this.scroll.scrollBy(-40); return true; }
153
+ return false;
154
+ }
155
+
156
+ fit(): void {
157
+ this.resize(this.host.screenW, this.host.screenH);
158
+ }
159
+
160
+ onRemove(): void {
161
+ this.scroll.destroy({ children: true });
162
+ }
163
+ }
@@ -21,7 +21,8 @@ export class ScrollBox extends Container {
21
21
  this.canvas = canvas;
22
22
  this.addChild(this.content);
23
23
  // maskG is added to the scene only while scrolling (see refresh) — a leftover unused mask
24
- // graphic renders as a white rect, and a masked container blocks pointer events to its children.
24
+ // graphic renders as a white rect. (Masking does NOT gate pointer events to the content, despite
25
+ // what an earlier version of this comment claimed — see the correction in refresh() below.)
25
26
  this.eventMode = 'static';
26
27
  this.on('pointerdown', this.onDown);
27
28
  this.on('globalpointermove', this.onMove);
@@ -55,10 +56,13 @@ export class ScrollBox extends Container {
55
56
  const b = this.content.getLocalBounds();
56
57
  const contentH = b.height + b.y; // content laid out from y≈0 downward
57
58
  this.maxScroll = Math.max(0, contentH - this.viewH);
58
- // Only clip + grab pointer/drag when the content actually overflows: a masked container blocks
59
- // pointer events to its children in Pixi v8, so when it fits we leave it unmasked and passive →
60
- // interactive controls (settings sliders/buttons) work. Tall scrolling content (game info) that
61
- // does get masked has no interactive children, so nothing is lost there.
59
+ // Only clip + grab pointer/drag when the content actually overflows. (An earlier version of this
60
+ // comment claimed a masked container blocks pointer events to its children in Pixi v8 that's
61
+ // false: EventBoundary's hitPruneFn only prunes a point OUTSIDE the mask/hitArea, so interactive
62
+ // children stay reachable anywhere inside the visible viewport; see tests/pixi/bet-picker-fit.test.ts
63
+ // and tests/pixi/menu.test.ts, which hit-test an overflowing, masked list on purpose to prove it.
64
+ // We still only mask + grab the pointer when scrollable, since an unmasked, passive box is
65
+ // simpler and cheaper when there's nothing to clip or drag.)
62
66
  const scrollable = this.maxScroll > 0;
63
67
  if (scrollable) {
64
68
  this.addChild(this.maskG);
@@ -1,68 +0,0 @@
1
- import type { ShellHost } from '@/core/renderer';
2
- import type { VolumeKey } from '@/core/types';
3
- import { createOverlay } from '../primitives';
4
- import { icon } from '../icons';
5
-
6
- export function openSettingsModal(host: ShellHost): HTMLElement {
7
- const { root, body } = createOverlay({ title: host.t('Settings'), onClose: () => host.actions.closeOverlay() });
8
- root.dataset.ge = 'settings-modal';
9
-
10
- // Sound on/off — backed by the shell's shared `soundOn` state so this toggle and the Shift+M
11
- // hotkey stay in sync; `setSound` emits `settingChange({ key: 'sound' })` and refreshes the icon.
12
- const sound = (() => {
13
- const btn = document.createElement('button');
14
- btn.className = 'ge-snd'; btn.dataset.ge = 'setting-sound';
15
- btn.setAttribute('aria-label', host.t('Sound'));
16
- const paint = (on: boolean) => {
17
- btn.innerHTML = icon(on ? 'soundOn' : 'soundOff');
18
- btn.classList.toggle('ge-active', on);
19
- btn.setAttribute('aria-pressed', String(on));
20
- };
21
- paint(host.soundOn);
22
- btn.addEventListener('click', () => host.setSound(!host.soundOn));
23
- // Live-update the icon when sound changes from here OR via Shift+M (shell clears on close).
24
- host.setSoundRefresh(paint);
25
- const row = document.createElement('div'); row.className = 'ge-ov-row';
26
- row.innerHTML = `<span class="ge-grow">${host.t('Sound')}</span>`; row.appendChild(btn);
27
- return row;
28
- })();
29
- body.appendChild(sound);
30
-
31
- // Volume sliders — full-width column rows with a live value readout. Positions are read from the
32
- // shell's stored volumes (not hardcoded to 100%), so reopening the overlay reflects the last set
33
- // value, and `host.setVolume()` from game code updates them live via the registered refreshers.
34
- const updaters: Partial<Record<VolumeKey, (v: number) => void>> = {};
35
- const slider = (key: VolumeKey, label: string) => {
36
- const row = document.createElement('div'); row.className = 'ge-ov-row ge-col';
37
- const head = document.createElement('div'); head.className = 'ge-row-head';
38
- const val = document.createElement('span'); val.className = 'ge-val';
39
- head.innerHTML = `<span>${label}</span>`; head.appendChild(val);
40
- const input = document.createElement('input');
41
- input.type = 'range'; input.min = '0'; input.max = '1'; input.step = '0.05';
42
- input.className = 'ge-slider'; input.dataset.ge = `setting-${key}`;
43
- const paint = (v: number) => { input.value = String(v); val.textContent = `${Math.round(v * 100)}%`; };
44
- paint(host.getVolume(key));
45
- input.addEventListener('input', () => {
46
- val.textContent = `${Math.round(Number(input.value) * 100)}%`;
47
- host.setVolume(key, Number(input.value));
48
- });
49
- updaters[key] = paint;
50
- row.append(head, input);
51
- return row;
52
- };
53
- body.appendChild(slider('master', host.t('Master volume')));
54
- body.appendChild(slider('music', host.t('Music')));
55
- body.appendChild(slider('sfx', host.t('SFX')));
56
- // Live-update sliders when volume changes via host.setVolume (shell clears on close).
57
- host.setVolumeRefresh((key, v) => updaters[key]?.(v));
58
-
59
- // Game info — full-width row button that opens its own overlay
60
- const gameInfo = document.createElement('button');
61
- gameInfo.className = 'ge-ov-row'; gameInfo.dataset.ge = 'game-info-btn';
62
- gameInfo.style.marginTop = '6px';
63
- gameInfo.innerHTML = `<span style="width:22px;font-size:22px">${icon('info')}</span><span class="ge-grow">${host.t('Game info')}</span><span style="width:20px;font-size:20px;color:var(--shell-muted)">${icon('chevronRight')}</span>`;
64
- gameInfo.addEventListener('click', () => { root.remove(); host.actions.openInfo(); });
65
- body.appendChild(gameInfo);
66
-
67
- return root;
68
- }
@@ -1,132 +0,0 @@
1
- import { Container, Text } from 'pixi.js';
2
- import type { VolumeKey } from '@/core/types';
3
- import type { PixiComponentContext, ShellLayer } from '../context';
4
- import { Overlay } from '../primitives/overlay';
5
- import { makeText } from '../text';
6
- import { makeIcon } from '../pixi-icon';
7
- import { FlexBox } from '../primitives/flex';
8
- import { Slider, Spacer } from '../primitives/controls';
9
- import { IconButton, attachHover } from '../primitives/widgets';
10
-
11
- /** Settings overlay — sound on/off, volume sliders, and a Game info link. */
12
- export function openSettings(host: PixiComponentContext): ShellLayer {
13
- return new Overlay(host, {
14
- tag: 'settings',
15
- title: host.t('Settings'),
16
- onClose: () => host.closeLayer(),
17
- build: (w) => buildBody(host, w),
18
- });
19
- }
20
-
21
- function buildBody(host: PixiComponentContext, width: number): Container {
22
- const col = new FlexBox({ direction: 'column', align: 'stretch', gap: 10 });
23
-
24
- // Sound on/off — backed by the shell's shared `soundOn` state so this toggle and the Shift+M
25
- // hotkey stay in sync; `setSound` emits `settingChange({ key: 'sound' })` and refreshes the icon.
26
- const soundOn0 = host.soundOn ?? true;
27
- const speaker = new IconButton(soundOn0 ? 'soundOn' : 'soundOff', {
28
- size: 36,
29
- glyph: 24,
30
- color: host.tokens.plaqueLabel,
31
- hover: host.tokens.accent,
32
- activeColor: '#ffffff',
33
- active: soundOn0,
34
- onTap: () => host.setSound?.(!(host.soundOn ?? true)),
35
- });
36
- // Live-update the speaker when sound changes from here OR via Shift+M (shell clears on close).
37
- host.setSoundRefresh?.((on) => {
38
- if (speaker.destroyed) return; // overlay torn down but refresher not yet cleared (push-over edge)
39
- speaker.setIcon(on ? 'soundOn' : 'soundOff');
40
- speaker.active = on;
41
- });
42
- col.add(glassRow(host, [textNode(host, host.t('Sound')), new Spacer(), speaker]));
43
-
44
- // Volume sliders — positions read from the shell's stored volumes (stateful across opens); the
45
- // registered refreshers let `host.setVolume()` from game code move the thumbs live.
46
- const updaters: Partial<Record<VolumeKey, (v: number) => void>> = {};
47
- col.add(sliderRow(host, width, 'master', host.t('Master volume'), updaters));
48
- col.add(sliderRow(host, width, 'music', host.t('Music'), updaters));
49
- col.add(sliderRow(host, width, 'sfx', host.t('SFX'), updaters));
50
- host.setVolumeRefresh?.((key, v) => updaters[key]?.(v));
51
-
52
- // Game info link
53
- const infoIcon = makeIcon('info', 22, '#ffffff');
54
- const infoLabel = textNode(host, host.t('Game info'));
55
- const chevron = makeIcon('chevronRight', 20, host.tokens.muted);
56
- const infoRow = glassRow(host, [infoIcon, infoLabel, new Spacer(), chevron], { button: true, onTap: () => host.actions.openInfo() });
57
- col.add(infoRow);
58
-
59
- return col;
60
- }
61
-
62
- function textNode(host: PixiComponentContext, text: string): Container {
63
- return makeText(text, { size: 14, weight: '600', color: '#ffffff' });
64
- }
65
-
66
- /** A full-width glass row holding the given children (label/controls), align-centred. */
67
- function glassRow(host: PixiComponentContext, children: Container[], opts: { button?: boolean; onTap?: () => void } = {}): FlexBox {
68
- const row = new FlexBox({
69
- direction: 'row',
70
- align: 'center',
71
- gap: 12,
72
- // .ge-ov-row { padding: clamp(11px,2.2vh,15px) 16px } — 15 at desktop; minHeight matches the
73
- // DOM row's effective height (driven by the tallest child's line box, not just the em box).
74
- padding: { top: 15, bottom: 15, left: 16, right: 16 },
75
- minHeight: 60,
76
- background: { fill: host.tokens.plaqueGlass, radius: 16 },
77
- });
78
- for (const c of children) row.add(c, c instanceof Spacer ? { grow: 1 } : {});
79
- if (opts.button) {
80
- row.setInteractive(true); // full-box hit area — hover + tap across the whole row, not just the icon/label
81
- if (opts.onTap) row.on('pointertap', opts.onTap);
82
- // hover: brighter glass + accent text (DOM button.ge-ov-row:hover)
83
- const texts = children.filter((c): c is Text => c instanceof Text);
84
- const orig = texts.map((t) => t.style.fill);
85
- attachHover(
86
- row,
87
- () => {
88
- row.setBgFill(host.tokens.plaqueGlassHover);
89
- texts.forEach((t) => (t.style.fill = host.tokens.accent));
90
- },
91
- () => {
92
- row.setBgFill(host.tokens.plaqueGlass);
93
- texts.forEach((t, i) => (t.style.fill = orig[i]));
94
- },
95
- );
96
- }
97
- return row;
98
- }
99
-
100
- /** A column row: head (label + live % value) over a draggable slider. */
101
- function sliderRow(
102
- host: PixiComponentContext,
103
- bodyWidth: number,
104
- key: VolumeKey,
105
- label: string,
106
- updaters: Partial<Record<VolumeKey, (v: number) => void>>,
107
- ): FlexBox {
108
- const row = new FlexBox({
109
- direction: 'column',
110
- align: 'stretch',
111
- gap: 10,
112
- padding: { top: 15, bottom: 15, left: 16, right: 16 },
113
- background: { fill: host.tokens.plaqueGlass, radius: 16 },
114
- });
115
- const head = new FlexBox({ direction: 'row', align: 'center', justify: 'space-between' });
116
- const initial = host.getVolume(key);
117
- const valueText = makeText(`${Math.round(initial * 100)}%`, { size: 13, weight: '700', color: host.tokens.plaqueLabel });
118
- head.add(makeText(label, { size: 14, weight: '600', color: '#ffffff' }));
119
- head.add(new Spacer(), { grow: 1 });
120
- head.add(valueText);
121
- const slider = new Slider(host, initial, (v) => {
122
- valueText.text = `${Math.round(v * 100)}%`;
123
- host.setVolume(key, v);
124
- });
125
- updaters[key] = (v) => {
126
- valueText.text = `${Math.round(v * 100)}%`;
127
- slider.setValue(v);
128
- };
129
- row.add(head);
130
- row.add(slider);
131
- return row;
132
- }