@energy8platform/shell 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/dist/html.cjs.js +3894 -0
  2. package/dist/html.cjs.js.map +1 -0
  3. package/dist/html.d.ts +616 -0
  4. package/dist/html.esm.js +3879 -0
  5. package/dist/html.esm.js.map +1 -0
  6. package/dist/index.cjs.js +1593 -0
  7. package/dist/index.cjs.js.map +1 -0
  8. package/dist/index.d.ts +554 -0
  9. package/dist/index.esm.js +1582 -0
  10. package/dist/index.esm.js.map +1 -0
  11. package/dist/pixi.cjs.js +5740 -0
  12. package/dist/pixi.cjs.js.map +1 -0
  13. package/dist/pixi.d.ts +682 -0
  14. package/dist/pixi.esm.js +5726 -0
  15. package/dist/pixi.esm.js.map +1 -0
  16. package/package.json +79 -0
  17. package/src/core/EventEmitter.ts +55 -0
  18. package/src/core/ShellController.ts +234 -0
  19. package/src/core/colors.ts +32 -0
  20. package/src/core/fonts-digits.ts +12 -0
  21. package/src/core/fonts.ts +13 -0
  22. package/src/core/format.ts +39 -0
  23. package/src/core/i18n.ts +96 -0
  24. package/src/core/index.ts +35 -0
  25. package/src/core/keyboard.ts +229 -0
  26. package/src/core/locales.ts +864 -0
  27. package/src/core/motion.ts +10 -0
  28. package/src/core/renderer.ts +121 -0
  29. package/src/core/state.ts +31 -0
  30. package/src/core/theme.ts +81 -0
  31. package/src/core/types.ts +269 -0
  32. package/src/core/version.ts +3 -0
  33. package/src/ui/html/HtmlRenderer.ts +292 -0
  34. package/src/ui/html/components/BottomBar.ts +240 -0
  35. package/src/ui/html/components/BuyBonus.ts +326 -0
  36. package/src/ui/html/components/GameInfo.ts +384 -0
  37. package/src/ui/html/components/Modal.ts +36 -0
  38. package/src/ui/html/components/ReplayModal.ts +58 -0
  39. package/src/ui/html/components/Settings.ts +59 -0
  40. package/src/ui/html/components/pickers.ts +146 -0
  41. package/src/ui/html/icons-preview.svg +224 -0
  42. package/src/ui/html/icons.ts +31 -0
  43. package/src/ui/html/index.ts +36 -0
  44. package/src/ui/html/motion-dom.ts +29 -0
  45. package/src/ui/html/primitives.ts +85 -0
  46. package/src/ui/html/shell.css.ts +528 -0
  47. package/src/ui/html/theme-css.ts +21 -0
  48. package/src/ui/pixi/PixiRenderer.ts +350 -0
  49. package/src/ui/pixi/components/BottomBar.ts +477 -0
  50. package/src/ui/pixi/components/BuyBonus.ts +763 -0
  51. package/src/ui/pixi/components/GameInfo.ts +559 -0
  52. package/src/ui/pixi/components/Modal.ts +40 -0
  53. package/src/ui/pixi/components/ReplayModal.ts +80 -0
  54. package/src/ui/pixi/components/Settings.ts +117 -0
  55. package/src/ui/pixi/components/pickers.ts +170 -0
  56. package/src/ui/pixi/context.ts +52 -0
  57. package/src/ui/pixi/icons.ts +45 -0
  58. package/src/ui/pixi/index.ts +56 -0
  59. package/src/ui/pixi/motion-pixi.ts +58 -0
  60. package/src/ui/pixi/pixi-icon.ts +119 -0
  61. package/src/ui/pixi/primitives/card.ts +227 -0
  62. package/src/ui/pixi/primitives/controls.ts +243 -0
  63. package/src/ui/pixi/primitives/flex.ts +335 -0
  64. package/src/ui/pixi/primitives/overlay.ts +181 -0
  65. package/src/ui/pixi/primitives/scroll.ts +117 -0
  66. package/src/ui/pixi/primitives/widgets.ts +680 -0
  67. package/src/ui/pixi/text.ts +131 -0
@@ -0,0 +1,117 @@
1
+ import { Container, Text } from 'pixi.js';
2
+ import type { PixiComponentContext, ShellLayer } from '../context';
3
+ import { Overlay } from '../primitives/overlay';
4
+ import { makeText } from '../text';
5
+ import { makeIcon } from '../pixi-icon';
6
+ import { FlexBox } from '../primitives/flex';
7
+ import { Slider, Spacer } from '../primitives/controls';
8
+ import { IconButton, attachHover } from '../primitives/widgets';
9
+
10
+ /** Settings overlay — sound on/off, volume sliders, and a Game info link. */
11
+ export function openSettings(host: PixiComponentContext): ShellLayer {
12
+ return new Overlay(host, {
13
+ tag: 'settings',
14
+ title: host.t('Settings'),
15
+ onClose: () => host.closeLayer(),
16
+ build: (w) => buildBody(host, w),
17
+ });
18
+ }
19
+
20
+ function buildBody(host: PixiComponentContext, width: number): Container {
21
+ const col = new FlexBox({ direction: 'column', align: 'stretch', gap: 10 });
22
+
23
+ // Sound on/off — backed by the shell's shared `soundOn` state so this toggle and the Shift+M
24
+ // hotkey stay in sync; `setSound` emits `settingChange({ key: 'sound' })` and refreshes the icon.
25
+ const soundOn0 = host.soundOn ?? true;
26
+ const speaker = new IconButton(soundOn0 ? 'soundOn' : 'soundOff', {
27
+ size: 36,
28
+ glyph: 24,
29
+ color: host.tokens.plaqueLabel,
30
+ hover: host.tokens.accent,
31
+ activeColor: '#ffffff',
32
+ active: soundOn0,
33
+ onTap: () => host.setSound?.(!(host.soundOn ?? true)),
34
+ });
35
+ // Live-update the speaker when sound changes from here OR via Shift+M (shell clears on close).
36
+ host.setSoundRefresh?.((on) => {
37
+ if (speaker.destroyed) return; // overlay torn down but refresher not yet cleared (push-over edge)
38
+ speaker.setIcon(on ? 'soundOn' : 'soundOff');
39
+ speaker.active = on;
40
+ });
41
+ col.add(glassRow(host, [textNode(host, host.t('Sound')), new Spacer(), speaker]));
42
+
43
+ // Volume sliders
44
+ col.add(sliderRow(host, width, 'master', host.t('Master volume')));
45
+ col.add(sliderRow(host, width, 'music', host.t('Music')));
46
+ col.add(sliderRow(host, width, 'sfx', host.t('SFX')));
47
+
48
+ // Game info link
49
+ const infoIcon = makeIcon('info', 22, '#ffffff');
50
+ const infoLabel = textNode(host, host.t('Game info'));
51
+ const chevron = makeIcon('chevronRight', 20, host.tokens.muted);
52
+ const infoRow = glassRow(host, [infoIcon, infoLabel, new Spacer(), chevron], { button: true, onTap: () => host.actions.openInfo() });
53
+ col.add(infoRow);
54
+
55
+ return col;
56
+ }
57
+
58
+ function textNode(host: PixiComponentContext, text: string): Container {
59
+ return makeText(text, { size: 14, weight: '600', color: '#ffffff' });
60
+ }
61
+
62
+ /** A full-width glass row holding the given children (label/controls), align-centred. */
63
+ function glassRow(host: PixiComponentContext, children: Container[], opts: { button?: boolean; onTap?: () => void } = {}): FlexBox {
64
+ const row = new FlexBox({
65
+ direction: 'row',
66
+ align: 'center',
67
+ gap: 12,
68
+ // .ge-ov-row { padding: clamp(11px,2.2vh,15px) 16px } — 15 at desktop; minHeight matches the
69
+ // DOM row's effective height (driven by the tallest child's line box, not just the em box).
70
+ padding: { top: 15, bottom: 15, left: 16, right: 16 },
71
+ minHeight: 60,
72
+ background: { fill: host.tokens.plaqueGlass, radius: 16 },
73
+ });
74
+ for (const c of children) row.add(c, c instanceof Spacer ? { grow: 1 } : {});
75
+ if (opts.button) {
76
+ row.setInteractive(true); // full-box hit area — hover + tap across the whole row, not just the icon/label
77
+ if (opts.onTap) row.on('pointertap', opts.onTap);
78
+ // hover: brighter glass + accent text (DOM button.ge-ov-row:hover)
79
+ const texts = children.filter((c): c is Text => c instanceof Text);
80
+ const orig = texts.map((t) => t.style.fill);
81
+ attachHover(
82
+ row,
83
+ () => {
84
+ row.setBgFill(host.tokens.plaqueGlassHover);
85
+ texts.forEach((t) => (t.style.fill = host.tokens.accent));
86
+ },
87
+ () => {
88
+ row.setBgFill(host.tokens.plaqueGlass);
89
+ texts.forEach((t, i) => (t.style.fill = orig[i]));
90
+ },
91
+ );
92
+ }
93
+ return row;
94
+ }
95
+
96
+ /** A column row: head (label + live % value) over a draggable slider. */
97
+ function sliderRow(host: PixiComponentContext, bodyWidth: number, key: string, label: string): FlexBox {
98
+ const row = new FlexBox({
99
+ direction: 'column',
100
+ align: 'stretch',
101
+ gap: 10,
102
+ padding: { top: 15, bottom: 15, left: 16, right: 16 },
103
+ background: { fill: host.tokens.plaqueGlass, radius: 16 },
104
+ });
105
+ const head = new FlexBox({ direction: 'row', align: 'center', justify: 'space-between' });
106
+ const valueText = makeText('100%', { size: 13, weight: '700', color: host.tokens.plaqueLabel });
107
+ head.add(makeText(label, { size: 14, weight: '600', color: '#ffffff' }));
108
+ head.add(new Spacer(), { grow: 1 });
109
+ head.add(valueText);
110
+ const slider = new Slider(host, 1, (v) => {
111
+ valueText.text = `${Math.round(v * 100)}%`;
112
+ host.emit('settingChange', { key, value: v });
113
+ });
114
+ row.add(head);
115
+ row.add(slider);
116
+ return row;
117
+ }
@@ -0,0 +1,170 @@
1
+ import type { PixiComponentContext, ShellLayer } from '../context';
2
+ import { CardModal } from '../primitives/card';
3
+ import { Chip } from '../primitives/controls';
4
+ import { FlexBox } from '../primitives/flex';
5
+
6
+ interface Choice {
7
+ id: string;
8
+ label: string;
9
+ }
10
+
11
+ interface SheetOpts {
12
+ tag: string;
13
+ title: string;
14
+ choices: Choice[];
15
+ selected: string;
16
+ /** Chips per row — a fixed number, or `{ wide, mobile }` that reflows with the layout. */
17
+ columns: number | { wide: number; mobile: number };
18
+ /** Max card width in em (the 6-wide bet picker needs 44em vs the 28em default). */
19
+ maxEm?: number;
20
+ confirmLabel: string;
21
+ onConfirm: (id: string) => void;
22
+ }
23
+
24
+ /** Extended CardModal that implements ShellLayer.onKey for keyboard navigation. */
25
+ class PickerModal extends CardModal {
26
+ private _onKey: (e: KeyboardEvent) => boolean;
27
+
28
+ constructor(host: PixiComponentContext, opts: ConstructorParameters<typeof CardModal>[1], onKey: (e: KeyboardEvent) => boolean) {
29
+ super(host, opts);
30
+ this._onKey = onKey;
31
+ }
32
+
33
+ onKey(e: KeyboardEvent): boolean {
34
+ return this._onKey(e);
35
+ }
36
+ }
37
+
38
+ /** A centred picker (chips grid + accent Confirm) on the shared card modal. */
39
+ function buildSheet(host: PixiComponentContext, opts: SheetOpts): ShellLayer {
40
+ const columns = typeof opts.columns === 'number'
41
+ ? opts.columns
42
+ : host.layout === 'mobile' ? opts.columns.mobile : opts.columns.wide;
43
+
44
+ let selected = opts.selected;
45
+ let focusIndex = opts.choices.findIndex((c) => c.id === selected);
46
+ if (focusIndex < 0) focusIndex = 0;
47
+ const chips: Chip[] = [];
48
+
49
+ /** Update chip visuals to reflect the current focused index. */
50
+ function setHighlight(newIndex: number): void {
51
+ focusIndex = newIndex;
52
+ selected = opts.choices[focusIndex].id;
53
+ for (let i = 0; i < chips.length; i++) {
54
+ chips[i].setSelected(i === focusIndex);
55
+ }
56
+ }
57
+
58
+ function doConfirm(): void {
59
+ opts.onConfirm(selected);
60
+ host.closeLayer();
61
+ }
62
+
63
+ function onKey(e: KeyboardEvent): boolean {
64
+ const last = opts.choices.length - 1;
65
+ switch (e.code) {
66
+ case 'ArrowRight':
67
+ case 'ArrowDown':
68
+ case 'Equal':
69
+ case 'NumpadAdd':
70
+ if (focusIndex < last) setHighlight(focusIndex + 1);
71
+ return true;
72
+ case 'ArrowLeft':
73
+ case 'ArrowUp':
74
+ case 'Minus':
75
+ case 'NumpadSubtract':
76
+ if (focusIndex > 0) setHighlight(focusIndex - 1);
77
+ return true;
78
+ case 'Enter':
79
+ case 'Space':
80
+ doConfirm();
81
+ return true;
82
+ case 'Escape':
83
+ host.closeLayer();
84
+ return true;
85
+ default:
86
+ return false;
87
+ }
88
+ }
89
+
90
+ const modal = new PickerModal(host, { tag: opts.tag, title: opts.title, maxEm: opts.maxEm, onClose: () => host.closeLayer() }, onKey);
91
+ const em = modal.emSize;
92
+ const gap = 0.65 * em;
93
+ const innerW = modal.cardWidth - 2.4 * em;
94
+ const colW = (innerW - gap * (columns - 1)) / columns;
95
+
96
+ const grid = new FlexBox({ direction: 'column', align: 'start', gap });
97
+ for (let i = 0; i < opts.choices.length; i += columns) {
98
+ const rowChoices = opts.choices.slice(i, i + columns);
99
+ const row = new FlexBox({ direction: 'row', align: 'center', gap });
100
+ for (let j = 0; j < rowChoices.length; j++) {
101
+ const c = rowChoices[j];
102
+ const chipIndex = i + j;
103
+ const chip = new Chip(host, c.id, c.label, chipIndex === focusIndex, em, (id) => {
104
+ const idx = opts.choices.findIndex((ch) => ch.id === id);
105
+ if (idx >= 0) setHighlight(idx);
106
+ });
107
+ chip.setLayoutSize(colW, undefined);
108
+ chips.push(chip);
109
+ row.add(chip);
110
+ }
111
+ row.layout();
112
+ grid.add(row);
113
+ }
114
+ modal.body.add(grid);
115
+
116
+ modal.setActions([
117
+ {
118
+ label: opts.confirmLabel,
119
+ kind: 'accent',
120
+ onTap: doConfirm,
121
+ },
122
+ ]);
123
+ modal.build();
124
+ return modal;
125
+ }
126
+
127
+ /** Bet picker — all available bets as chips (6 per row, 3 on mobile), accent Confirm applies it. */
128
+ export function openBetPicker(host: PixiComponentContext): ShellLayer {
129
+ return buildSheet(host, {
130
+ tag: 'bet-modal',
131
+ title: host.t('Bet'),
132
+ columns: { wide: 6, mobile: 3 },
133
+ maxEm: 44, // wider card to fit 6 chips/row
134
+ confirmLabel: host.t('Confirm'),
135
+ choices: host.state.availableBets.map((b) => ({ id: String(b), label: host.fmt(b) })),
136
+ selected: String(host.state.bet),
137
+ onConfirm: (id) => {
138
+ const v = Number(id);
139
+ host.actions.setBet(v);
140
+ },
141
+ });
142
+ }
143
+
144
+ const AUTOPLAY_COUNTS = [10, 25, 50, 100, 250, 500, 1000, 2000, Infinity];
145
+
146
+ function autoplayCounts(maxCount?: number): number[] {
147
+ if (maxCount == null) return AUTOPLAY_COUNTS;
148
+ const capped = AUTOPLAY_COUNTS.filter((n) => Number.isFinite(n) && n <= maxCount);
149
+ if (!capped.includes(maxCount)) capped.push(maxCount);
150
+ return capped;
151
+ }
152
+
153
+ /** Autoplay picker — spin counts (incl. ∞ unless a maxCount caps them); Confirm starts autoplay. */
154
+ export function openAutoplayPicker(host: PixiComponentContext): ShellLayer {
155
+ const maxCount = host.config.features.autoplay?.maxCount;
156
+ const counts = autoplayCounts(maxCount);
157
+ return buildSheet(host, {
158
+ tag: 'autoplay-modal',
159
+ title: host.t('Autoplay'),
160
+ columns: 3,
161
+ confirmLabel: host.t('Start'),
162
+ choices: counts.map((n) => ({ id: String(n), label: Number.isFinite(n) ? String(n) : '∞' })),
163
+ selected: String(host.state.autoplay.remaining || counts[0]),
164
+ onConfirm: (id) => {
165
+ let remaining = Number(id);
166
+ if (maxCount != null) remaining = Math.min(remaining, maxCount);
167
+ host.actions.startAutoplay(remaining);
168
+ },
169
+ });
170
+ }
@@ -0,0 +1,52 @@
1
+ import type { Container, Ticker } from 'pixi.js';
2
+ import type { ShellHost } from '@/core/renderer';
3
+
4
+ /** A pushed full-screen layer (overlay or centred modal). Optional hooks let the host re-fit it
5
+ * on resize. */
6
+ export interface ShellLayer extends Container {
7
+ /** Re-flow to a new screen size (overlays fill it; cards re-centre + fit-scale). */
8
+ resize?(w: number, h: number): void;
9
+ /** Re-run the card fit-scale backstop for short popouts. */
10
+ fit?(): void;
11
+ /** Called right before the layer is removed, so it can detach DOM listeners etc. */
12
+ onRemove?(): void;
13
+ /** Called by the shell keyboard controller while this layer is open.
14
+ * Return true to consume the key (prevents bar actions + Escape close); false to pass through
15
+ * (Escape → closeLayer). */
16
+ onKey?(e: KeyboardEvent): boolean;
17
+ }
18
+
19
+ export interface LayerHandle {
20
+ root: ShellLayer;
21
+ close(): void;
22
+ }
23
+
24
+ /** What pixi components read: the core brain (ShellHost) plus the Pixi-specific surface the
25
+ * PixiRenderer provides (ticker, screen size, layer stack).
26
+ *
27
+ * Members already on core ShellHost (state, config, tokens, layout, soundOn, t, emit, setSound,
28
+ * setSoundRefresh, actions, formatCurrency, notifyResize) are NOT re-declared here.
29
+ *
30
+ * fmt/fmtWin decision: pixi components in pixi-shell use `host.fmt(n)` / `host.fmtWin(n)`.
31
+ * Rather than retargeting all components to `host.formatCurrency(n)` / `host.formatCurrency(n, true)`
32
+ * in Task 12, we add `fmt` and `fmtWin` as convenience shorthands here. The PixiRenderer will
33
+ * implement them as thin wrappers over `formatCurrency`. This avoids a wider refactor in Task 12
34
+ * while keeping components portable. */
35
+ export interface PixiComponentContext extends ShellHost {
36
+ readonly ticker: Ticker;
37
+ readonly canvas?: HTMLCanvasElement;
38
+ readonly screenW: number;
39
+ readonly screenH: number;
40
+ render(): void;
41
+ pushLayer(node: ShellLayer): LayerHandle;
42
+ closeLayer(): void;
43
+ fitModals(): void;
44
+ /** Swap the active language at runtime (rebuilds resolver, re-renders bar). Optional. */
45
+ setLanguage?(lang: string): void;
46
+ /** Format a money amount in the shell currency (fixed minDecimals — balance/bet/prices).
47
+ * Convenience shorthand over formatCurrency(n). */
48
+ fmt(n: number): string;
49
+ /** Format a win / total-win amount (variable decimals — keeps small wins' significant digits).
50
+ * Convenience shorthand over formatCurrency(n, true). */
51
+ fmtWin(n: number): string;
52
+ }
@@ -0,0 +1,45 @@
1
+ // AUTO-GENERATED from ./icons-preview.svg by scripts/gen-icons.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"; the few stroked glyphs use stroke="currentColor").
4
+ // Coordinates are baked into the 24×24 space (no <g transform>), so the same fragment renders
5
+ // identically in the DOM <svg> and in Pixi's GraphicsContext.svg.
6
+ // To change an icon: edit icons-preview.svg, then run `node scripts/gen-icons.mjs`.
7
+ const SVGS: Record<string, string> = {
8
+ spin: `<g fill="currentColor" fill-rule="evenodd"><path d="M17.72 4.86c-1.77 -0.89 -5.48 -2.51 -7.37 -3.08l1.4 1.96h0c-5.36 0.16 -8.6 5.47 -9.04 10.35l1.58 2.61l0.19 0.1l1.86 -1.68c-0.21 -0.42 -0.56 -1.87 -0.56 -1.87h0c-0.41 -4.55 3.28 -7.88 7.75 -7.37h0l2.33 0.75l1.86 -1.59v-0.18Z"/><path d="M6.29 19.38c1.79 0.86 5.54 2.39 7.43 2.92l-1.44 -1.93h0c5.36 -0.27 8.48 -5.65 8.81 -10.54l-1.64 -2.57l-0.19 -0.1l-1.82 1.72c0.22 0.42 0.6 1.86 0.6 1.86h0c0.51 4.54 -3.12 7.95 -7.59 7.53h0l-2.35 -0.7l-1.83 1.63v0.18Z"/></g>`,
9
+ turbo1: `<g fill="currentColor" fill-rule="evenodd"><path d="M11.77 2.71l-4.62 10.63h4.39c-2.08 3.86 -4.74 8.67 -4.74 8.67h0.23s11.95 -12.16 11.95 -12.16l-5.79 0.03l5.63 -8.67l-7.05 1.5Z"/></g>`,
10
+ autoplay: `<g fill="currentColor" fill-rule="evenodd"><path d="M9.61 8.97v6.44h0.1c0.82 -0.65 4.19 -2.6 5.15 -3.17v-0.1c-0.99 -0.55 -4.32 -2.68 -5.25 -3.17Z"/><path d="M20.32 8.33l-1.5 1.31v7.33l-2.08 1.69s-12.68 0 -12.68 0v0.1l4.36 3.76l-0.59 -2.18h10.1l2.48 -2.57h0l-0.09 -9.44Z"/><path d="M2.88 15.89l1.51 -1.3l0.05 -7.33l2.09 -1.67s12.68 0.08 12.68 0.08v-0.1s-4.34 -3.79 -4.34 -3.79l0.58 2.18h0l-10.1 -0.07l-2.5 2.55h0l0.03 9.44Z"/></g>`,
11
+ stop: `<g fill="currentColor" fill-rule="evenodd"><path d="M20.96 1.6h-17.58l-1.78 1.85v17.51l1.3 -1.84v-14.37l1.64 -1.64h14.58l1.84 -1.51Z"/><path d="M3.09 22.37l17.58 -0.04l1.78 -1.85l-0.04 -17.51l-1.3 1.84l0.03 14.37l-1.64 1.64l-14.58 0.03h0l-1.84 1.52Z"/></g>`,
12
+ menu: `<g fill="currentColor" fill-rule="evenodd"><path d="M1.6 13.52h18.01l2.79 -3.17h-18.01l-2.79 3.17h0Z"/><path d="M1.6 6.04h18.01l2.79 -3.17h-18.01l-2.79 3.17h0Z"/><path d="M1.64 21.02h18.01l2.79 -3.17h-18.01l-2.79 3.17h0Z"/></g>`,
13
+ minus: `<g fill="currentColor" fill-rule="evenodd"><path d="M19.81 13.26l2.59 -2.44l-18.13 -0.08l-2.67 2.52"/></g>`,
14
+ plus: `<g fill="currentColor" fill-rule="evenodd"><path d="M13.16 1.6h0l-2.39 3.82v5.31l-0.08 0.07h-6.36l-2.54 2.4h8.9l0.08 0.07v9.13l2.39 -3.74h0v-5.39l0.07 -0.07h6.36l2.62 -2.4h-8.98l-0.07 -0.07v-9.13Z"/></g>`,
15
+ gift: `<rect x="4" y="9" width="16" height="11" rx="2" fill="currentColor"/><path d="M9 9c-1.35 -0.31 -2.18 -1.65 -1.87 -3s1.65 -2.18 3 -1.87c0.93 0.22 1.65 0.94 1.87 1.87c0.31 -1.35 1.65 -2.18 3 -1.87c1.35 0.31 2.18 1.65 1.87 3c-0.22 0.93 -0.94 1.65 -1.87 1.87h-6Z" fill="currentColor"/><rect x="11" y="9" width="2" height="11" fill="rgba(0,0,0,.35)"/>`,
16
+ info: `<g fill="currentColor" fill-rule="evenodd"><path d="M14.22 7.78l-3.09 0.08s-1.82 1.49 -1.96 1.43h0.98l-2.03 13.03h0l5.5 -4.37h-1.66"/><path d="M15.96 1.6h-3.02c-0.86 1.04 -2.06 2.98 -2.86 4.07l2.71 -0.08c0.84 -0.95 2.43 -2.96 3.17 -3.99Z"/></g>`,
17
+ soundOn: `<g fill="currentColor" fill-rule="evenodd"><path d="M18.88 10.12l-2.27 -3.44l0.55 1.72l0.24 1.57v3.91l-0.24 1.56l-0.55 1.72l2.27 -3.44"/><path d="M20.91 13.33c0.08 1.91 -0.87 4.39 -1.64 6.1c1.12 -1.38 2.35 -3.64 3.13 -5.24c0 0 0 -4.61 0 -4.61c-0.75 -1.65 -2 -3.83 -3.13 -5.24c0.85 1.78 1.69 4.08 1.64 6.1"/><path d="M13.17 1.76c-1.7 1.45 -5.26 4.82 -6.88 6.41l-4.69 0.08v7.35c0.14 0.14 4.37 0.65 4.69 0.7c1.67 1.52 5.13 4.54 6.88 5.94v-20.48Z"/><path d="M11.69 5.59l0.23 0.08v13.37l-0.23 0.08l-4.62 -4.3l-2.5 -0.32l-0.15 -0.15v-3.84l0.15 -0.15l2.5 -0.08l4.62 -4.69Z"/></g>`,
18
+ soundOff: `<g fill="currentColor" fill-rule="evenodd"><path d="M13.68 13.18l-1.15 1.22v4.04l-0.23 0.07l-1.9 -1.75l-1.07 1.07l3.51 3.05l0.91 0.68v-8.38h-0.07Z"/><path d="M17.71 8.69l-0.22 0.22l0.07 0.99s0.22 4.19 -0.53 5.49l1.75 -2.51v-2.59l-1.07 -1.6Z"/><path d="M19.47 6.63l1.06 3.5v2.9l-1.22 3.88l2.21 -3.73v-3.28l-2.05 -3.27Z"/><path d="M20.91 2.21c-1.64 1.63 -5.41 5.42 -6.93 7.09l-0.23 -0.08v-7.62c-1.67 1.43 -5.11 4.69 -6.7 6.25l-4.65 0.07l0.08 0.08v7.16l4.03 0.54l1.22 -1.38l-2.43 -0.3l-0.08 -0.08v-3.81l2.59 -0.23l4.49 -4.57l0.23 0.08v5.49c-3.03 3.13 -7.21 8.09 -9.98 11.5c6.55 -5.77 13.23 -13.13 18.36 -20.19Z"/></g>`,
19
+ close: `<g fill="currentColor" fill-rule="evenodd"><path d="M16.89 5.27l-4.77 5.02l-4.77 -4.9l-5.14 -3.3l3.67 5.14l4.53 4.77l-5.05 5.19l-3.52 5.21l5.39 -3.67l4.77 -5.02h0.12l4.9 5.02l5.14 3.43l-3.5 -4.99l-4.95 -5.05v-0.24l4.82 -5.08l3.63 -5.19"/></g>`,
20
+ back: `<path d="M15 6l-6 6l6 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>`,
21
+ chevronRight: `<path d="M9 6l6 6l-6 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>`,
22
+ ticket: `<path d="M1.72 9.48L19.64 4.67L20.28 7.07C19.19 7.8 18.82 9 19.11 10.09C19.41 11.19 20.33 12.04 21.64 12.13L22.28 14.52L4.36 19.32L3.72 16.93C4.81 16.2 5.18 15 4.89 13.91C4.59 12.81 3.67 11.96 2.36 11.87L1.72 9.48Z" fill="none" stroke="currentColor" stroke-width="1.84" stroke-linejoin="miter"/><path d="M12.57 7.17L9.75 13.26L11.92 12.4L11.84 16.74L14.85 10.06L12.38 11.1Z" fill="currentColor"/>`,
23
+ };
24
+
25
+ export type IconName = keyof typeof SVGS;
26
+ export const ICON_NAMES = Object.keys(SVGS) as IconName[];
27
+
28
+ /** Inline SVG string for an icon, sized to 1em (scale via font-size/width). */
29
+ export function icon(name: IconName): string {
30
+ return `<svg viewBox="0 0 24 24" width="1em" height="1em" aria-hidden="true">${SVGS[name]}</svg>`;
31
+ }
32
+
33
+ /** A standalone, parseable SVG document with `currentColor` resolved to `color`.
34
+ * Used by the Pixi renderer to build vector geometry via GraphicsContext.svg(). */
35
+ export function iconSVG(name: IconName, color = '#ffffff'): string {
36
+ return `<svg viewBox="0 0 24 24">${SVGS[name].split('currentColor').join(color)}</svg>`;
37
+ }
38
+
39
+ /** A standalone SVG with the glyph rendered as a stroke-only outline (fill:none).
40
+ * Used to build the outer-ring effect in Pixi: draw this first (under), then iconSVG on top;
41
+ * only the outer half of the stroke is visible — the paint-order:stroke CSS equivalent. */
42
+ export function iconStrokeSVG(name: IconName, stroke: string, width = 4): string {
43
+ const frag = SVGS[name].split('fill="currentColor"').join(`fill="none" stroke="${stroke}" stroke-width="${width}" stroke-linejoin="round"`);
44
+ return `<svg viewBox="0 0 24 24">${frag}</svg>`;
45
+ }
@@ -0,0 +1,56 @@
1
+ import type { Application, Container } from 'pixi.js';
2
+ import { createShell } from '@/core';
3
+ import type { Shell, ShellSurface } from '@/core';
4
+ import type { ShellConfig, BonusOption as CoreBonusOption, BonusCardContext, GameInfoSection as CoreGameInfoSection } from '@/core/types';
5
+ import { PixiRenderer } from './PixiRenderer';
6
+
7
+ /** Config for the Pixi shell: the renderer-agnostic ShellConfig plus the Pixi mount target.
8
+ * `app` is the PixiJS Application; `parent` (defaults to `app.stage`) is where the shell root
9
+ * attaches. */
10
+ export interface PixiShellConfig extends ShellConfig {
11
+ app: Application;
12
+ parent?: Container;
13
+ }
14
+
15
+ /** A bonus-buy option whose `custom` card renderer returns a Pixi `Container` (vs core's `unknown`
16
+ * / ui/html's `HTMLElement`). */
17
+ export interface BonusOption extends CoreBonusOption {
18
+ custom?: (ctx: BonusCardContext) => Container;
19
+ }
20
+
21
+ /** A `custom` game-info section renders a game-supplied Pixi `Container` (`node`). The other section
22
+ * kinds are unchanged from core. */
23
+ export type GameInfoSection =
24
+ | Exclude<CoreGameInfoSection, { type: 'custom' }>
25
+ | { type: 'custom'; title?: string; order?: number; node?: Container; html?: string };
26
+
27
+ /** The surface game scenes use to position themselves. Identical to the renderer-agnostic
28
+ * `ShellSurface` (kept as a named alias for back-compat). */
29
+ export type PixiShellSurface = ShellSurface;
30
+
31
+ /** The Pixi shell handle: the renderer-agnostic controller + the surface game scenes use. */
32
+ export type PixiGameShell = Shell;
33
+
34
+ let active: PixiGameShell | null = null;
35
+
36
+ /** Create the Pixi game shell. Like `createGameShell`, only one is active at a time. Returns the
37
+ * existing instance if one is already active. The `ShellSurface` facade (safeArea/barHeight/
38
+ * setVisible) is wired by `createShell`, delegating to the PixiRenderer. */
39
+ export function createPixiShell(config: PixiShellConfig): PixiGameShell {
40
+ if (active) return active;
41
+ const renderer = new PixiRenderer({ app: config.app, parent: config.parent });
42
+ active = createShell({ ...config, renderer });
43
+ return active;
44
+ }
45
+
46
+ /** Tear down the active Pixi shell (fade out, detach listeners, remove its display objects).
47
+ * Resolves when removed — mirrors `removePixiShell` in the legacy package. */
48
+ export function removePixiShell(): Promise<void> {
49
+ if (!active) return Promise.resolve();
50
+ const shell = active;
51
+ active = null;
52
+ return shell.destroy();
53
+ }
54
+
55
+ export { PixiRenderer };
56
+ export * from '@/core';
@@ -0,0 +1,58 @@
1
+ import type { Text, Ticker } from 'pixi.js';
2
+ import { prefersReducedMotion, easeOutCubic } from '@/core/motion';
3
+ import { setText } from './text';
4
+
5
+ export interface TweenOpts {
6
+ duration: number;
7
+ ease?: (p: number) => number;
8
+ onUpdate: (v: number) => void;
9
+ onComplete?: () => void;
10
+ }
11
+
12
+ /** Tween 0→1 on the Pixi ticker. Returns a canceler. Skips to the end when motion is reduced. */
13
+ export function tween(ticker: Ticker, opts: TweenOpts): () => void {
14
+ const ease = opts.ease ?? easeOutCubic;
15
+ if (prefersReducedMotion() || opts.duration <= 0) {
16
+ opts.onUpdate(1);
17
+ opts.onComplete?.();
18
+ return () => {};
19
+ }
20
+ let elapsed = 0;
21
+ let done = false;
22
+ const tick = (t: Ticker): void => {
23
+ if (done) return;
24
+ elapsed += t.deltaMS;
25
+ const p = Math.min(1, elapsed / opts.duration);
26
+ opts.onUpdate(ease(p));
27
+ if (p >= 1) {
28
+ done = true;
29
+ ticker.remove(tick);
30
+ opts.onComplete?.();
31
+ }
32
+ };
33
+ ticker.add(tick);
34
+ return () => {
35
+ if (done) return;
36
+ done = true;
37
+ ticker.remove(tick);
38
+ };
39
+ }
40
+
41
+ /** Count a Text's numeric value from→to via `fmt`. Returns a canceler — call it before the Text
42
+ * is destroyed so the loop can't keep writing to a dead node. Mirrors the DOM shell's countUp
43
+ * (easeOutCubic, 450ms default, jumps to final when motion is reduced). */
44
+ export function countUpText(
45
+ ticker: Ticker,
46
+ text: Text,
47
+ from: number,
48
+ to: number,
49
+ fmt: (n: number) => string,
50
+ durationMs = 450,
51
+ ): () => void {
52
+ return tween(ticker, {
53
+ duration: durationMs,
54
+ ease: easeOutCubic,
55
+ onUpdate: (p) => setText(text, fmt(from + (to - from) * p)),
56
+ onComplete: () => setText(text, fmt(to)),
57
+ });
58
+ }
@@ -0,0 +1,119 @@
1
+ import { Container, Graphics, GraphicsContext } from 'pixi.js';
2
+ import { iconSVG, iconStrokeSVG, type IconName } from './icons';
3
+ import type { Sizable } from './primitives/flex';
4
+
5
+ // The DOM shell renders each icon as an inline <svg> sized to 1em and recoloured via
6
+ // `currentColor`. The Pixi shell rebuilds the same 24×24 vector paths through Pixi's SVG
7
+ // parser (GraphicsContext.svg) and scales them to the requested pixel size — same shapes,
8
+ // crisp at any scale. Recolour rebuilds the geometry with the colour substituted for
9
+ // `currentColor` (recolour happens only on hover/active, so this is cheap).
10
+
11
+ const VIEWBOX = 24;
12
+
13
+ // Cache parsed contexts by `name|fill|ring|width` — many icons repeat across the bar at the same colour.
14
+ const cache = new Map<string, GraphicsContext>();
15
+
16
+ function context(name: IconName, color: string, ring?: string, ringWidth = 4): GraphicsContext {
17
+ const key = `${name}|${color}|${ring ?? ''}|${ring ? ringWidth : 0}`;
18
+ let ctx = cache.get(key);
19
+ if (!ctx) {
20
+ if (ring) {
21
+ // Two-pass layering: stroke context first (under), then fill on top.
22
+ // Only the outer half of the stroke shows — the paint-order:stroke CSS equivalent.
23
+ ctx = new GraphicsContext()
24
+ .svg(iconStrokeSVG(name, ring, ringWidth))
25
+ .svg(iconSVG(name, color));
26
+ } else {
27
+ ctx = new GraphicsContext().svg(iconSVG(name, color));
28
+ }
29
+ cache.set(key, ctx);
30
+ }
31
+ return ctx;
32
+ }
33
+
34
+ /** A recolourable, scalable icon. Its content is centred on the local origin's box so the view's
35
+ * bounds are `size × size` and `rotation` spins around the glyph centre (like CSS 50% 50%).
36
+ *
37
+ * Optional `ring` and `ringWidth` enable the outer-ring treatment (fill-on-top-of-stroke): the
38
+ * stroke is drawn first (underneath), then the fill path is drawn on top — only the outer half
39
+ * of the stroke is visible, mirroring the DOM `paint-order:stroke` technique. */
40
+ export class IconView extends Container implements Sizable {
41
+ private gfx: Graphics;
42
+ private _size: number;
43
+ private _color: string;
44
+ private _ring: string | undefined;
45
+ private _ringWidth: number;
46
+ readonly iconName: IconName;
47
+
48
+ constructor(name: IconName, size: number, color = '#ffffff', ring?: string, ringWidth = 4) {
49
+ super();
50
+ this.iconName = name;
51
+ this._size = size;
52
+ this._color = color;
53
+ this._ring = ring;
54
+ this._ringWidth = ringWidth;
55
+ this.gfx = new Graphics(context(name, color, ring, ringWidth));
56
+ this.addChild(this.gfx);
57
+ this.layout();
58
+ }
59
+
60
+ private layout(): void {
61
+ const s = this._size / VIEWBOX;
62
+ this.gfx.scale.set(s);
63
+ this.gfx.pivot.set(VIEWBOX / 2, VIEWBOX / 2);
64
+ this.gfx.position.set(this._size / 2, this._size / 2);
65
+ }
66
+
67
+ get size(): number {
68
+ return this._size;
69
+ }
70
+
71
+ setColor(color: string): void {
72
+ if (color === this._color && !this._ring) return;
73
+ this._color = color;
74
+ this._ring = undefined;
75
+ this.gfx.context = context(this.iconName, color);
76
+ }
77
+
78
+ /** Update fill colour and ring colour simultaneously (ring is preserved if provided). */
79
+ setColors(fill: string, ring?: string): void {
80
+ if (fill === this._color && ring === this._ring) return;
81
+ this._color = fill;
82
+ this._ring = ring;
83
+ this.gfx.context = context(this.iconName, fill, ring, this._ringWidth);
84
+ }
85
+
86
+ setSize(size: number): void {
87
+ if (size === this._size) return;
88
+ this._size = size;
89
+ this.layout();
90
+ }
91
+
92
+ // ── Sizable: a raw icon occupies a size×size em box (the DOM's 1em <span>), positioned by that
93
+ // box so flex rows centre the EM box like CSS line-box centring — not the ink, which for
94
+ // off-centre glyphs (chevron, info) sits asymmetrically and drifts vertically.
95
+ measureSize(): { w: number; h: number } {
96
+ return { w: this._size, h: this._size };
97
+ }
98
+ setLayoutSize(): void {
99
+ /* fixed-size glyph — no stretch */
100
+ }
101
+
102
+ /** Rotation around the glyph centre (radians) — used by the spinning SPIN disc. */
103
+ set spin(r: number) {
104
+ this.gfx.rotation = r;
105
+ }
106
+ get spin(): number {
107
+ return this.gfx.rotation;
108
+ }
109
+ }
110
+
111
+ export function makeIcon(name: IconName, size: number, color = '#ffffff'): IconView {
112
+ return new IconView(name, size, color);
113
+ }
114
+
115
+ /** Create a ringed icon — glyph filled with `fill`, outer ring coloured `ring` (width `ringWidth`).
116
+ * Uses the two-pass stroke-under-fill technique to show only the outer half of the stroke. */
117
+ export function makeRingedIcon(name: IconName, size: number, fill: string, ring: string, ringWidth = 4): IconView {
118
+ return new IconView(name, size, fill, ring, ringWidth);
119
+ }