@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,559 @@
1
+ import { Assets, Container, Graphics, Sprite, Texture } from 'pixi.js';
2
+ import type { PixiComponentContext, ShellLayer } from '../context';
3
+ import type {
4
+ CellRef,
5
+ GameInfoSection,
6
+ GameMode,
7
+ PaytableRow,
8
+ PaylineDef,
9
+ ShapeDef,
10
+ WinSection,
11
+ } from '@/core/types';
12
+
13
+ /** Default order key for the auto-injected hotkeys section: just after `controls` (-1). */
14
+ const HOTKEYS_DEFAULT_ORDER = -0.5;
15
+ import { Overlay } from '../primitives/overlay';
16
+ import { makeText, textBaseline } from '../text';
17
+ import { makeIcon } from '../pixi-icon';
18
+ import { FlexBox } from '../primitives/flex';
19
+ import { section, paragraph, Spacer } from '../primitives/controls';
20
+ import { BuyBonusBadge } from '../primitives/widgets';
21
+ import { PACKAGE_VERSION } from '@/core/version';
22
+
23
+ /** Game info overlay — modes, controls, paytable, win illustrations, custom sections. */
24
+ export function openGameInfo(host: PixiComponentContext): ShellLayer {
25
+ return new Overlay(host, {
26
+ tag: 'game-info',
27
+ title: host.t('Game info'),
28
+ onClose: () => host.closeLayer(),
29
+ onBack: () => {
30
+ host.closeLayer();
31
+ host.actions.openSettings();
32
+ },
33
+ build: (w) => buildBody(host, w),
34
+ });
35
+ }
36
+
37
+ function buildBody(host: PixiComponentContext, width: number): Container {
38
+ const col = new FlexBox({ direction: 'column', align: 'stretch', gap: 12 });
39
+ const rawSections = host.config.gameInfo.sections ?? [];
40
+ // Auto-inject a hotkeys section unless the game already provides one or features.hotkeys === false.
41
+ const sectionsWithHotkeys: GameInfoSection[] = [...rawSections];
42
+ if (host.config.features.hotkeys !== false && !rawSections.some((s) => s.type === 'hotkeys')) {
43
+ sectionsWithHotkeys.push({ type: 'hotkeys', order: HOTKEYS_DEFAULT_ORDER });
44
+ }
45
+ const sections = sectionsWithHotkeys;
46
+ const base = (s: GameInfoSection, i: number): number =>
47
+ s.order ?? (s.type === 'modes' ? -2 : s.type === 'controls' ? -1 : i);
48
+ sections
49
+ .map((s, i) => ({ s, i, k: base(s, i) }))
50
+ .sort((a, b) => a.k - b.k || a.i - b.i)
51
+ .forEach(({ s }) => col.add(renderSection(host, s, width)));
52
+ col.add(versionFooter(host, width));
53
+ return col;
54
+ }
55
+
56
+ /** A muted version stamp pinned to the bottom of the game-info overlay:
57
+ * `${config.version ?? '1.0.0'}.${engine version without dots}` (e.g. '1.0.0.010'). */
58
+ function versionFooter(host: PixiComponentContext, width: number): FlexBox {
59
+ const gameVersion = host.config.version ?? '1.0.0';
60
+ const stamp = `${gameVersion}.${PACKAGE_VERSION.split('.').join('')}`;
61
+ const row = new FlexBox({ direction: 'row', justify: 'center', width, padding: { top: 6, bottom: 4 } });
62
+ // white-based muted (the overlay is always dark) so it's visible regardless of the dark/light
63
+ // scheme — the scheme-dependent `muted` is near-invisible on the dark overlay in light mode.
64
+ const t = makeText(stamp, { size: 11, weight: '600', color: host.tokens.plaqueLabel, letterSpacing: 0.88 });
65
+ row.add(t);
66
+ return row;
67
+ }
68
+
69
+ function renderSection(host: PixiComponentContext, s: GameInfoSection, width: number): FlexBox {
70
+ switch (s.type) {
71
+ case 'modes':
72
+ return sectionModes(host, s.modes, s.title ?? host.t('Modes'), width);
73
+ case 'controls':
74
+ return sectionControls(host, s.title ?? host.t('Controls'), width);
75
+ case 'hotkeys':
76
+ return sectionHotkeys(host, s.title ?? host.t('Hotkeys'), width);
77
+ case 'paytable':
78
+ return sectionPaytable(host, s.rows, s.title ?? host.t('Paytable'), width);
79
+ case 'wins':
80
+ return sectionWins(host, s, width);
81
+ case 'custom':
82
+ return sectionCustom(host, s, width);
83
+ }
84
+ }
85
+
86
+ const SECTION_PAD = 18 * 2; // section() horizontal padding (left+right)
87
+
88
+ // ── modes ──────────────────────────────────────────────────────────────────────
89
+ function sectionModes(host: PixiComponentContext, modes: GameMode[], title: string, width: number): FlexBox {
90
+ const sec = section(host, title);
91
+ const inner = width - SECTION_PAD;
92
+ modes.forEach((m, i) => {
93
+ if (i > 0) sec.add(hairline(host, inner));
94
+ sec.add(modeRow(host, m, inner));
95
+ });
96
+ return sec;
97
+ }
98
+
99
+ function modeRow(host: PixiComponentContext, m: GameMode, inner: number): FlexBox {
100
+ const row = new FlexBox({ direction: 'column', align: 'stretch', gap: 6, padding: { top: 12, bottom: 12 } });
101
+ const title = makeText(m.title, { size: 16, weight: '800', color: '#ffffff' });
102
+
103
+ // Label + value baseline-aligned within each cell (CSS .ge-gi-mode-st { align-items: baseline }).
104
+ // trim:false gives uniform line boxes; placing each text at `base - itsAscent` lands both on one
105
+ // baseline so a 14px value sits level with its 10px UPPER label — and so the comma in "5,000×"
106
+ // no longer drops the value relative to "100×".
107
+ const LABEL_SIZE = 10, VALUE_SIZE = 14, LV_GAP = 5;
108
+ const labelAsc = textBaseline(LABEL_SIZE, '600');
109
+ const valueAsc = textBaseline(VALUE_SIZE, '800');
110
+ const base = Math.max(labelAsc, valueAsc);
111
+ const cells: Container[] = [];
112
+ const stat = (label: string, val: string): void => {
113
+ const lt = makeText(host.t(label), { size: LABEL_SIZE, weight: '600', color: host.tokens.plaqueLabel, letterSpacing: 1, upper: true, trim: false });
114
+ const vt = makeText(val, { size: VALUE_SIZE, weight: '800', color: '#ffffff', trim: false });
115
+ lt.position.set(0, base - labelAsc);
116
+ vt.position.set(lt.width + LV_GAP, base - valueAsc);
117
+ const cell = new Container();
118
+ cell.addChild(lt, vt);
119
+ cells.push(cell);
120
+ };
121
+ if (m.price != null) stat('Price', m.price);
122
+ if (typeof m.rtp === 'number') stat('RTP', `${m.rtp}%`);
123
+ if (m.maxWin != null) stat('Max win', m.maxWin);
124
+
125
+ const GAP = 14;
126
+ const cellW = cells.map((c) => c.getLocalBounds().width);
127
+ const statsW = cellW.reduce((a, b) => a + b, 0) + GAP * Math.max(0, cells.length - 1);
128
+
129
+ // DOM `.ge-gi-mode-top { flex-wrap:wrap; justify-content:space-between }`: title and stats share a
130
+ // line when they fit (title left, stats right); otherwise the stats wrap onto their own line(s),
131
+ // which a plain row would instead overlap onto the title at narrow (mobile) widths.
132
+ if (cells.length === 0 || title.width + 16 + statsW <= inner) {
133
+ const top = new FlexBox({ direction: 'row', align: 'center', justify: 'space-between', width: inner });
134
+ top.add(title);
135
+ if (cells.length) {
136
+ const stats = new FlexBox({ direction: 'row', align: 'end', gap: GAP });
137
+ cells.forEach((c) => stats.add(c));
138
+ top.add(stats);
139
+ }
140
+ row.add(top);
141
+ } else {
142
+ row.add(title);
143
+ row.add(wrapFlow(cells, cellW, inner, GAP, 8));
144
+ }
145
+
146
+ if (m.description) row.add(paragraph(host, m.description, inner, { size: 14, color: 'rgba(255,255,255,.78)' }));
147
+ return row;
148
+ }
149
+
150
+ /** Pack cells left-to-right into lines no wider than maxW (a minimal flex-wrap), stacked in a
151
+ * column — used to wrap the mode stats below the title on narrow widths. */
152
+ function wrapFlow(cells: Container[], widths: number[], maxW: number, gapX: number, gapY: number): FlexBox {
153
+ const col = new FlexBox({ direction: 'column', align: 'start', gap: gapY });
154
+ let line = new FlexBox({ direction: 'row', align: 'end', gap: gapX });
155
+ let lineW = 0;
156
+ cells.forEach((cell, i) => {
157
+ const w = widths[i];
158
+ if (lineW > 0 && lineW + gapX + w > maxW) {
159
+ col.add(line);
160
+ line = new FlexBox({ direction: 'row', align: 'end', gap: gapX });
161
+ lineW = 0;
162
+ }
163
+ line.add(cell);
164
+ lineW += (lineW > 0 ? gapX : 0) + w;
165
+ });
166
+ col.add(line);
167
+ return col;
168
+ }
169
+
170
+ // ── controls ─────────────────────────────────────────────────────────────────
171
+ interface CtlRow {
172
+ vis: Container;
173
+ name: string;
174
+ desc: string;
175
+ on: boolean;
176
+ }
177
+
178
+ function sectionControls(host: PixiComponentContext, title: string, width: number): FlexBox {
179
+ const sec = section(host, title);
180
+ const inner = width - SECTION_PAD;
181
+ const { features } = host.config;
182
+ const slotIcon = (name: Parameters<typeof makeIcon>[0]): Container => {
183
+ const box = new Container();
184
+ const g = makeIcon(name, 26, '#ffffff');
185
+ g.position.set((48 - 26) / 2, (48 - 26) / 2);
186
+ box.addChild(spacerBox(48, 48), g);
187
+ return box;
188
+ };
189
+ const buyBadge = (): Container => {
190
+ const box = new Container();
191
+ const badge = new BuyBonusBadge({
192
+ size: 46,
193
+ fontSize: 8,
194
+ border: 2,
195
+ bg: host.tokens.accent,
196
+ fg: '#ffffff',
197
+ label: host.t('BUY BONUS').split(/\s+/).join('\n'),
198
+ tokens: host.tokens,
199
+ ticker: host.ticker,
200
+ onTap: () => {},
201
+ });
202
+ badge.eventMode = 'none';
203
+ box.addChild(spacerBox(48, 48), badge);
204
+ badge.position.set(1, 1);
205
+ return box;
206
+ };
207
+
208
+ const game: CtlRow[] = [
209
+ { vis: slotIcon('spin'), name: 'Spin', desc: 'Start a spin at the current bet.', on: true },
210
+ { vis: slotIcon('plus'), name: 'Raise bet', desc: 'Increase your stake.', on: true },
211
+ { vis: slotIcon('minus'), name: 'Lower bet', desc: 'Decrease your stake.', on: true },
212
+ { vis: slotIcon('autoplay'), name: 'Autoplay', desc: 'Spin automatically a set number of times.', on: features.autoplay != null },
213
+ { vis: slotIcon('turbo1'), name: 'Turbo', desc: 'Speed up spin animations.', on: features.turbo > 0 },
214
+ { vis: buyBadge(), name: 'Buy bonus', desc: 'Pay a fixed cost to enter a bonus feature.', on: features.buyBonus !== false },
215
+ ];
216
+ const menu: CtlRow[] = [
217
+ { vis: slotIcon('menu'), name: 'Menu', desc: 'Open settings and game info.', on: true },
218
+ { vis: slotIcon('soundOn'), name: 'Sound', desc: 'Mute or unmute the game.', on: true },
219
+ { vis: slotIcon('info'), name: 'Game info', desc: 'Open the paytable and rules.', on: true },
220
+ { vis: slotIcon('close'), name: 'Close', desc: 'Dismiss the current overlay.', on: true },
221
+ ];
222
+
223
+ sec.add(ctlBlock(host, 'Game', game, inner, false));
224
+ sec.add(ctlBlock(host, 'Menu & info', menu, inner, true));
225
+ return sec;
226
+ }
227
+
228
+ function ctlBlock(host: PixiComponentContext, label: string, rows: CtlRow[], inner: number, topBorder: boolean): FlexBox {
229
+ const block = new FlexBox({ direction: 'column', align: 'stretch', gap: 0, padding: { top: topBorder ? 16 : 0 } });
230
+ if (topBorder) block.add(hairline(host, inner));
231
+ // Heading off the divider above and the first row below — the DOM gives it margin:8px 0 2px plus
232
+ // the block's 4px padding under the border; with gap:0 the pixi heading sat flush on the hairline.
233
+ const head = new FlexBox({ direction: 'column', align: 'start', padding: { top: topBorder ? 12 : 0, bottom: 4 } });
234
+ head.add(makeText(host.t(label), { size: 11, weight: '700', color: host.tokens.plaqueLabel, letterSpacing: 1.3, upper: true }));
235
+ block.add(head);
236
+ const visible = rows.filter((r) => r.on);
237
+ visible.forEach((r, i) => {
238
+ if (i > 0) block.add(hairline(host, inner));
239
+ const row = new FlexBox({ direction: 'row', align: 'center', gap: 14, padding: { top: 9, bottom: 9 } });
240
+ const tx = new FlexBox({ direction: 'column', align: 'start', gap: 2 });
241
+ tx.add(makeText(host.t(r.name), { size: 15, weight: '700', color: '#ffffff' }));
242
+ tx.add(paragraph(host, host.t(r.desc), inner - 48 - 14, { size: 13, color: 'rgba(255,255,255,.7)' }));
243
+ row.add(r.vis);
244
+ row.add(tx);
245
+ block.add(row);
246
+ });
247
+ return block;
248
+ }
249
+
250
+ // ── hotkeys (keycap chips → localized action name) ────────────────────────────
251
+ interface HkRow {
252
+ chips: string[];
253
+ name: string;
254
+ on: boolean;
255
+ }
256
+
257
+ function sectionHotkeys(host: PixiComponentContext, title: string, width: number): FlexBox {
258
+ const sec = section(host, title);
259
+ const inner = width - SECTION_PAD;
260
+ const { features } = host.config;
261
+
262
+ const rows: HkRow[] = [
263
+ { chips: ['Space'], name: 'Spin', on: true },
264
+ { chips: ['Shift', '↑', 'Shift', '='], name: 'Raise bet', on: true },
265
+ { chips: ['Shift', '↓', 'Shift', '-'], name: 'Lower bet', on: true },
266
+ { chips: ['Shift', 'A'], name: 'Autoplay', on: features.autoplay != null },
267
+ { chips: ['Shift', 'T'], name: 'Turbo', on: features.turbo > 0 },
268
+ { chips: ['Shift', 'B'], name: 'Buy bonus', on: features.buyBonus !== false },
269
+ { chips: ['Shift', 'I'], name: 'Game info', on: true },
270
+ { chips: ['Shift', 'S'], name: 'Menu', on: true },
271
+ { chips: ['Shift', 'M'], name: 'Mute', on: true },
272
+ { chips: ['←', '→'], name: 'Navigate', on: true },
273
+ { chips: ['Enter'], name: 'Confirm', on: true },
274
+ { chips: ['Esc'], name: 'Close', on: true },
275
+ ];
276
+
277
+ const visible = rows.filter((r) => r.on);
278
+ visible.forEach((r, i) => {
279
+ if (i > 0) sec.add(hairline(host, inner));
280
+ sec.add(hkRow(host, r, inner));
281
+ });
282
+ return sec;
283
+ }
284
+
285
+ /** Render a single hotkey row: keycap chip(s) on the left, action name on the right. */
286
+ function hkRow(host: PixiComponentContext, r: HkRow, inner: number): FlexBox {
287
+ const row = new FlexBox({ direction: 'row', align: 'center', gap: 14, padding: { top: 8, bottom: 8 } });
288
+
289
+ // Build chips container
290
+ const chipsCol = new FlexBox({ direction: 'row', align: 'center', gap: 4 });
291
+ const chipKeys = buildChipKeys(r);
292
+ for (const key of chipKeys) {
293
+ if (key === '/') {
294
+ chipsCol.add(makeText('/', { size: 12, weight: '500', color: host.tokens.plaqueLabel }));
295
+ } else {
296
+ chipsCol.add(keycap(host, key));
297
+ }
298
+ }
299
+
300
+ const nameText = makeText(host.t(r.name), { size: 15, weight: '700', color: '#ffffff' });
301
+ row.add(chipsCol);
302
+ row.add(nameText);
303
+ return row;
304
+ }
305
+
306
+ /** Flatten a row's chip list into display tokens — for bet rows, produce: Shift ↑ / Shift = style. */
307
+ function buildChipKeys(r: HkRow): string[] {
308
+ if ((r.name === 'Raise bet' || r.name === 'Lower bet') && r.chips.length === 4) {
309
+ const [k1, k2, k3, k4] = r.chips;
310
+ return [k1, k2, '/', k3, k4];
311
+ }
312
+ return r.chips;
313
+ }
314
+
315
+ /** A small rounded rectangle with the key label — the "keycap chip" appearance. */
316
+ function keycap(host: PixiComponentContext, label: string): Container {
317
+ const c = new Container();
318
+ const txt = makeText(label, { size: 11, weight: '700', color: '#ffffff', letterSpacing: 0.2 });
319
+ const padH = 8, padV = 3;
320
+ const w = txt.width + padH * 2;
321
+ const h = txt.height + padV * 2;
322
+ const bg = new Graphics();
323
+ bg.roundRect(0, 0, w, h, 4).fill(host.tokens.plaqueGlass ?? host.tokens.plaqueDark);
324
+ bg.roundRect(0, 0, w, h, 4).stroke({ color: host.tokens.plaqueLine, width: 1 });
325
+ txt.position.set(padH, padV);
326
+ c.addChild(bg, txt);
327
+ return c;
328
+ }
329
+
330
+ // ── paytable ───────────────────────────────────────────────────────────────────
331
+ function sectionPaytable(host: PixiComponentContext, rows: PaytableRow[], title: string, width: number): FlexBox {
332
+ const sec = section(host, title);
333
+ const inner = width - SECTION_PAD;
334
+ const gap = 10;
335
+ const cols = Math.max(1, Math.floor((inner + gap) / (120 + gap)));
336
+ const cardW = (inner - gap * (cols - 1)) / cols;
337
+ const grid = new FlexBox({ direction: 'column', align: 'start', gap });
338
+ for (let i = 0; i < rows.length; i += cols) {
339
+ const row = new FlexBox({ direction: 'row', align: 'start', gap });
340
+ for (const r of rows.slice(i, i + cols)) row.add(paytableCard(host, r, cardW));
341
+ row.layout();
342
+ grid.add(row);
343
+ }
344
+ sec.add(grid);
345
+ return sec;
346
+ }
347
+
348
+ function paytableCard(host: PixiComponentContext, r: PaytableRow, w: number): FlexBox {
349
+ const card = new FlexBox({
350
+ direction: 'column',
351
+ align: 'center',
352
+ gap: 8,
353
+ padding: { top: 14, bottom: 14, left: 12, right: 12 },
354
+ width: w,
355
+ background: { fill: host.tokens.plaqueDark, radius: 14 },
356
+ });
357
+ const sym = new FlexBox({ direction: 'column', align: 'center', gap: 5 });
358
+ if (r.symbol.image) sym.add(imageBox(r.symbol.image, 56, 56));
359
+ if (r.symbol.text) sym.add(makeText(r.symbol.text, { size: 13, weight: '700', color: '#ffffff', upper: true, letterSpacing: 0.65 }));
360
+ card.add(sym);
361
+ const wins = new FlexBox({ direction: 'column', align: 'stretch', gap: 2, width: w - 24 });
362
+ for (const win of r.wins) {
363
+ const wr = new FlexBox({ direction: 'row', align: 'center', justify: 'space-between' });
364
+ wr.add(makeText(win.count ? String(win.count) : '', { size: 13.5, weight: '400', color: host.tokens.plaqueLabel }));
365
+ wr.add(new Spacer(), { grow: 1 });
366
+ wr.add(makeText(`x${win.multiplier}`, { size: 13.5, weight: '700', color: host.tokens.accent }));
367
+ wins.add(wr);
368
+ }
369
+ card.add(wins);
370
+ return card;
371
+ }
372
+
373
+ // ── wins ─────────────────────────────────────────────────────────────────────
374
+ function winFallbackTitle(kind: WinSection['kind']): string {
375
+ return { classic: 'Paylines', cluster: 'Cluster pays', anywhere: 'Pays anywhere', ways: 'Ways to win', shapes: 'Winning shapes' }[
376
+ kind
377
+ ];
378
+ }
379
+
380
+ function sectionWins(host: PixiComponentContext, s: WinSection, width: number): FlexBox {
381
+ const inner = width - SECTION_PAD;
382
+ const title = s.title ?? host.t(winFallbackTitle(s.kind));
383
+ const sec = section(host);
384
+ // header (title + optional "min N" badge)
385
+ const header = new FlexBox({ direction: 'row', align: 'center', gap: 10 });
386
+ header.add(makeText(title, { size: 11, weight: '700', color: host.tokens.plaqueLabel, letterSpacing: 1.54, upper: true }));
387
+ if ((s.kind === 'cluster' || s.kind === 'anywhere') && 'minCount' in s) header.add(badgePill(host, `min ${s.minCount}`));
388
+ sec.add(header);
389
+
390
+ if (s.kind === 'classic') {
391
+ if (s.description) sec.add(paragraph(host, s.description, inner, { size: 14, color: 'rgba(255,255,255,.78)' }));
392
+ const gap = 12;
393
+ const cols = Math.max(1, Math.floor((inner + gap) / (72 + gap)));
394
+ const itemW = (inner - gap * (cols - 1)) / cols;
395
+ const grid = new FlexBox({ direction: 'column', align: 'start', gap });
396
+ const lines = s.lines;
397
+ for (let i = 0; i < lines.length; i += cols) {
398
+ const row = new FlexBox({ direction: 'row', align: 'start', gap });
399
+ lines.slice(i, i + cols).forEach((line, j) => {
400
+ const def: PaylineDef = Array.isArray(line) ? { pattern: line } : line;
401
+ const on: CellRef[] = def.pattern.map((rowIdx, col) => [col, rowIdx]);
402
+ row.add(lineItem(host, s.grid, on, i + j + 1, Math.min(itemW, 140)));
403
+ });
404
+ row.layout();
405
+ grid.add(row);
406
+ }
407
+ sec.add(grid);
408
+ } else if (s.kind === 'cluster' || s.kind === 'anywhere') {
409
+ const example = s.example ?? (s.kind === 'cluster' ? clusterExample(s.grid, s.minCount) : anywhereExample(s.grid, s.minCount));
410
+ const row = new FlexBox({ direction: 'row', align: 'start', gap: 16 });
411
+ row.add(gridIllustration(host, s.grid, example, 140));
412
+ if (s.description) row.add(paragraph(host, s.description, inner - 156, { size: 14, color: 'rgba(255,255,255,.78)' }));
413
+ sec.add(row);
414
+ } else if (s.kind === 'shapes') {
415
+ if (s.description) sec.add(paragraph(host, s.description, inner, { size: 14, color: 'rgba(255,255,255,.78)' }));
416
+ s.shapes.forEach((sh, i) => {
417
+ if (i > 0) sec.add(hairline(host, inner));
418
+ sec.add(shapeRow(host, s.grid, sh, inner));
419
+ });
420
+ } else {
421
+ if (s.description) sec.add(paragraph(host, s.description, inner, { size: 14, color: 'rgba(255,255,255,.78)' }));
422
+ // two example grids side by side; clamp each to the available width and stack them when both
423
+ // won't fit on one row (mirrors the DOM `.ge-gi-win-two { flex-wrap:wrap }` — prevents the grids
424
+ // running off-screen on mobile-s).
425
+ const GAP = 22;
426
+ const colW = Math.min(140, inner);
427
+ const two = colW * 2 + GAP <= inner
428
+ ? new FlexBox({ direction: 'row', align: 'start', gap: GAP })
429
+ : new FlexBox({ direction: 'column', align: 'start', gap: 12 });
430
+ two.add(waysCol(host, '✓ wins', host.tokens.winOk, s.grid, s.winExample ?? waysWin(s.grid), colW));
431
+ two.add(waysCol(host, '✗ no win', host.tokens.winNo, s.grid, s.loseExample ?? waysLose(s.grid), colW));
432
+ sec.add(two);
433
+ }
434
+ return sec;
435
+ }
436
+
437
+ function lineItem(host: PixiComponentContext, grid: { cols: number; rows: number }, on: CellRef[], n: number, w: number): FlexBox {
438
+ const item = new FlexBox({ direction: 'column', align: 'center', gap: 6 });
439
+ item.add(makeText(String(n), { size: 13, weight: '700', color: '#ffffff' }));
440
+ item.add(gridIllustration(host, grid, on, w));
441
+ return item;
442
+ }
443
+
444
+ function waysCol(host: PixiComponentContext, tag: string, color: string, grid: { cols: number; rows: number }, cells: CellRef[], w = 140): FlexBox {
445
+ const col = new FlexBox({ direction: 'column', align: 'center', gap: 6 });
446
+ col.add(makeText(tag, { size: 12, weight: '700', color }));
447
+ col.add(gridIllustration(host, grid, cells, w));
448
+ return col;
449
+ }
450
+
451
+ function shapeRow(host: PixiComponentContext, grid: { cols: number; rows: number }, sh: ShapeDef, inner: number): FlexBox {
452
+ const row = new FlexBox({ direction: 'row', align: 'center', gap: 16, padding: { top: 12, bottom: 12 } });
453
+ row.add(gridIllustration(host, grid, sh.cells, 96));
454
+ const tx = new FlexBox({ direction: 'column', align: 'start', gap: 4 });
455
+ tx.add(makeText(sh.name, { size: 16, weight: '800', color: '#ffffff' }));
456
+ if (sh.description) tx.add(paragraph(host, sh.description, inner - 112, { size: 14, color: 'rgba(255,255,255,.78)' }));
457
+ row.add(tx);
458
+ return row;
459
+ }
460
+
461
+ /** A cols×rows grid; `on` cells filled in the accent colour, the rest faint (`.ge-gi-pl-*`). */
462
+ function gridIllustration(host: PixiComponentContext, grid: { cols: number; rows: number }, on: CellRef[], width: number): Container {
463
+ const { cols, rows } = grid;
464
+ const cell = width / cols;
465
+ const inset = width / 100;
466
+ const rx = width / 50;
467
+ const onSet = new Set(on.map(([c, r]) => `${c},${r}`));
468
+ const g = new Graphics();
469
+ for (let y = 0; y < rows; y++) {
470
+ for (let x = 0; x < cols; x++) {
471
+ g.roundRect(x * cell + inset, y * cell + inset, cell - 2 * inset, cell - 2 * inset, rx);
472
+ g.fill(onSet.has(`${x},${y}`) ? host.tokens.accent : host.tokens.plaqueLine);
473
+ }
474
+ }
475
+ const c = new Container();
476
+ c.addChild(g);
477
+ return c;
478
+ }
479
+
480
+ // ── custom ─────────────────────────────────────────────────────────────────────
481
+ function sectionCustom(host: PixiComponentContext, s: Extract<GameInfoSection, { type: 'custom' }>, width: number): FlexBox {
482
+ // Translate the heading (e.g. the host-built DISCLAIMER title) — the body stays verbatim. The
483
+ // socialize-exemption for the disclaimer runs in the host before render, so its identity is intact.
484
+ const sec = section(host, s.title != null ? host.t(s.title) : undefined);
485
+ const inner = width - SECTION_PAD;
486
+ if (s.node) {
487
+ sec.add(s.node as Container); // game-supplied Pixi content owns its own layout
488
+ } else if (s.html) {
489
+ const text = s.html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
490
+ if (text) sec.add(paragraph(host, text, inner, { size: 15 }));
491
+ }
492
+ return sec;
493
+ }
494
+
495
+ // ── default illustrations (ported from DOM GameInfo) ─────────────────────────────
496
+ function clusterExample(grid: { cols: number; rows: number }, n: number): CellRef[] {
497
+ const w = Math.min(grid.cols, Math.max(1, Math.ceil(Math.sqrt(n))));
498
+ const cells: CellRef[] = [];
499
+ for (let y = 0; y < grid.rows && cells.length < n; y++) for (let x = 0; x < w && cells.length < n; x++) cells.push([x, y]);
500
+ return cells;
501
+ }
502
+ function anywhereExample(grid: { cols: number; rows: number }, n: number): CellRef[] {
503
+ const count = Math.min(n, grid.cols * grid.rows);
504
+ const cells: CellRef[] = [];
505
+ for (let i = 0; i < count; i++) cells.push([Math.floor((i * grid.cols) / count), (i * 2 + 1) % grid.rows]);
506
+ return cells;
507
+ }
508
+ function waysWin(grid: { cols: number; rows: number }): CellRef[] {
509
+ const cells: CellRef[] = [];
510
+ for (let c = 0; c < grid.cols; c++) cells.push([c, c % grid.rows]);
511
+ return cells;
512
+ }
513
+ function waysLose(grid: { cols: number; rows: number }): CellRef[] {
514
+ const gap = Math.floor(grid.cols / 2);
515
+ return waysWin(grid).filter(([c]) => c !== gap);
516
+ }
517
+
518
+ // ── small helpers ────────────────────────────────────────────────────────────
519
+ function hairline(host: PixiComponentContext, width: number): Graphics {
520
+ const g = new Graphics();
521
+ g.rect(0, 0, width, 1).fill(host.tokens.plaqueLine);
522
+ return g;
523
+ }
524
+
525
+ function badgePill(host: PixiComponentContext, text: string): Container {
526
+ const c = new Container();
527
+ const label = makeText(text, { size: 11, weight: '700', color: host.tokens.accent, letterSpacing: 0.22 });
528
+ const bg = new Graphics();
529
+ const w = label.width + 16;
530
+ const h = label.height + 4;
531
+ bg.roundRect(0, 0, w, h, 999).fill(host.tokens.plaqueDark);
532
+ label.position.set(8, 2);
533
+ c.addChild(bg, label);
534
+ return c;
535
+ }
536
+
537
+ function spacerBox(w: number, h: number): Graphics {
538
+ const g = new Graphics();
539
+ g.rect(0, 0, w, h).fill({ color: 0xffffff, alpha: 0 });
540
+ return g;
541
+ }
542
+
543
+ /** A fixed box that loads an image into it (object-fit: contain) once the texture resolves. */
544
+ function imageBox(url: string, w: number, h: number): Container {
545
+ const c = new Container();
546
+ c.addChild(spacerBox(w, h));
547
+ Assets.load(url)
548
+ .then((tex: Texture) => {
549
+ const sp = new Sprite(tex);
550
+ const scale = Math.min(w / tex.width, h / tex.height);
551
+ sp.scale.set(scale);
552
+ sp.position.set((w - tex.width * scale) / 2, (h - tex.height * scale) / 2);
553
+ c.addChild(sp);
554
+ })
555
+ .catch(() => {
556
+ /* missing image → empty box */
557
+ });
558
+ return c;
559
+ }
@@ -0,0 +1,40 @@
1
+ import type { PixiComponentContext, ShellLayer } from '../context';
2
+ import type { ModalOptions } from '@/core/types';
3
+ import { makeText } from '../text';
4
+ import { CardModal, type CardAction } from '../primitives/card';
5
+
6
+ /** Generic externally-triggered modal — title + body text + optional action buttons. Each action
7
+ * runs its `on` then closes; the ✕ shows when `availableClose`. */
8
+ export function buildModal(host: PixiComponentContext, opts: ModalOptions): ShellLayer {
9
+ const modal = new CardModal(host, {
10
+ tag: 'modal',
11
+ title: opts.title,
12
+ closable: opts.availableClose,
13
+ blur: opts.blurLevel,
14
+ onClose: () => host.closeLayer(),
15
+ });
16
+ const em = modal.emSize;
17
+ const text = makeText(opts.body, {
18
+ size: 0.93 * em,
19
+ weight: '400',
20
+ color: 'rgba(255,255,255,.85)',
21
+ align: 'center',
22
+ wrapWidth: modal.cardWidth - 2.4 * em,
23
+ lineHeight: 0.93 * em * 1.5,
24
+ });
25
+ modal.body.add(text);
26
+
27
+ if (opts.actions?.length) {
28
+ const actions: CardAction[] = opts.actions.map((a) => ({
29
+ label: a.title,
30
+ kind: a.color ? a.color : 'ghost',
31
+ onTap: () => {
32
+ a.on?.();
33
+ host.closeLayer();
34
+ },
35
+ }));
36
+ modal.setActions(actions);
37
+ }
38
+ modal.build();
39
+ return modal;
40
+ }
@@ -0,0 +1,80 @@
1
+ import { Graphics } from 'pixi.js';
2
+ import type { PixiComponentContext, ShellLayer } from '../context';
3
+ import type { ReplayModalOptions } from '@/core/types';
4
+ import { makeText } from '../text';
5
+ import { CardModal } from '../primitives/card';
6
+ import { FlexBox } from '../primitives/flex';
7
+
8
+ /** Non-dismissable replay summary — label/value rows, accented total-win row. The only way out is
9
+ * START REPLAY, which closes the modal, runs `onReplay`, then reopens it (whether it resolves or
10
+ * rejects, so a failed replay can't strand the user).
11
+ *
12
+ * The `reopen` callback is supplied by the PixiRenderer (Task 13) — it re-opens this overlay so
13
+ * the user can see the summary again after the replay finishes. */
14
+ export function buildReplayModal(host: PixiComponentContext, opts: ReplayModalOptions, reopen: () => void): ShellLayer {
15
+ const { bonusId, bet, payoutMultiplier } = opts;
16
+ const bonus = Array.isArray(host.config.features.buyBonus)
17
+ ? host.config.features.buyBonus.find((b) => b.id === bonusId)
18
+ : undefined;
19
+ const mode = bonus?.title ?? bonusId;
20
+ const costMultiplier = bonus?.priceMultiplier ?? 1;
21
+
22
+ const modal = new CardModal(host, { tag: 'replay-modal', title: host.t('Replay'), closable: false });
23
+ const em = modal.emSize;
24
+ const innerW = modal.cardWidth - 2.4 * em;
25
+
26
+ const rows = new FlexBox({ direction: 'column', align: 'stretch', gap: 0 });
27
+ const addRow = (label: string, value: string, total = false): void => {
28
+ if (rows.measureSize().h > 0) rows.add(hairline(host, innerW)); // border-top between rows
29
+ const row = new FlexBox({
30
+ direction: 'row',
31
+ align: 'center',
32
+ justify: 'space-between',
33
+ padding: { top: 0.73 * em, bottom: 0.73 * em },
34
+ });
35
+ row.add(
36
+ makeText(host.t(label), {
37
+ size: (total ? 0.8 : 0.73) * em,
38
+ weight: '700',
39
+ color: total ? '#ffffff' : host.tokens.plaqueLabel,
40
+ letterSpacing: 0.73 * em * 0.07,
41
+ upper: true,
42
+ }),
43
+ );
44
+ row.add(
45
+ makeText(value, {
46
+ size: (total ? 1.27 : 1) * em,
47
+ weight: '800',
48
+ color: total ? host.tokens.accent : '#ffffff',
49
+ }),
50
+ );
51
+ rows.add(row);
52
+ };
53
+
54
+ addRow('Mode', mode);
55
+ addRow('Base bet', host.fmt(bet));
56
+ addRow('Cost multiplier', `${costMultiplier}×`);
57
+ addRow('Total cost', host.fmt(bet * costMultiplier));
58
+ addRow('Win multiplier', `${payoutMultiplier}×`);
59
+ addRow('Total win', host.fmtWin(payoutMultiplier * bet), true);
60
+ modal.body.add(rows);
61
+
62
+ modal.setActions([
63
+ {
64
+ label: host.t('Start replay'),
65
+ kind: 'accent',
66
+ onTap: () => {
67
+ host.closeLayer();
68
+ Promise.resolve(opts.onReplay()).then(reopen, reopen);
69
+ },
70
+ },
71
+ ]);
72
+ modal.build();
73
+ return modal;
74
+ }
75
+
76
+ function hairline(host: PixiComponentContext, width: number): Graphics {
77
+ const g = new Graphics();
78
+ g.rect(0, 0, width, 1).fill(host.tokens.plaqueLine);
79
+ return g;
80
+ }