@energy8platform/shell 0.7.2 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/html.cjs.js +77 -11
- package/dist/html.cjs.js.map +1 -1
- package/dist/html.d.ts +14 -4
- package/dist/html.esm.js +77 -11
- package/dist/html.esm.js.map +1 -1
- package/dist/index.cjs.js +42 -3
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +14 -4
- package/dist/index.esm.js +42 -3
- package/dist/index.esm.js.map +1 -1
- package/dist/pixi.cjs.js +322 -47
- package/dist/pixi.cjs.js.map +1 -1
- package/dist/pixi.d.ts +14 -4
- package/dist/pixi.esm.js +322 -47
- package/dist/pixi.esm.js.map +1 -1
- package/package.json +1 -1
- package/src/core/ShellController.ts +10 -2
- package/src/core/bonusGroups.ts +29 -0
- package/src/core/device.ts +37 -0
- package/src/core/types.ts +12 -2
- package/src/core/version.ts +1 -1
- package/src/ui/html/components/BottomBar.ts +20 -5
- package/src/ui/html/components/GameInfo.ts +8 -3
- package/src/ui/html/shell.css.ts +5 -0
- package/src/ui/pixi/components/BottomBar.ts +22 -3
- package/src/ui/pixi/components/BuyBonus.ts +195 -19
- package/src/ui/pixi/components/GameInfo.ts +8 -3
- package/src/ui/pixi/primitives/widgets.ts +65 -19
package/package.json
CHANGED
|
@@ -5,6 +5,7 @@ import { formatCurrency } from './format';
|
|
|
5
5
|
import { createI18n, type I18n } from './i18n';
|
|
6
6
|
import { KeyboardController, type KeyboardHost } from './keyboard';
|
|
7
7
|
import { DEFAULT_MENU, rangeBounds, seedMenuValues, type MenuItem, type MenuRangeItem } from './menu';
|
|
8
|
+
import { keyboardCapable } from './device';
|
|
8
9
|
import { PACKAGE_VERSION } from './version';
|
|
9
10
|
import type {
|
|
10
11
|
ShellConfig,
|
|
@@ -46,7 +47,10 @@ export function resolveConfig(config: ShellConfig): ResolvedShellConfig {
|
|
|
46
47
|
win: config.win,
|
|
47
48
|
mode: config.mode,
|
|
48
49
|
gameInfo: config.gameInfo,
|
|
49
|
-
|
|
50
|
+
// `hotkeys` unset means "decide for me": a touchscreen has no keys to press, so the shell
|
|
51
|
+
// neither binds them nor advertises them there. A host that knows better — the platform's own
|
|
52
|
+
// `device` field, a jurisdiction rule — says so outright and that wins. See core/device.ts.
|
|
53
|
+
features: { ...config.features, hotkeys: config.features.hotkeys ?? keyboardCapable() },
|
|
50
54
|
theme: config.theme,
|
|
51
55
|
onBonusBuy: config.onBonusBuy,
|
|
52
56
|
volumes: config.volumes,
|
|
@@ -145,7 +149,11 @@ export class ShellController extends EventEmitter<ShellEvents> implements ShellH
|
|
|
145
149
|
this.renderer.renderBar();
|
|
146
150
|
},
|
|
147
151
|
toggleAutoplay: () => {
|
|
148
|
-
|
|
152
|
+
// A halted run (stopped, but with spins still owed after a lost connection) counts as
|
|
153
|
+
// "autoplay is on screen": the toggle retires its leftover count, exactly as it stops a
|
|
154
|
+
// running one. Resuming those spins is the disc's job, not this one's.
|
|
155
|
+
const { active, remaining } = this.state.autoplay;
|
|
156
|
+
if (active || remaining > 0) a.stopAutoplay();
|
|
149
157
|
else this.openAutoplayPicker();
|
|
150
158
|
},
|
|
151
159
|
startAutoplay: (remaining) => {
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { BonusOption } from './types';
|
|
2
|
+
|
|
3
|
+
/** Collapse buy-bonus options that share a `groupedBy` key into one slot. A slot with more than one
|
|
4
|
+
* member is rendered by the pixi shell as a single card with arrows (the DOM shell ignores the key
|
|
5
|
+
* and keeps a card per option).
|
|
6
|
+
*
|
|
7
|
+
* A group takes the position of its FIRST member, so the strip order stays predictable however the
|
|
8
|
+
* members are scattered through the array. Options with a game-supplied `custom` renderer never
|
|
9
|
+
* join a group — the shell has no layout for their interior and could not place arrows over it. */
|
|
10
|
+
export function groupBonusSlots(bonuses: readonly BonusOption[]): BonusOption[][] {
|
|
11
|
+
const slots: BonusOption[][] = [];
|
|
12
|
+
const byKey = new Map<string, BonusOption[]>();
|
|
13
|
+
for (const bonus of bonuses) {
|
|
14
|
+
const key = bonus.groupedBy;
|
|
15
|
+
if (!key || bonus.custom) {
|
|
16
|
+
slots.push([bonus]);
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
const open = byKey.get(key);
|
|
20
|
+
if (open) {
|
|
21
|
+
open.push(bonus);
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
const slot = [bonus];
|
|
25
|
+
byKey.set(key, slot);
|
|
26
|
+
slots.push(slot);
|
|
27
|
+
}
|
|
28
|
+
return slots;
|
|
29
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Can the player in front of this client actually press a key?
|
|
3
|
+
*
|
|
4
|
+
* The shell documents its shortcuts in a Hotkeys section and binds Spacebar to spin. On a phone
|
|
5
|
+
* neither is reachable, and showing a keycap chart to someone holding a touchscreen is a promise
|
|
6
|
+
* the game can't keep — a certification lab reads it as a feature offered where it doesn't work.
|
|
7
|
+
*
|
|
8
|
+
* The question is deliberately NOT "is the layout narrow" (a portrait desktop window still has a
|
|
9
|
+
* keyboard) and NOT "is there a touchscreen" (a touch laptop has both). It is: what does the
|
|
10
|
+
* PRIMARY pointer look like, and can it hover? Coarse-and-hoverless is a touchscreen, and a
|
|
11
|
+
* touchscreen is the one case where the keys genuinely aren't there.
|
|
12
|
+
*
|
|
13
|
+
* A tablet with a keyboard case answers "coarse, no hover" too and loses the chart. That is the
|
|
14
|
+
* right side to be wrong on: the chart is a convenience, and the keys keep working for anyone who
|
|
15
|
+
* has them — the media query only decides what the shell ADVERTISES (hosts can still say outright,
|
|
16
|
+
* via `features.hotkeys`, and the platform's own `device` field does exactly that).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
interface MediaQueryHost {
|
|
20
|
+
matchMedia?(query: string): { matches: boolean };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A touchscreen: the primary pointer is a finger, and nothing can hover. */
|
|
24
|
+
const TOUCH_ONLY = '(pointer: coarse) and (hover: none)';
|
|
25
|
+
|
|
26
|
+
export function keyboardCapable(
|
|
27
|
+
win: MediaQueryHost | undefined = typeof window === 'undefined' ? undefined : window,
|
|
28
|
+
): boolean {
|
|
29
|
+
// No window (SSR, node tests) or a browser too old for matchMedia: assume a keyboard rather than
|
|
30
|
+
// silently stripping shortcuts from a desktop we simply failed to measure.
|
|
31
|
+
if (typeof win?.matchMedia !== 'function') return true;
|
|
32
|
+
try {
|
|
33
|
+
return !win.matchMedia(TOUCH_ONLY).matches;
|
|
34
|
+
} catch {
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
}
|
package/src/core/types.ts
CHANGED
|
@@ -40,6 +40,11 @@ export interface BonusOption {
|
|
|
40
40
|
priceMultiplier: number;
|
|
41
41
|
/** Per-option accent override. Falls back to the type default (bonus → purple, feature → gold). */
|
|
42
42
|
accentColor?: string;
|
|
43
|
+
/** Options sharing a `groupedBy` key occupy ONE card slot and are flipped through with arrows —
|
|
44
|
+
* e.g. four same-priced Ante variants that each swap the character, its art and its volatility.
|
|
45
|
+
* Pixi shell only; the DOM shell ignores the key and keeps a card per option. Options with a
|
|
46
|
+
* `custom` renderer are never grouped. */
|
|
47
|
+
groupedBy?: string;
|
|
43
48
|
/** Override the card UI. Return the card's inner content; the shell keeps the grid wrapper,
|
|
44
49
|
* accent vars and live re-pricing, and runs the normal buy flow when you call `ctx.select()`.
|
|
45
50
|
* Core uses `unknown`; each renderer re-exports a typed alias (ui/html → HTMLElement,
|
|
@@ -151,8 +156,13 @@ export interface AutoplayConfig {
|
|
|
151
156
|
|
|
152
157
|
export interface ShellFeatures {
|
|
153
158
|
turbo: 0 | 1 | 2 | 3;
|
|
154
|
-
/** Master keyboard-shortcut switch
|
|
155
|
-
*
|
|
159
|
+
/** Master keyboard-shortcut switch: `false` disables ALL hotkeys (overrides `spacebar` and any
|
|
160
|
+
* future hotkey) AND hides the Hotkeys section of Game Info, including one the game supplied
|
|
161
|
+
* itself — a keycap chart for keys that do nothing is worse than no chart.
|
|
162
|
+
*
|
|
163
|
+
* Left unset, the shell measures the client (`core/device.ts`): a touchscreen has no keys to
|
|
164
|
+
* press, so it gets neither the shortcuts nor the chart. Set it explicitly when you know better
|
|
165
|
+
* than the media query — which is what the host does with the platform's `device` field. */
|
|
156
166
|
hotkeys?: boolean;
|
|
157
167
|
/** Spacebar starts a spin in base mode. Defaults to `true`; set `false` to disable the
|
|
158
168
|
* keyboard shortcut (e.g. jurisdictions that forbid quick-spin keys). */
|
package/src/core/version.ts
CHANGED
|
@@ -225,20 +225,32 @@ function betLocked(host: ShellHost): boolean {
|
|
|
225
225
|
return host.state.busy || host.state.autoplay.active;
|
|
226
226
|
}
|
|
227
227
|
|
|
228
|
-
/**
|
|
228
|
+
/**
|
|
229
|
+
* SPIN disc — rotates while busy; becomes a STOP + countdown while autoplay runs; becomes an
|
|
230
|
+
* autoplay glyph + the SAME countdown when a run was halted with spins still owed (a lost
|
|
231
|
+
* connection), where a tap resumes it. That third state is what a certification lab means by "after
|
|
232
|
+
* reconnection the counter is displayed correctly": the run stopped, but the spins the player asked
|
|
233
|
+
* for are still on screen and one tap away, instead of silently reset to zero.
|
|
234
|
+
*/
|
|
229
235
|
function spinButton(host: ShellHost): HTMLButtonElement {
|
|
230
236
|
const { state } = host;
|
|
231
237
|
const sp = document.createElement('button');
|
|
232
238
|
sp.className = 'ge-shell-spin';
|
|
233
239
|
sp.dataset.ge = 'spin';
|
|
240
|
+
const rem = state.autoplay.remaining;
|
|
241
|
+
const count = Number.isFinite(rem) ? String(rem) : '∞';
|
|
234
242
|
if (state.autoplay.active) {
|
|
235
243
|
sp.classList.add('ge-stop');
|
|
236
|
-
|
|
237
|
-
const label = Number.isFinite(rem) ? String(rem) : '∞';
|
|
238
|
-
sp.innerHTML = `<span class="ge-spin-stop">${icon('stop')}</span><span class="ge-spin-count">${label}</span>`;
|
|
244
|
+
sp.innerHTML = `<span class="ge-spin-stop">${icon('stop')}</span><span class="ge-spin-count">${count}</span>`;
|
|
239
245
|
sp.addEventListener('click', () => {
|
|
240
246
|
if (!sp.disabled) host.actions.stopAutoplay();
|
|
241
247
|
});
|
|
248
|
+
} else if (rem > 0) {
|
|
249
|
+
sp.classList.add('ge-auto-paused');
|
|
250
|
+
sp.innerHTML = `<span class="ge-spin-auto">${icon('autoplay')}</span><span class="ge-spin-count">${count}</span>`;
|
|
251
|
+
sp.addEventListener('click', () => {
|
|
252
|
+
if (!sp.disabled) host.actions.startAutoplay(rem);
|
|
253
|
+
});
|
|
242
254
|
} else {
|
|
243
255
|
sp.innerHTML = icon('spin');
|
|
244
256
|
if (state.busy) sp.classList.add('ge-spinning');
|
|
@@ -257,8 +269,11 @@ function autoButton(host: ShellHost): HTMLButtonElement {
|
|
|
257
269
|
return b;
|
|
258
270
|
}
|
|
259
271
|
|
|
272
|
+
/** Same button, three jobs: stop a running run, retire a halted run's leftover count (which frees
|
|
273
|
+
* the disc for a manual spin again), or open the picker. */
|
|
260
274
|
function onAutoplay(host: ShellHost): void {
|
|
261
|
-
|
|
275
|
+
const { active, remaining } = host.state.autoplay;
|
|
276
|
+
if (active || remaining > 0) host.actions.stopAutoplay();
|
|
262
277
|
else host.actions.openAutoplayPicker();
|
|
263
278
|
}
|
|
264
279
|
|
|
@@ -24,10 +24,15 @@ export function openGameInfoModal(host: ShellHost): GameInfoModal {
|
|
|
24
24
|
});
|
|
25
25
|
root.dataset.ge = 'info-modal';
|
|
26
26
|
|
|
27
|
-
const
|
|
28
|
-
// Auto-inject a hotkeys section unless the game already provides one
|
|
27
|
+
const allSections = host.config.gameInfo.sections ?? [];
|
|
28
|
+
// Auto-inject a hotkeys section unless the game already provides one. With hotkeys off — a
|
|
29
|
+
// jurisdiction that forbids them, or a touchscreen that has no keys at all (see core/device.ts) —
|
|
30
|
+
// there is no keyboard surface to document, and a game-supplied section is dropped along with the
|
|
31
|
+
// auto-injected one: a keycap chart for keys the player cannot press is a promise the game breaks.
|
|
32
|
+
const keys = host.config.features.hotkeys !== false;
|
|
33
|
+
const rawSections = keys ? allSections : allSections.filter((s) => s.type !== 'hotkeys');
|
|
29
34
|
const sectionsWithHotkeys: GameInfoSection[] = [...rawSections];
|
|
30
|
-
if (
|
|
35
|
+
if (keys && !rawSections.some((s) => s.type === 'hotkeys')) {
|
|
31
36
|
sectionsWithHotkeys.push({ type: 'hotkeys', order: HOTKEYS_DEFAULT_ORDER });
|
|
32
37
|
}
|
|
33
38
|
const sections = sectionsWithHotkeys;
|
package/src/ui/html/shell.css.ts
CHANGED
|
@@ -57,6 +57,11 @@ export const SHELL_CSS = SHELL_FONT_CSS + SHELL_DIGIT_FONT_CSS + `
|
|
|
57
57
|
/* the STOP glyph is a solid dark square, so the autoplay count is always pure white to read on it. */
|
|
58
58
|
#${SHELL_ROOT_ID} .ge-spin-count { position:absolute; inset:0; display:flex; align-items:center; justify-content:center;
|
|
59
59
|
font-size:22px; font-weight:800; line-height:1; font-variant-numeric:tabular-nums; color:#fff; }
|
|
60
|
+
/* autoplay halted with spins still owed (a lost connection): the run is stopped, so no STOP square —
|
|
61
|
+
the auto glyph sits above the leftover count, both in the disc's own ink, and a tap resumes. */
|
|
62
|
+
#${SHELL_ROOT_ID} .ge-shell-spin.ge-auto-paused { flex-direction:column; gap:1px; position:relative; }
|
|
63
|
+
#${SHELL_ROOT_ID} .ge-auto-paused .ge-spin-auto { display:flex; font-size:.46em; line-height:0; }
|
|
64
|
+
#${SHELL_ROOT_ID} .ge-auto-paused .ge-spin-count { position:static; inset:auto; color:inherit; font-size:20px; }
|
|
60
65
|
|
|
61
66
|
/* BUY BONUS — round accent badge, 2-line label, text pulses + accent glow on hover */
|
|
62
67
|
#${SHELL_ROOT_ID} .ge-shell-buybonus { pointer-events:auto; cursor:pointer; box-sizing:border-box;
|
|
@@ -13,9 +13,19 @@ import {
|
|
|
13
13
|
FsHero,
|
|
14
14
|
divider,
|
|
15
15
|
} from '../primitives/widgets';
|
|
16
|
+
import type { SpinAutoplayMode } from '../primitives/widgets';
|
|
16
17
|
import type { IconName } from '../icons';
|
|
17
18
|
|
|
18
19
|
// ── design constants (mirror the DOM `.ge-bar-panel` / mobile rules) ──────────
|
|
20
|
+
/** Which of the disc's three faces the autoplay state calls for — see `SpinAutoplayMode`. A run
|
|
21
|
+
* that halted with spins still owed (`!active && remaining > 0`) keeps its counter on the disc. */
|
|
22
|
+
function autoplayDiscMode(state: {
|
|
23
|
+
autoplay: { active: boolean; remaining: number };
|
|
24
|
+
}): SpinAutoplayMode {
|
|
25
|
+
if (state.autoplay.active) return 'running';
|
|
26
|
+
return state.autoplay.remaining > 0 ? 'paused' : 'off';
|
|
27
|
+
}
|
|
28
|
+
|
|
19
29
|
const BAR_H = 68; // continuous dark panel height
|
|
20
30
|
const SPIN = 84; // hero disc — pops above/below the bar
|
|
21
31
|
const SPIN_POP = (SPIN - BAR_H) / 2; // 8 — how far the disc sticks out top/bottom
|
|
@@ -296,8 +306,9 @@ export class BottomBar extends Container {
|
|
|
296
306
|
ticker: this.host.ticker,
|
|
297
307
|
onSpin: () => this.host.actions.spin(),
|
|
298
308
|
onStop: () => this.stopAutoplay(),
|
|
309
|
+
onResume: () => this.resumeAutoplay(),
|
|
299
310
|
});
|
|
300
|
-
|
|
311
|
+
this.spin.setAutoplay(autoplayDiscMode(state), state.autoplay.remaining);
|
|
301
312
|
if (state.busy) this.spin.setBusy(true);
|
|
302
313
|
spinWrap.add(this.spin);
|
|
303
314
|
} else if (showFsBlocks) {
|
|
@@ -373,8 +384,9 @@ export class BottomBar extends Container {
|
|
|
373
384
|
ticker: this.host.ticker,
|
|
374
385
|
onSpin: () => this.host.actions.spin(),
|
|
375
386
|
onStop: () => this.stopAutoplay(),
|
|
387
|
+
onResume: () => this.resumeAutoplay(),
|
|
376
388
|
});
|
|
377
|
-
|
|
389
|
+
this.spin.setAutoplay(autoplayDiscMode(state), state.autoplay.remaining);
|
|
378
390
|
if (state.busy) this.spin.setBusy(true);
|
|
379
391
|
hero = this.spin;
|
|
380
392
|
} else if (showFsBlocks) {
|
|
@@ -569,13 +581,20 @@ export class BottomBar extends Container {
|
|
|
569
581
|
private onTurbo(): void {
|
|
570
582
|
this.host.actions.cycleTurbo();
|
|
571
583
|
}
|
|
584
|
+
/** Same button, three jobs: stop a running run, retire a halted run's leftover count (freeing the
|
|
585
|
+
* disc for a manual spin again), or open the picker. Mirrors the DOM bar's `onAutoplay`. */
|
|
572
586
|
private onAutoplay(): void {
|
|
573
|
-
|
|
587
|
+
const { active, remaining } = this.host.state.autoplay;
|
|
588
|
+
if (active || remaining > 0) this.stopAutoplay();
|
|
574
589
|
else this.host.actions.openAutoplayPicker();
|
|
575
590
|
}
|
|
576
591
|
private stopAutoplay(): void {
|
|
577
592
|
this.host.actions.stopAutoplay();
|
|
578
593
|
}
|
|
594
|
+
/** Halted run, tapped: play out the spins it still owes. */
|
|
595
|
+
private resumeAutoplay(): void {
|
|
596
|
+
this.host.actions.startAutoplay(this.host.state.autoplay.remaining);
|
|
597
|
+
}
|
|
579
598
|
private betLocked(): boolean {
|
|
580
599
|
return this.host.state.busy || this.host.state.autoplay.active;
|
|
581
600
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Assets, Container, Graphics, Rectangle, Sprite, Text, Texture, type FederatedPointerEvent } from 'pixi.js';
|
|
2
2
|
import type { PixiComponentContext, ShellLayer } from '../context';
|
|
3
3
|
import type { BonusOption } from '@/core/types';
|
|
4
|
+
import { groupBonusSlots } from '@/core/bonusGroups';
|
|
4
5
|
import { betDir } from '@/core/keyboard';
|
|
5
6
|
import { effectiveAccent, contrastText } from '@/core/colors';
|
|
6
7
|
import { makeText } from '../text';
|
|
@@ -23,11 +24,15 @@ export function openBuyBonus(host: PixiComponentContext): ShellLayer | null {
|
|
|
23
24
|
return new BuyBonusOverlay(host, bonuses);
|
|
24
25
|
}
|
|
25
26
|
|
|
26
|
-
/** Pairing of a rendered CardView with its bonus data (affordability included).
|
|
27
|
+
/** Pairing of a rendered CardView with its bonus data (affordability included). One entry per
|
|
28
|
+
* OPTION — the members of a `groupedBy` slot each get an entry pointing at the shared group card,
|
|
29
|
+
* so keyboard navigation walks every option even though they occupy one slot. */
|
|
27
30
|
interface CardEntry {
|
|
28
31
|
view: CardView;
|
|
29
32
|
bonus: BonusOption;
|
|
30
33
|
affordable: boolean;
|
|
34
|
+
/** Set on members of a grouped slot; flipping it brings this member's card to the front. */
|
|
35
|
+
group?: GroupCard;
|
|
31
36
|
}
|
|
32
37
|
|
|
33
38
|
class BuyBonusOverlay extends Container implements ShellLayer {
|
|
@@ -51,6 +56,9 @@ class BuyBonusOverlay extends Container implements ShellLayer {
|
|
|
51
56
|
private cardEntries: CardEntry[] = [];
|
|
52
57
|
/** Keyboard focus index into the affordable subset of cardEntries. -1 = none. */
|
|
53
58
|
private focusIndex = -1;
|
|
59
|
+
/** Which member each `groupedBy` slot is showing, kept across rebuilds (bet steps, resizes) so a
|
|
60
|
+
* flipped-to variant doesn't snap back to the first one under the player. */
|
|
61
|
+
private groupIndex = new Map<string, number>();
|
|
54
62
|
/** true when cards are stacked vertically (mobile, or a short landscape popout). */
|
|
55
63
|
private stack = false;
|
|
56
64
|
// Drag-scroll state. The drag is handled on the (unmasked) overlay, not the masked strip — a mask
|
|
@@ -169,7 +177,9 @@ class BuyBonusOverlay extends Container implements ShellLayer {
|
|
|
169
177
|
const top = this.headerH + 6;
|
|
170
178
|
const areaH = this.h - top - this.footerH - 6;
|
|
171
179
|
const gap = 14;
|
|
172
|
-
|
|
180
|
+
// Grouped options share ONE slot (a carousel card), so the width fit counts SLOTS, not options.
|
|
181
|
+
const slots = groupBonusSlots(this.bonuses);
|
|
182
|
+
const n = Math.max(1, slots.length);
|
|
173
183
|
// The whole card is laid out in `em`; pick the largest em that fits BOTH dimensions of the
|
|
174
184
|
// available area, so the card is always fully visible (no horizontal clip, CTA never under the
|
|
175
185
|
// footer). emH keeps the card height within the band between header and footer; emW makes the
|
|
@@ -182,16 +192,45 @@ class BuyBonusOverlay extends Container implements ShellLayer {
|
|
|
182
192
|
const em = stack ? Math.min(12, emW) : clamp(4, Math.min(emH, emW), 12);
|
|
183
193
|
const cardW = Math.min(18 * em, this.w - 48);
|
|
184
194
|
|
|
185
|
-
const cards =
|
|
195
|
+
const cards: CardView[] = [];
|
|
196
|
+
this.cardEntries = [];
|
|
197
|
+
for (const slot of slots) {
|
|
198
|
+
if (slot.length === 1) {
|
|
199
|
+
const view = this.buildCard(slot[0], cardW, em, stack, areaH);
|
|
200
|
+
cards.push(view);
|
|
201
|
+
this.cardEntries.push({ view, bonus: slot[0], affordable: this.isAffordable(slot[0]) });
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
// Carousel slot: every member is built (the slot sizes to the tallest and flipping costs
|
|
205
|
+
// nothing), but only the current one is visible.
|
|
206
|
+
const key = slot[0].groupedBy as string;
|
|
207
|
+
// Flipping the card also moves keyboard focus onto the shown member: one cursor, not two
|
|
208
|
+
// (otherwise the next rebuild would snap the card back to whatever the focus still pointed at).
|
|
209
|
+
const group: GroupCard = new GroupCard((i) => {
|
|
210
|
+
this.groupIndex.set(key, i);
|
|
211
|
+
this.focusShownMember(group, i);
|
|
212
|
+
});
|
|
213
|
+
const members = slot.map(
|
|
214
|
+
(b, i) =>
|
|
215
|
+
this.buildCard(b, cardW, em, stack, areaH, {
|
|
216
|
+
index: i,
|
|
217
|
+
count: slot.length,
|
|
218
|
+
prevLabel: `bb-nav-prev:${key}`,
|
|
219
|
+
nextLabel: `bb-nav-next:${key}`,
|
|
220
|
+
onStep: (dir) => {
|
|
221
|
+
if (this.dragged) return; // a scroll gesture, not a tap
|
|
222
|
+
group.step(dir);
|
|
223
|
+
},
|
|
224
|
+
}) as BonusCard,
|
|
225
|
+
);
|
|
226
|
+
group.setCards(members, this.groupIndex.get(key) ?? 0);
|
|
227
|
+
cards.push(group);
|
|
228
|
+
for (let i = 0; i < slot.length; i++) {
|
|
229
|
+
this.cardEntries.push({ view: members[i], bonus: slot[i], affordable: this.isAffordable(slot[i]), group });
|
|
230
|
+
}
|
|
231
|
+
}
|
|
186
232
|
const cardH = Math.max(...cards.map((c) => c.height));
|
|
187
233
|
for (const c of cards) c.setHeight(cardH);
|
|
188
|
-
|
|
189
|
-
// Rebuild card entries for keyboard navigation
|
|
190
|
-
this.cardEntries = this.bonuses.map((b, i) => ({
|
|
191
|
-
view: cards[i],
|
|
192
|
-
bonus: b,
|
|
193
|
-
affordable: this.isAffordable(b),
|
|
194
|
-
}));
|
|
195
234
|
// Restore or init keyboard focus on the first affordable card
|
|
196
235
|
const affordable = this.cardEntries.filter((e) => e.affordable);
|
|
197
236
|
if (affordable.length > 0) {
|
|
@@ -230,7 +269,7 @@ class BuyBonusOverlay extends Container implements ShellLayer {
|
|
|
230
269
|
}
|
|
231
270
|
|
|
232
271
|
// ── one card ──────────────────────────────────────────────────────────────
|
|
233
|
-
private buildCard(bonus: BonusOption, cardW: number, em: number, stack: boolean, areaH: number): CardView {
|
|
272
|
+
private buildCard(bonus: BonusOption, cardW: number, em: number, stack: boolean, areaH: number, nav?: CardNav): CardView {
|
|
234
273
|
const accent = effectiveAccent(bonus);
|
|
235
274
|
const ink = contrastText(accent);
|
|
236
275
|
const price = bonus.priceMultiplier * this.host.state.bet;
|
|
@@ -264,6 +303,7 @@ class BuyBonusOverlay extends Container implements ShellLayer {
|
|
|
264
303
|
enabled,
|
|
265
304
|
ctaLabel: this.host.t(bonus.type === 'feature' ? 'Activate' : 'Buy'),
|
|
266
305
|
onSelect: select,
|
|
306
|
+
nav,
|
|
267
307
|
});
|
|
268
308
|
void stack;
|
|
269
309
|
void areaH;
|
|
@@ -392,12 +432,12 @@ class BuyBonusOverlay extends Container implements ShellLayer {
|
|
|
392
432
|
const r = 1.3 * em;
|
|
393
433
|
card.addChild(
|
|
394
434
|
footerButton(this.host, this.host.t('Cancel'), 'ghost', half, ctaH, 0, y, () => this.removeConfirm(), undefined, r, 0),
|
|
395
|
-
footerButton(this.host, this.host.t(bonus.type === 'feature' ? 'Activate' : 'Buy'), accent, half, ctaH, half, y, () => {
|
|
435
|
+
labelled('bb-confirm-ok', footerButton(this.host, this.host.t(bonus.type === 'feature' ? 'Activate' : 'Buy'), accent, half, ctaH, half, y, () => {
|
|
396
436
|
if (!this.isAffordable(bonus)) return;
|
|
397
437
|
if (bonus.type === 'feature') this.host.actions.activateFeature(bonus);
|
|
398
438
|
else this.host.actions.selectBuyBonus(bonus.id);
|
|
399
439
|
this.host.closeLayer();
|
|
400
|
-
}, ink, 0, r),
|
|
440
|
+
}, ink, 0, r)),
|
|
401
441
|
);
|
|
402
442
|
card.position.set((this.w - cardW) / 2, (this.h - cardH) / 2);
|
|
403
443
|
layer.addChild(card);
|
|
@@ -430,14 +470,26 @@ class BuyBonusOverlay extends Container implements ShellLayer {
|
|
|
430
470
|
this.buildFooter();
|
|
431
471
|
}
|
|
432
472
|
|
|
473
|
+
/** Move keyboard focus onto the member a carousel slot just flipped to. Skipped when that member
|
|
474
|
+
* can't be bought at the current bet — focus only ever sits on affordable cards. */
|
|
475
|
+
private focusShownMember(group: GroupCard, index: number): void {
|
|
476
|
+
const card = group.cardAt(index);
|
|
477
|
+
const at = this.cardEntries.filter((ce) => ce.affordable).findIndex((ce) => ce.group === group && ce.view === card);
|
|
478
|
+
if (at < 0) return;
|
|
479
|
+
this.focusIndex = at;
|
|
480
|
+
this.applyFocusRing();
|
|
481
|
+
}
|
|
482
|
+
|
|
433
483
|
/** Apply or clear the focus ring on affordable cards. */
|
|
434
484
|
private applyFocusRing(): void {
|
|
435
485
|
const affordable = this.cardEntries.filter((ce) => ce.affordable);
|
|
436
486
|
for (let i = 0; i < affordable.length; i++) {
|
|
437
|
-
const
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
487
|
+
const entry = affordable[i];
|
|
488
|
+
const focused = i === this.focusIndex;
|
|
489
|
+
// Focusing a member of a carousel slot brings it to the front — the keyboard walks options,
|
|
490
|
+
// and the card follows.
|
|
491
|
+
if (focused && entry.group) entry.group.showCard(entry.view);
|
|
492
|
+
if (entry.view instanceof BonusCard) entry.view.setFocused(focused);
|
|
441
493
|
}
|
|
442
494
|
}
|
|
443
495
|
|
|
@@ -550,6 +602,68 @@ class CustomCard implements CardView {
|
|
|
550
602
|
}
|
|
551
603
|
}
|
|
552
604
|
|
|
605
|
+
/** One slot shared by several options (`groupedBy`) — e.g. four same-priced Ante characters. Every
|
|
606
|
+
* member card is built (the slot sizes to the tallest, and flipping is then just a visibility
|
|
607
|
+
* swap, so the card never resizes under the player); one is visible at a time. */
|
|
608
|
+
class GroupCard implements CardView {
|
|
609
|
+
readonly node = new Container();
|
|
610
|
+
height = 0;
|
|
611
|
+
private cards: BonusCard[] = [];
|
|
612
|
+
private index = 0;
|
|
613
|
+
/** Reports the shown member back to the overlay, which keeps it across rebuilds. */
|
|
614
|
+
private onIndex: (index: number) => void;
|
|
615
|
+
|
|
616
|
+
constructor(onIndex: (index: number) => void) {
|
|
617
|
+
this.onIndex = onIndex;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
setCards(cards: BonusCard[], index: number): void {
|
|
621
|
+
this.cards = cards;
|
|
622
|
+
for (const c of cards) this.node.addChild(c.node);
|
|
623
|
+
this.height = Math.max(...cards.map((c) => c.height));
|
|
624
|
+
this.show(index, false); // restoring a remembered position is not a player action
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/** Flip to member `i`, wrapping around in both directions. */
|
|
628
|
+
show(i: number, notify = true): void {
|
|
629
|
+
const n = this.cards.length;
|
|
630
|
+
if (n === 0) return;
|
|
631
|
+
this.index = ((i % n) + n) % n;
|
|
632
|
+
for (let k = 0; k < n; k++) this.cards[k].node.visible = k === this.index;
|
|
633
|
+
if (notify) this.onIndex(this.index);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
step(dir: 1 | -1): void {
|
|
637
|
+
this.show(this.index + dir);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
cardAt(index: number): CardView | undefined {
|
|
641
|
+
return this.cards[index];
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/** Bring a specific member's card to the front (keyboard focus landing on that option). */
|
|
645
|
+
showCard(card: CardView): void {
|
|
646
|
+
const i = this.cards.findIndex((c) => c === card);
|
|
647
|
+
if (i >= 0 && i !== this.index) this.show(i);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
setHeight(total: number): void {
|
|
651
|
+
this.height = total;
|
|
652
|
+
for (const c of this.cards) c.setHeight(total);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/** Carousel chrome for a card that shares its slot with other options (`groupedBy`): arrows either
|
|
657
|
+
* side of the title and a dot per member under the description. */
|
|
658
|
+
interface CardNav {
|
|
659
|
+
/** This member's position in the slot (drives which dot is lit). */
|
|
660
|
+
index: number;
|
|
661
|
+
count: number;
|
|
662
|
+
prevLabel: string;
|
|
663
|
+
nextLabel: string;
|
|
664
|
+
onStep: (dir: 1 | -1) => void;
|
|
665
|
+
}
|
|
666
|
+
|
|
553
667
|
interface BonusCardOpts {
|
|
554
668
|
host: PixiComponentContext;
|
|
555
669
|
bonus: BonusOption;
|
|
@@ -561,6 +675,7 @@ interface BonusCardOpts {
|
|
|
561
675
|
enabled: boolean;
|
|
562
676
|
ctaLabel: string;
|
|
563
677
|
onSelect: () => void;
|
|
678
|
+
nav?: CardNav;
|
|
564
679
|
}
|
|
565
680
|
|
|
566
681
|
class BonusCard implements CardView {
|
|
@@ -587,9 +702,22 @@ class BonusCard implements CardView {
|
|
|
587
702
|
|
|
588
703
|
const top = new Container();
|
|
589
704
|
let y = 1.25 * em;
|
|
590
|
-
|
|
705
|
+
// With arrows the title has to clear them on both sides, or a long name wraps under the glyphs.
|
|
706
|
+
const arrowSz = 1.5 * em;
|
|
707
|
+
const arrowPad = 0.45 * em;
|
|
708
|
+
const titleW = opts.nav ? innerW - 2 * (arrowSz + 2 * arrowPad) : innerW;
|
|
709
|
+
const title = makeText(bonus.title, { size: 1.3 * em, weight: '800', color: titleColor, letterSpacing: 1.3 * em * 0.04, upper: true, align: 'center', wrapWidth: titleW });
|
|
591
710
|
title.position.set((cardW - title.width) / 2, y);
|
|
592
711
|
top.addChild(title);
|
|
712
|
+
if (opts.nav) {
|
|
713
|
+
const boxSz = arrowSz + 2 * arrowPad;
|
|
714
|
+
const rowY = y + (title.height - boxSz) / 2;
|
|
715
|
+
const prev = arrowButton(opts.nav.prevLabel, -1, arrowSz, arrowPad, accent, opts.nav.onStep);
|
|
716
|
+
const next = arrowButton(opts.nav.nextLabel, 1, arrowSz, arrowPad, accent, opts.nav.onStep);
|
|
717
|
+
prev.position.set(0.6 * em, rowY);
|
|
718
|
+
next.position.set(cardW - 0.6 * em - boxSz, rowY);
|
|
719
|
+
top.addChild(prev, next);
|
|
720
|
+
}
|
|
593
721
|
y += title.height + 0.75 * em;
|
|
594
722
|
const thumb = thumbNode(host, bonus, accent, 6.2 * em);
|
|
595
723
|
thumb.position.set((cardW - 6.2 * em) / 2, y);
|
|
@@ -599,6 +727,13 @@ class BonusCard implements CardView {
|
|
|
599
727
|
desc.position.set((cardW - desc.width) / 2, y);
|
|
600
728
|
top.addChild(desc);
|
|
601
729
|
y += desc.height;
|
|
730
|
+
if (opts.nav) {
|
|
731
|
+
y += 0.7 * em;
|
|
732
|
+
const dots = dotRow(opts.nav.count, opts.nav.index, accent, 0.24 * em, 0.5 * em);
|
|
733
|
+
dots.position.set((cardW - dots.width) / 2, y);
|
|
734
|
+
top.addChild(dots);
|
|
735
|
+
y += 0.48 * em;
|
|
736
|
+
}
|
|
602
737
|
this.topH = y;
|
|
603
738
|
|
|
604
739
|
// bottom block: volatility + price
|
|
@@ -615,8 +750,9 @@ class BonusCard implements CardView {
|
|
|
615
750
|
by += priceText.height;
|
|
616
751
|
this.bottomH = by;
|
|
617
752
|
|
|
618
|
-
this.cta = ctaButton(host, opts.ctaLabel, accent, opts.ink, cardW, this.ctaH, opts.enabled, opts.onSelect, 1.4 * em);
|
|
753
|
+
this.cta = labelled(`bb-cta:${bonus.id}`, ctaButton(host, opts.ctaLabel, accent, opts.ink, cardW, this.ctaH, opts.enabled, opts.onSelect, 1.4 * em));
|
|
619
754
|
|
|
755
|
+
this.node.label = `bb-card:${bonus.id}`;
|
|
620
756
|
this.node.addChild(this.bg, top, this.bottomBlock, this.cta);
|
|
621
757
|
if (opts.enabled) {
|
|
622
758
|
this.node.eventMode = 'static';
|
|
@@ -672,6 +808,46 @@ class BonusCard implements CardView {
|
|
|
672
808
|
}
|
|
673
809
|
|
|
674
810
|
// ── shared card bits ─────────────────────────────────────────────────────────
|
|
811
|
+
|
|
812
|
+
/** Tag a node so the shell (and its tests) can find it in the display tree. */
|
|
813
|
+
function labelled<T extends Container>(label: string, node: T): T {
|
|
814
|
+
node.label = label;
|
|
815
|
+
return node;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
/** One carousel arrow. `stopPropagation` keeps the tap off the card underneath, which would
|
|
819
|
+
* otherwise open the confirm dialog for the member being flipped away from. */
|
|
820
|
+
function arrowButton(label: string, dir: 1 | -1, size: number, pad: number, accent: string, onStep: (dir: 1 | -1) => void): Container {
|
|
821
|
+
const b = new Container();
|
|
822
|
+
b.label = label;
|
|
823
|
+
const glyph = makeIcon('chevronRight', size, '#ffffff');
|
|
824
|
+
if (dir === -1) glyph.spin = Math.PI; // no chevronLeft in the set — the glyph spins about its centre
|
|
825
|
+
glyph.position.set(pad, pad);
|
|
826
|
+
b.addChild(rectHit(size + pad * 2, size + pad * 2), glyph);
|
|
827
|
+
b.eventMode = 'static';
|
|
828
|
+
b.cursor = 'pointer';
|
|
829
|
+
b.on('pointerover', () => glyph.setColor(accent));
|
|
830
|
+
b.on('pointerout', () => glyph.setColor('#ffffff'));
|
|
831
|
+
b.on('pointertap', (e: FederatedPointerEvent) => {
|
|
832
|
+
e.stopPropagation();
|
|
833
|
+
onStep(dir);
|
|
834
|
+
});
|
|
835
|
+
return b;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
/** Position indicator for a carousel slot: one dot per member, the current one in the accent. */
|
|
839
|
+
function dotRow(count: number, index: number, accent: string, r: number, gap: number): Container {
|
|
840
|
+
const c = new Container();
|
|
841
|
+
for (let i = 0; i < count; i++) {
|
|
842
|
+
const dot = new Graphics();
|
|
843
|
+
const on = i === index;
|
|
844
|
+
dot.circle(r, r, r).fill(on ? accent : 'rgba(255,255,255,.28)');
|
|
845
|
+
dot.position.set(i * (2 * r + gap), 0);
|
|
846
|
+
c.addChild(dot);
|
|
847
|
+
}
|
|
848
|
+
return c;
|
|
849
|
+
}
|
|
850
|
+
|
|
675
851
|
function thumbNode(host: PixiComponentContext, bonus: BonusOption, accent: string, h: number): Container {
|
|
676
852
|
const c = new Container();
|
|
677
853
|
c.addChild(rectHit(h, h, 0)); // size anchor (transparent)
|
|
@@ -37,10 +37,15 @@ export function openGameInfo(host: PixiComponentContext): ShellLayer {
|
|
|
37
37
|
|
|
38
38
|
function buildBody(host: PixiComponentContext, width: number): Container {
|
|
39
39
|
const col = new FlexBox({ direction: 'column', align: 'stretch', gap: 12 });
|
|
40
|
-
const
|
|
41
|
-
// Auto-inject a hotkeys section unless the game already provides one
|
|
40
|
+
const allSections = host.config.gameInfo.sections ?? [];
|
|
41
|
+
// Auto-inject a hotkeys section unless the game already provides one. With hotkeys off — a
|
|
42
|
+
// jurisdiction that forbids them, or a touchscreen that has no keys at all (see core/device.ts) —
|
|
43
|
+
// there is no keyboard surface to document, and a game-supplied section is dropped along with the
|
|
44
|
+
// auto-injected one: a keycap chart for keys the player cannot press is a promise the game breaks.
|
|
45
|
+
const keys = host.config.features.hotkeys !== false;
|
|
46
|
+
const rawSections = keys ? allSections : allSections.filter((s) => s.type !== 'hotkeys');
|
|
42
47
|
const sectionsWithHotkeys: GameInfoSection[] = [...rawSections];
|
|
43
|
-
if (
|
|
48
|
+
if (keys && !rawSections.some((s) => s.type === 'hotkeys')) {
|
|
44
49
|
sectionsWithHotkeys.push({ type: 'hotkeys', order: HOTKEYS_DEFAULT_ORDER });
|
|
45
50
|
}
|
|
46
51
|
const sections = sectionsWithHotkeys;
|