@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/dist/pixi.cjs.js
CHANGED
|
@@ -1692,9 +1692,41 @@ class KeyboardController {
|
|
|
1692
1692
|
}
|
|
1693
1693
|
}
|
|
1694
1694
|
|
|
1695
|
+
/**
|
|
1696
|
+
* Can the player in front of this client actually press a key?
|
|
1697
|
+
*
|
|
1698
|
+
* The shell documents its shortcuts in a Hotkeys section and binds Spacebar to spin. On a phone
|
|
1699
|
+
* neither is reachable, and showing a keycap chart to someone holding a touchscreen is a promise
|
|
1700
|
+
* the game can't keep — a certification lab reads it as a feature offered where it doesn't work.
|
|
1701
|
+
*
|
|
1702
|
+
* The question is deliberately NOT "is the layout narrow" (a portrait desktop window still has a
|
|
1703
|
+
* keyboard) and NOT "is there a touchscreen" (a touch laptop has both). It is: what does the
|
|
1704
|
+
* PRIMARY pointer look like, and can it hover? Coarse-and-hoverless is a touchscreen, and a
|
|
1705
|
+
* touchscreen is the one case where the keys genuinely aren't there.
|
|
1706
|
+
*
|
|
1707
|
+
* A tablet with a keyboard case answers "coarse, no hover" too and loses the chart. That is the
|
|
1708
|
+
* right side to be wrong on: the chart is a convenience, and the keys keep working for anyone who
|
|
1709
|
+
* has them — the media query only decides what the shell ADVERTISES (hosts can still say outright,
|
|
1710
|
+
* via `features.hotkeys`, and the platform's own `device` field does exactly that).
|
|
1711
|
+
*/
|
|
1712
|
+
/** A touchscreen: the primary pointer is a finger, and nothing can hover. */
|
|
1713
|
+
const TOUCH_ONLY = '(pointer: coarse) and (hover: none)';
|
|
1714
|
+
function keyboardCapable(win = typeof window === 'undefined' ? undefined : window) {
|
|
1715
|
+
// No window (SSR, node tests) or a browser too old for matchMedia: assume a keyboard rather than
|
|
1716
|
+
// silently stripping shortcuts from a desktop we simply failed to measure.
|
|
1717
|
+
if (typeof win?.matchMedia !== 'function')
|
|
1718
|
+
return true;
|
|
1719
|
+
try {
|
|
1720
|
+
return !win.matchMedia(TOUCH_ONLY).matches;
|
|
1721
|
+
}
|
|
1722
|
+
catch {
|
|
1723
|
+
return true;
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1695
1727
|
// AUTO-GENERATED by scripts/gen-version.mjs — do not edit. Mirrors package.json "version".
|
|
1696
1728
|
/** The @energy8platform/shell package version, stamped into the game-info footer. */
|
|
1697
|
-
const PACKAGE_VERSION = '0.
|
|
1729
|
+
const PACKAGE_VERSION = '0.9.0';
|
|
1698
1730
|
|
|
1699
1731
|
/** Apply defaults to the raw config (the mount target lives on the renderer, not here). */
|
|
1700
1732
|
function resolveConfig(config) {
|
|
@@ -1708,7 +1740,10 @@ function resolveConfig(config) {
|
|
|
1708
1740
|
win: config.win,
|
|
1709
1741
|
mode: config.mode,
|
|
1710
1742
|
gameInfo: config.gameInfo,
|
|
1711
|
-
|
|
1743
|
+
// `hotkeys` unset means "decide for me": a touchscreen has no keys to press, so the shell
|
|
1744
|
+
// neither binds them nor advertises them there. A host that knows better — the platform's own
|
|
1745
|
+
// `device` field, a jurisdiction rule — says so outright and that wins. See core/device.ts.
|
|
1746
|
+
features: { ...config.features, hotkeys: config.features.hotkeys ?? keyboardCapable() },
|
|
1712
1747
|
theme: config.theme,
|
|
1713
1748
|
onBonusBuy: config.onBonusBuy,
|
|
1714
1749
|
volumes: config.volumes,
|
|
@@ -1802,7 +1837,11 @@ class ShellController extends EventEmitter {
|
|
|
1802
1837
|
this.renderer.renderBar();
|
|
1803
1838
|
},
|
|
1804
1839
|
toggleAutoplay: () => {
|
|
1805
|
-
|
|
1840
|
+
// A halted run (stopped, but with spins still owed after a lost connection) counts as
|
|
1841
|
+
// "autoplay is on screen": the toggle retires its leftover count, exactly as it stops a
|
|
1842
|
+
// running one. Resuming those spins is the disc's job, not this one's.
|
|
1843
|
+
const { active, remaining } = this.state.autoplay;
|
|
1844
|
+
if (active || remaining > 0)
|
|
1806
1845
|
a.stopAutoplay();
|
|
1807
1846
|
else
|
|
1808
1847
|
this.openAutoplayPicker();
|
|
@@ -3089,6 +3128,7 @@ class SpinDisc extends pixi_js.Container {
|
|
|
3089
3128
|
rotTick;
|
|
3090
3129
|
onSpin;
|
|
3091
3130
|
onStop;
|
|
3131
|
+
onResume;
|
|
3092
3132
|
constructor(opts) {
|
|
3093
3133
|
super();
|
|
3094
3134
|
this.size = opts.size ?? 84;
|
|
@@ -3097,6 +3137,7 @@ class SpinDisc extends pixi_js.Container {
|
|
|
3097
3137
|
this.ticker = opts.ticker;
|
|
3098
3138
|
this.onSpin = opts.onSpin;
|
|
3099
3139
|
this.onStop = opts.onStop;
|
|
3140
|
+
this.onResume = opts.onResume ?? opts.onSpin;
|
|
3100
3141
|
this.disc = new pixi_js.Graphics();
|
|
3101
3142
|
this.glyph = makeIcon('spin', this.glyphSize, this.tokens.btnInk);
|
|
3102
3143
|
this.glyph.position.set((this.size - this.glyphSize) / 2, (this.size - this.glyphSize) / 2);
|
|
@@ -3117,6 +3158,8 @@ class SpinDisc extends pixi_js.Container {
|
|
|
3117
3158
|
return;
|
|
3118
3159
|
if (this.mode === 'stop')
|
|
3119
3160
|
this.onStop();
|
|
3161
|
+
else if (this.mode === 'resume')
|
|
3162
|
+
this.onResume();
|
|
3120
3163
|
else
|
|
3121
3164
|
this.onSpin();
|
|
3122
3165
|
});
|
|
@@ -3128,9 +3171,10 @@ class SpinDisc extends pixi_js.Container {
|
|
|
3128
3171
|
drawDisc(this.disc, this.size, this.tokens.btn, 4);
|
|
3129
3172
|
const glyphColor = hot ? this.tokens.accent : this.tokens.btnInk;
|
|
3130
3173
|
this.glyph.setColor(glyphColor);
|
|
3131
|
-
// the count sits ON the solid (btnInk) STOP square, so it must be light to read
|
|
3174
|
+
// the count sits ON the solid (btnInk) STOP square, so it must be light to read; halted, it
|
|
3175
|
+
// sits on the bare disc instead and takes the disc's own ink.
|
|
3132
3176
|
if (this.countText)
|
|
3133
|
-
this.countText.style.fill = '#ffffff';
|
|
3177
|
+
this.countText.style.fill = this.mode === 'resume' ? this.tokens.btnInk : '#ffffff';
|
|
3134
3178
|
// Disabled (mid-spin / can't spin): darken OPAQUELY (≈ filter:grayscale(.4) brightness(.62))
|
|
3135
3179
|
// with a dark veil over the disc — not alpha, which would let the bright board show through and
|
|
3136
3180
|
// read as a translucent/missing button (what looked "transparent" while spinning).
|
|
@@ -3139,32 +3183,45 @@ class SpinDisc extends pixi_js.Container {
|
|
|
3139
3183
|
this.dim.circle(this.size / 2, this.size / 2, this.size / 2 - 1.5).fill({ color: 0x000000, alpha: 0.4 });
|
|
3140
3184
|
}
|
|
3141
3185
|
}
|
|
3142
|
-
/** STOP glyph + remaining-count
|
|
3143
|
-
setAutoplay(
|
|
3144
|
-
if (
|
|
3145
|
-
this.mode = 'stop';
|
|
3146
|
-
this.stopRotation();
|
|
3147
|
-
// STOP glyph at the disc's full size (like SPIN); the count is centred on top of it.
|
|
3148
|
-
this.glyph.visible = false;
|
|
3149
|
-
this.stopGlyph();
|
|
3150
|
-
if (!this.countText) {
|
|
3151
|
-
this.countText = makeText('', { size: 22, weight: '800', color: '#ffffff', align: 'center', family: NUM_FONT_FAMILY });
|
|
3152
|
-
this.addChild(this.countText); // added after the STOP glyph → renders on top of it
|
|
3153
|
-
}
|
|
3154
|
-
const label = Number.isFinite(remaining) ? String(remaining) : '∞';
|
|
3155
|
-
setText(this.countText, label);
|
|
3156
|
-
this.countText.position.set((this.size - this.countText.width) / 2, (this.size - this.countText.height) / 2);
|
|
3157
|
-
}
|
|
3158
|
-
else {
|
|
3186
|
+
/** STOP glyph + remaining-count while autoplay runs; auto glyph + the same count once it halts. */
|
|
3187
|
+
setAutoplay(mode, remaining) {
|
|
3188
|
+
if (mode === 'off') {
|
|
3159
3189
|
this.mode = 'spin';
|
|
3160
3190
|
this.glyph.visible = true;
|
|
3161
3191
|
this.removeStopGlyph();
|
|
3192
|
+
this.removeAutoGlyph();
|
|
3162
3193
|
if (this.countText) {
|
|
3163
3194
|
this.removeChild(this.countText);
|
|
3164
3195
|
this.countText.destroy();
|
|
3165
3196
|
this.countText = undefined;
|
|
3166
3197
|
}
|
|
3198
|
+
this.paint();
|
|
3199
|
+
return;
|
|
3167
3200
|
}
|
|
3201
|
+
const running = mode === 'running';
|
|
3202
|
+
this.mode = running ? 'stop' : 'resume';
|
|
3203
|
+
this.stopRotation();
|
|
3204
|
+
this.glyph.visible = false;
|
|
3205
|
+
// Running: a solid STOP square at the disc's full size (like SPIN), count centred on top of it.
|
|
3206
|
+
// Halted: the auto glyph, smaller and lifted, with the count under it — no dark square, because
|
|
3207
|
+
// nothing is running to stop, and a white count would have nothing to read against.
|
|
3208
|
+
if (running) {
|
|
3209
|
+
this.removeAutoGlyph();
|
|
3210
|
+
this.stopGlyph();
|
|
3211
|
+
}
|
|
3212
|
+
else {
|
|
3213
|
+
this.removeStopGlyph();
|
|
3214
|
+
this.autoGlyph();
|
|
3215
|
+
}
|
|
3216
|
+
if (!this.countText) {
|
|
3217
|
+
this.countText = makeText('', { size: 22, weight: '800', color: '#ffffff', align: 'center', family: NUM_FONT_FAMILY });
|
|
3218
|
+
this.addChild(this.countText); // added after the glyph → renders on top of it
|
|
3219
|
+
}
|
|
3220
|
+
setText(this.countText, Number.isFinite(remaining) ? String(remaining) : '∞');
|
|
3221
|
+
const cy = running
|
|
3222
|
+
? (this.size - this.countText.height) / 2
|
|
3223
|
+
: this.size * 0.56; // sits under the lifted auto glyph
|
|
3224
|
+
this.countText.position.set((this.size - this.countText.width) / 2, cy);
|
|
3168
3225
|
this.paint();
|
|
3169
3226
|
}
|
|
3170
3227
|
stopGlyphView;
|
|
@@ -3182,6 +3239,22 @@ class SpinDisc extends pixi_js.Container {
|
|
|
3182
3239
|
this.stopGlyphView = undefined;
|
|
3183
3240
|
}
|
|
3184
3241
|
}
|
|
3242
|
+
autoGlyphView;
|
|
3243
|
+
autoGlyph() {
|
|
3244
|
+
if (this.autoGlyphView)
|
|
3245
|
+
return;
|
|
3246
|
+
const size = this.glyphSize * 0.42;
|
|
3247
|
+
this.autoGlyphView = makeIcon('autoplay', size, this.tokens.btnInk);
|
|
3248
|
+
this.autoGlyphView.position.set((this.size - size) / 2, this.size * 0.2);
|
|
3249
|
+
this.addChild(this.autoGlyphView);
|
|
3250
|
+
}
|
|
3251
|
+
removeAutoGlyph() {
|
|
3252
|
+
if (this.autoGlyphView) {
|
|
3253
|
+
this.removeChild(this.autoGlyphView);
|
|
3254
|
+
this.autoGlyphView.destroy();
|
|
3255
|
+
this.autoGlyphView = undefined;
|
|
3256
|
+
}
|
|
3257
|
+
}
|
|
3185
3258
|
setBusy(busy) {
|
|
3186
3259
|
this._busy = busy;
|
|
3187
3260
|
if (busy && this.mode === 'spin')
|
|
@@ -3412,6 +3485,13 @@ function divider(tokens, height = 30) {
|
|
|
3412
3485
|
}
|
|
3413
3486
|
|
|
3414
3487
|
// ── design constants (mirror the DOM `.ge-bar-panel` / mobile rules) ──────────
|
|
3488
|
+
/** Which of the disc's three faces the autoplay state calls for — see `SpinAutoplayMode`. A run
|
|
3489
|
+
* that halted with spins still owed (`!active && remaining > 0`) keeps its counter on the disc. */
|
|
3490
|
+
function autoplayDiscMode(state) {
|
|
3491
|
+
if (state.autoplay.active)
|
|
3492
|
+
return 'running';
|
|
3493
|
+
return state.autoplay.remaining > 0 ? 'paused' : 'off';
|
|
3494
|
+
}
|
|
3415
3495
|
const BAR_H = 68; // continuous dark panel height
|
|
3416
3496
|
const SPIN = 84; // hero disc — pops above/below the bar
|
|
3417
3497
|
const SPIN_POP = (SPIN - BAR_H) / 2; // 8 — how far the disc sticks out top/bottom
|
|
@@ -3660,9 +3740,9 @@ class BottomBar extends pixi_js.Container {
|
|
|
3660
3740
|
ticker: this.host.ticker,
|
|
3661
3741
|
onSpin: () => this.host.actions.spin(),
|
|
3662
3742
|
onStop: () => this.stopAutoplay(),
|
|
3743
|
+
onResume: () => this.resumeAutoplay(),
|
|
3663
3744
|
});
|
|
3664
|
-
|
|
3665
|
-
this.spin.setAutoplay(true, state.autoplay.remaining);
|
|
3745
|
+
this.spin.setAutoplay(autoplayDiscMode(state), state.autoplay.remaining);
|
|
3666
3746
|
if (state.busy)
|
|
3667
3747
|
this.spin.setBusy(true);
|
|
3668
3748
|
spinWrap.add(this.spin);
|
|
@@ -3734,9 +3814,9 @@ class BottomBar extends pixi_js.Container {
|
|
|
3734
3814
|
ticker: this.host.ticker,
|
|
3735
3815
|
onSpin: () => this.host.actions.spin(),
|
|
3736
3816
|
onStop: () => this.stopAutoplay(),
|
|
3817
|
+
onResume: () => this.resumeAutoplay(),
|
|
3737
3818
|
});
|
|
3738
|
-
|
|
3739
|
-
this.spin.setAutoplay(true, state.autoplay.remaining);
|
|
3819
|
+
this.spin.setAutoplay(autoplayDiscMode(state), state.autoplay.remaining);
|
|
3740
3820
|
if (state.busy)
|
|
3741
3821
|
this.spin.setBusy(true);
|
|
3742
3822
|
hero = this.spin;
|
|
@@ -3931,8 +4011,11 @@ class BottomBar extends pixi_js.Container {
|
|
|
3931
4011
|
onTurbo() {
|
|
3932
4012
|
this.host.actions.cycleTurbo();
|
|
3933
4013
|
}
|
|
4014
|
+
/** Same button, three jobs: stop a running run, retire a halted run's leftover count (freeing the
|
|
4015
|
+
* disc for a manual spin again), or open the picker. Mirrors the DOM bar's `onAutoplay`. */
|
|
3934
4016
|
onAutoplay() {
|
|
3935
|
-
|
|
4017
|
+
const { active, remaining } = this.host.state.autoplay;
|
|
4018
|
+
if (active || remaining > 0)
|
|
3936
4019
|
this.stopAutoplay();
|
|
3937
4020
|
else
|
|
3938
4021
|
this.host.actions.openAutoplayPicker();
|
|
@@ -3940,6 +4023,10 @@ class BottomBar extends pixi_js.Container {
|
|
|
3940
4023
|
stopAutoplay() {
|
|
3941
4024
|
this.host.actions.stopAutoplay();
|
|
3942
4025
|
}
|
|
4026
|
+
/** Halted run, tapped: play out the spins it still owes. */
|
|
4027
|
+
resumeAutoplay() {
|
|
4028
|
+
this.host.actions.startAutoplay(this.host.state.autoplay.remaining);
|
|
4029
|
+
}
|
|
3943
4030
|
betLocked() {
|
|
3944
4031
|
return this.host.state.busy || this.host.state.autoplay.active;
|
|
3945
4032
|
}
|
|
@@ -4826,10 +4913,15 @@ function openGameInfo(host) {
|
|
|
4826
4913
|
}
|
|
4827
4914
|
function buildBody(host, width) {
|
|
4828
4915
|
const col = new FlexBox({ direction: 'column', align: 'stretch', gap: 12 });
|
|
4829
|
-
const
|
|
4830
|
-
// Auto-inject a hotkeys section unless the game already provides one
|
|
4916
|
+
const allSections = host.config.gameInfo.sections ?? [];
|
|
4917
|
+
// Auto-inject a hotkeys section unless the game already provides one. With hotkeys off — a
|
|
4918
|
+
// jurisdiction that forbids them, or a touchscreen that has no keys at all (see core/device.ts) —
|
|
4919
|
+
// there is no keyboard surface to document, and a game-supplied section is dropped along with the
|
|
4920
|
+
// auto-injected one: a keycap chart for keys the player cannot press is a promise the game breaks.
|
|
4921
|
+
const keys = host.config.features.hotkeys !== false;
|
|
4922
|
+
const rawSections = keys ? allSections : allSections.filter((s) => s.type !== 'hotkeys');
|
|
4831
4923
|
const sectionsWithHotkeys = [...rawSections];
|
|
4832
|
-
if (
|
|
4924
|
+
if (keys && !rawSections.some((s) => s.type === 'hotkeys')) {
|
|
4833
4925
|
sectionsWithHotkeys.push({ type: 'hotkeys', order: HOTKEYS_DEFAULT_ORDER });
|
|
4834
4926
|
}
|
|
4835
4927
|
const sections = sectionsWithHotkeys;
|
|
@@ -5379,6 +5471,34 @@ function imageBox(url, w, h) {
|
|
|
5379
5471
|
return c;
|
|
5380
5472
|
}
|
|
5381
5473
|
|
|
5474
|
+
/** Collapse buy-bonus options that share a `groupedBy` key into one slot. A slot with more than one
|
|
5475
|
+
* member is rendered by the pixi shell as a single card with arrows (the DOM shell ignores the key
|
|
5476
|
+
* and keeps a card per option).
|
|
5477
|
+
*
|
|
5478
|
+
* A group takes the position of its FIRST member, so the strip order stays predictable however the
|
|
5479
|
+
* members are scattered through the array. Options with a game-supplied `custom` renderer never
|
|
5480
|
+
* join a group — the shell has no layout for their interior and could not place arrows over it. */
|
|
5481
|
+
function groupBonusSlots(bonuses) {
|
|
5482
|
+
const slots = [];
|
|
5483
|
+
const byKey = new Map();
|
|
5484
|
+
for (const bonus of bonuses) {
|
|
5485
|
+
const key = bonus.groupedBy;
|
|
5486
|
+
if (!key || bonus.custom) {
|
|
5487
|
+
slots.push([bonus]);
|
|
5488
|
+
continue;
|
|
5489
|
+
}
|
|
5490
|
+
const open = byKey.get(key);
|
|
5491
|
+
if (open) {
|
|
5492
|
+
open.push(bonus);
|
|
5493
|
+
continue;
|
|
5494
|
+
}
|
|
5495
|
+
const slot = [bonus];
|
|
5496
|
+
byKey.set(key, slot);
|
|
5497
|
+
slots.push(slot);
|
|
5498
|
+
}
|
|
5499
|
+
return slots;
|
|
5500
|
+
}
|
|
5501
|
+
|
|
5382
5502
|
/** Below this frame height (px), a wide/landscape popout (e.g. Popout S 400×225) stacks its cards
|
|
5383
5503
|
* vertically and scrolls — like mobile — so descriptions stay readable instead of shrinking to a
|
|
5384
5504
|
* ~4px floor. Wide + taller frames (Popout L and up) keep the centred horizontal row. */
|
|
@@ -5412,6 +5532,9 @@ class BuyBonusOverlay extends pixi_js.Container {
|
|
|
5412
5532
|
cardEntries = [];
|
|
5413
5533
|
/** Keyboard focus index into the affordable subset of cardEntries. -1 = none. */
|
|
5414
5534
|
focusIndex = -1;
|
|
5535
|
+
/** Which member each `groupedBy` slot is showing, kept across rebuilds (bet steps, resizes) so a
|
|
5536
|
+
* flipped-to variant doesn't snap back to the first one under the player. */
|
|
5537
|
+
groupIndex = new Map();
|
|
5415
5538
|
/** true when cards are stacked vertically (mobile, or a short landscape popout). */
|
|
5416
5539
|
stack = false;
|
|
5417
5540
|
// Drag-scroll state. The drag is handled on the (unmasked) overlay, not the masked strip — a mask
|
|
@@ -5530,7 +5653,9 @@ class BuyBonusOverlay extends pixi_js.Container {
|
|
|
5530
5653
|
const top = this.headerH + 6;
|
|
5531
5654
|
const areaH = this.h - top - this.footerH - 6;
|
|
5532
5655
|
const gap = 14;
|
|
5533
|
-
|
|
5656
|
+
// Grouped options share ONE slot (a carousel card), so the width fit counts SLOTS, not options.
|
|
5657
|
+
const slots = groupBonusSlots(this.bonuses);
|
|
5658
|
+
const n = Math.max(1, slots.length);
|
|
5534
5659
|
// The whole card is laid out in `em`; pick the largest em that fits BOTH dimensions of the
|
|
5535
5660
|
// available area, so the card is always fully visible (no horizontal clip, CTA never under the
|
|
5536
5661
|
// footer). emH keeps the card height within the band between header and footer; emW makes the
|
|
@@ -5542,16 +5667,44 @@ class BuyBonusOverlay extends pixi_js.Container {
|
|
|
5542
5667
|
: (this.w - 48 - (n - 1) * gap) / (18 * n); // row: N cards + gaps fit the frame width
|
|
5543
5668
|
const em = stack ? Math.min(12, emW) : clamp(4, Math.min(emH, emW), 12);
|
|
5544
5669
|
const cardW = Math.min(18 * em, this.w - 48);
|
|
5545
|
-
const cards =
|
|
5670
|
+
const cards = [];
|
|
5671
|
+
this.cardEntries = [];
|
|
5672
|
+
for (const slot of slots) {
|
|
5673
|
+
if (slot.length === 1) {
|
|
5674
|
+
const view = this.buildCard(slot[0], cardW, em, stack, areaH);
|
|
5675
|
+
cards.push(view);
|
|
5676
|
+
this.cardEntries.push({ view, bonus: slot[0], affordable: this.isAffordable(slot[0]) });
|
|
5677
|
+
continue;
|
|
5678
|
+
}
|
|
5679
|
+
// Carousel slot: every member is built (the slot sizes to the tallest and flipping costs
|
|
5680
|
+
// nothing), but only the current one is visible.
|
|
5681
|
+
const key = slot[0].groupedBy;
|
|
5682
|
+
// Flipping the card also moves keyboard focus onto the shown member: one cursor, not two
|
|
5683
|
+
// (otherwise the next rebuild would snap the card back to whatever the focus still pointed at).
|
|
5684
|
+
const group = new GroupCard((i) => {
|
|
5685
|
+
this.groupIndex.set(key, i);
|
|
5686
|
+
this.focusShownMember(group, i);
|
|
5687
|
+
});
|
|
5688
|
+
const members = slot.map((b, i) => this.buildCard(b, cardW, em, stack, areaH, {
|
|
5689
|
+
index: i,
|
|
5690
|
+
count: slot.length,
|
|
5691
|
+
prevLabel: `bb-nav-prev:${key}`,
|
|
5692
|
+
nextLabel: `bb-nav-next:${key}`,
|
|
5693
|
+
onStep: (dir) => {
|
|
5694
|
+
if (this.dragged)
|
|
5695
|
+
return; // a scroll gesture, not a tap
|
|
5696
|
+
group.step(dir);
|
|
5697
|
+
},
|
|
5698
|
+
}));
|
|
5699
|
+
group.setCards(members, this.groupIndex.get(key) ?? 0);
|
|
5700
|
+
cards.push(group);
|
|
5701
|
+
for (let i = 0; i < slot.length; i++) {
|
|
5702
|
+
this.cardEntries.push({ view: members[i], bonus: slot[i], affordable: this.isAffordable(slot[i]), group });
|
|
5703
|
+
}
|
|
5704
|
+
}
|
|
5546
5705
|
const cardH = Math.max(...cards.map((c) => c.height));
|
|
5547
5706
|
for (const c of cards)
|
|
5548
5707
|
c.setHeight(cardH);
|
|
5549
|
-
// Rebuild card entries for keyboard navigation
|
|
5550
|
-
this.cardEntries = this.bonuses.map((b, i) => ({
|
|
5551
|
-
view: cards[i],
|
|
5552
|
-
bonus: b,
|
|
5553
|
-
affordable: this.isAffordable(b),
|
|
5554
|
-
}));
|
|
5555
5708
|
// Restore or init keyboard focus on the first affordable card
|
|
5556
5709
|
const affordable = this.cardEntries.filter((e) => e.affordable);
|
|
5557
5710
|
if (affordable.length > 0) {
|
|
@@ -5592,7 +5745,7 @@ class BuyBonusOverlay extends pixi_js.Container {
|
|
|
5592
5745
|
}
|
|
5593
5746
|
}
|
|
5594
5747
|
// ── one card ──────────────────────────────────────────────────────────────
|
|
5595
|
-
buildCard(bonus, cardW, em, stack, areaH) {
|
|
5748
|
+
buildCard(bonus, cardW, em, stack, areaH, nav) {
|
|
5596
5749
|
const accent = effectiveAccent(bonus);
|
|
5597
5750
|
const ink = contrastText(accent);
|
|
5598
5751
|
const price = bonus.priceMultiplier * this.host.state.bet;
|
|
@@ -5628,6 +5781,7 @@ class BuyBonusOverlay extends pixi_js.Container {
|
|
|
5628
5781
|
enabled,
|
|
5629
5782
|
ctaLabel: this.host.t(bonus.type === 'feature' ? 'Activate' : 'Buy'),
|
|
5630
5783
|
onSelect: select,
|
|
5784
|
+
nav,
|
|
5631
5785
|
});
|
|
5632
5786
|
return card;
|
|
5633
5787
|
}
|
|
@@ -5746,7 +5900,7 @@ class BuyBonusOverlay extends pixi_js.Container {
|
|
|
5746
5900
|
// the buttons keep the card silhouette without a mask blocking their pointer events.
|
|
5747
5901
|
const half = cardW / 2;
|
|
5748
5902
|
const r = 1.3 * em;
|
|
5749
|
-
card.addChild(footerButton(this.host, this.host.t('Cancel'), 'ghost', half, ctaH, 0, y, () => this.removeConfirm(), undefined, r, 0), footerButton(this.host, this.host.t(bonus.type === 'feature' ? 'Activate' : 'Buy'), accent, half, ctaH, half, y, () => {
|
|
5903
|
+
card.addChild(footerButton(this.host, this.host.t('Cancel'), 'ghost', half, ctaH, 0, y, () => this.removeConfirm(), undefined, r, 0), labelled('bb-confirm-ok', footerButton(this.host, this.host.t(bonus.type === 'feature' ? 'Activate' : 'Buy'), accent, half, ctaH, half, y, () => {
|
|
5750
5904
|
if (!this.isAffordable(bonus))
|
|
5751
5905
|
return;
|
|
5752
5906
|
if (bonus.type === 'feature')
|
|
@@ -5754,7 +5908,7 @@ class BuyBonusOverlay extends pixi_js.Container {
|
|
|
5754
5908
|
else
|
|
5755
5909
|
this.host.actions.selectBuyBonus(bonus.id);
|
|
5756
5910
|
this.host.closeLayer();
|
|
5757
|
-
}, ink, 0, r));
|
|
5911
|
+
}, ink, 0, r)));
|
|
5758
5912
|
card.position.set((this.w - cardW) / 2, (this.h - cardH) / 2);
|
|
5759
5913
|
layer.addChild(card);
|
|
5760
5914
|
// close X pinned to screen corner
|
|
@@ -5780,14 +5934,28 @@ class BuyBonusOverlay extends pixi_js.Container {
|
|
|
5780
5934
|
this.buildCards();
|
|
5781
5935
|
this.buildFooter();
|
|
5782
5936
|
}
|
|
5937
|
+
/** Move keyboard focus onto the member a carousel slot just flipped to. Skipped when that member
|
|
5938
|
+
* can't be bought at the current bet — focus only ever sits on affordable cards. */
|
|
5939
|
+
focusShownMember(group, index) {
|
|
5940
|
+
const card = group.cardAt(index);
|
|
5941
|
+
const at = this.cardEntries.filter((ce) => ce.affordable).findIndex((ce) => ce.group === group && ce.view === card);
|
|
5942
|
+
if (at < 0)
|
|
5943
|
+
return;
|
|
5944
|
+
this.focusIndex = at;
|
|
5945
|
+
this.applyFocusRing();
|
|
5946
|
+
}
|
|
5783
5947
|
/** Apply or clear the focus ring on affordable cards. */
|
|
5784
5948
|
applyFocusRing() {
|
|
5785
5949
|
const affordable = this.cardEntries.filter((ce) => ce.affordable);
|
|
5786
5950
|
for (let i = 0; i < affordable.length; i++) {
|
|
5787
|
-
const
|
|
5788
|
-
|
|
5789
|
-
|
|
5790
|
-
|
|
5951
|
+
const entry = affordable[i];
|
|
5952
|
+
const focused = i === this.focusIndex;
|
|
5953
|
+
// Focusing a member of a carousel slot brings it to the front — the keyboard walks options,
|
|
5954
|
+
// and the card follows.
|
|
5955
|
+
if (focused && entry.group)
|
|
5956
|
+
entry.group.showCard(entry.view);
|
|
5957
|
+
if (entry.view instanceof BonusCard)
|
|
5958
|
+
entry.view.setFocused(focused);
|
|
5791
5959
|
}
|
|
5792
5960
|
}
|
|
5793
5961
|
/** Two-phase keyboard handler.
|
|
@@ -5896,6 +6064,55 @@ class CustomCard {
|
|
|
5896
6064
|
this.height = total; // the game owns its own layout; we only record the slot height
|
|
5897
6065
|
}
|
|
5898
6066
|
}
|
|
6067
|
+
/** One slot shared by several options (`groupedBy`) — e.g. four same-priced Ante characters. Every
|
|
6068
|
+
* member card is built (the slot sizes to the tallest, and flipping is then just a visibility
|
|
6069
|
+
* swap, so the card never resizes under the player); one is visible at a time. */
|
|
6070
|
+
class GroupCard {
|
|
6071
|
+
node = new pixi_js.Container();
|
|
6072
|
+
height = 0;
|
|
6073
|
+
cards = [];
|
|
6074
|
+
index = 0;
|
|
6075
|
+
/** Reports the shown member back to the overlay, which keeps it across rebuilds. */
|
|
6076
|
+
onIndex;
|
|
6077
|
+
constructor(onIndex) {
|
|
6078
|
+
this.onIndex = onIndex;
|
|
6079
|
+
}
|
|
6080
|
+
setCards(cards, index) {
|
|
6081
|
+
this.cards = cards;
|
|
6082
|
+
for (const c of cards)
|
|
6083
|
+
this.node.addChild(c.node);
|
|
6084
|
+
this.height = Math.max(...cards.map((c) => c.height));
|
|
6085
|
+
this.show(index, false); // restoring a remembered position is not a player action
|
|
6086
|
+
}
|
|
6087
|
+
/** Flip to member `i`, wrapping around in both directions. */
|
|
6088
|
+
show(i, notify = true) {
|
|
6089
|
+
const n = this.cards.length;
|
|
6090
|
+
if (n === 0)
|
|
6091
|
+
return;
|
|
6092
|
+
this.index = ((i % n) + n) % n;
|
|
6093
|
+
for (let k = 0; k < n; k++)
|
|
6094
|
+
this.cards[k].node.visible = k === this.index;
|
|
6095
|
+
if (notify)
|
|
6096
|
+
this.onIndex(this.index);
|
|
6097
|
+
}
|
|
6098
|
+
step(dir) {
|
|
6099
|
+
this.show(this.index + dir);
|
|
6100
|
+
}
|
|
6101
|
+
cardAt(index) {
|
|
6102
|
+
return this.cards[index];
|
|
6103
|
+
}
|
|
6104
|
+
/** Bring a specific member's card to the front (keyboard focus landing on that option). */
|
|
6105
|
+
showCard(card) {
|
|
6106
|
+
const i = this.cards.findIndex((c) => c === card);
|
|
6107
|
+
if (i >= 0 && i !== this.index)
|
|
6108
|
+
this.show(i);
|
|
6109
|
+
}
|
|
6110
|
+
setHeight(total) {
|
|
6111
|
+
this.height = total;
|
|
6112
|
+
for (const c of this.cards)
|
|
6113
|
+
c.setHeight(total);
|
|
6114
|
+
}
|
|
6115
|
+
}
|
|
5899
6116
|
class BonusCard {
|
|
5900
6117
|
node = new pixi_js.Container();
|
|
5901
6118
|
height = 0;
|
|
@@ -5917,9 +6134,22 @@ class BonusCard {
|
|
|
5917
6134
|
const volColor = opts.enabled ? accent : 'rgba(255,255,255,.4)';
|
|
5918
6135
|
const top = new pixi_js.Container();
|
|
5919
6136
|
let y = 1.25 * em;
|
|
5920
|
-
|
|
6137
|
+
// With arrows the title has to clear them on both sides, or a long name wraps under the glyphs.
|
|
6138
|
+
const arrowSz = 1.5 * em;
|
|
6139
|
+
const arrowPad = 0.45 * em;
|
|
6140
|
+
const titleW = opts.nav ? innerW - 2 * (arrowSz + 2 * arrowPad) : innerW;
|
|
6141
|
+
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 });
|
|
5921
6142
|
title.position.set((cardW - title.width) / 2, y);
|
|
5922
6143
|
top.addChild(title);
|
|
6144
|
+
if (opts.nav) {
|
|
6145
|
+
const boxSz = arrowSz + 2 * arrowPad;
|
|
6146
|
+
const rowY = y + (title.height - boxSz) / 2;
|
|
6147
|
+
const prev = arrowButton(opts.nav.prevLabel, -1, arrowSz, arrowPad, accent, opts.nav.onStep);
|
|
6148
|
+
const next = arrowButton(opts.nav.nextLabel, 1, arrowSz, arrowPad, accent, opts.nav.onStep);
|
|
6149
|
+
prev.position.set(0.6 * em, rowY);
|
|
6150
|
+
next.position.set(cardW - 0.6 * em - boxSz, rowY);
|
|
6151
|
+
top.addChild(prev, next);
|
|
6152
|
+
}
|
|
5923
6153
|
y += title.height + 0.75 * em;
|
|
5924
6154
|
const thumb = thumbNode(host, bonus, accent, 6.2 * em);
|
|
5925
6155
|
thumb.position.set((cardW - 6.2 * em) / 2, y);
|
|
@@ -5929,6 +6159,13 @@ class BonusCard {
|
|
|
5929
6159
|
desc.position.set((cardW - desc.width) / 2, y);
|
|
5930
6160
|
top.addChild(desc);
|
|
5931
6161
|
y += desc.height;
|
|
6162
|
+
if (opts.nav) {
|
|
6163
|
+
y += 0.7 * em;
|
|
6164
|
+
const dots = dotRow(opts.nav.count, opts.nav.index, accent, 0.24 * em, 0.5 * em);
|
|
6165
|
+
dots.position.set((cardW - dots.width) / 2, y);
|
|
6166
|
+
top.addChild(dots);
|
|
6167
|
+
y += 0.48 * em;
|
|
6168
|
+
}
|
|
5932
6169
|
this.topH = y;
|
|
5933
6170
|
// bottom block: volatility + price
|
|
5934
6171
|
let by = 0;
|
|
@@ -5943,7 +6180,8 @@ class BonusCard {
|
|
|
5943
6180
|
this.bottomBlock.addChild(priceText);
|
|
5944
6181
|
by += priceText.height;
|
|
5945
6182
|
this.bottomH = by;
|
|
5946
|
-
this.cta = ctaButton(host, opts.ctaLabel, accent, opts.ink, cardW, this.ctaH, opts.enabled, opts.onSelect, 1.4 * em);
|
|
6183
|
+
this.cta = labelled(`bb-cta:${bonus.id}`, ctaButton(host, opts.ctaLabel, accent, opts.ink, cardW, this.ctaH, opts.enabled, opts.onSelect, 1.4 * em));
|
|
6184
|
+
this.node.label = `bb-card:${bonus.id}`;
|
|
5947
6185
|
this.node.addChild(this.bg, top, this.bottomBlock, this.cta);
|
|
5948
6186
|
if (opts.enabled) {
|
|
5949
6187
|
this.node.eventMode = 'static';
|
|
@@ -5994,6 +6232,43 @@ class BonusCard {
|
|
|
5994
6232
|
}
|
|
5995
6233
|
}
|
|
5996
6234
|
// ── shared card bits ─────────────────────────────────────────────────────────
|
|
6235
|
+
/** Tag a node so the shell (and its tests) can find it in the display tree. */
|
|
6236
|
+
function labelled(label, node) {
|
|
6237
|
+
node.label = label;
|
|
6238
|
+
return node;
|
|
6239
|
+
}
|
|
6240
|
+
/** One carousel arrow. `stopPropagation` keeps the tap off the card underneath, which would
|
|
6241
|
+
* otherwise open the confirm dialog for the member being flipped away from. */
|
|
6242
|
+
function arrowButton(label, dir, size, pad, accent, onStep) {
|
|
6243
|
+
const b = new pixi_js.Container();
|
|
6244
|
+
b.label = label;
|
|
6245
|
+
const glyph = makeIcon('chevronRight', size, '#ffffff');
|
|
6246
|
+
if (dir === -1)
|
|
6247
|
+
glyph.spin = Math.PI; // no chevronLeft in the set — the glyph spins about its centre
|
|
6248
|
+
glyph.position.set(pad, pad);
|
|
6249
|
+
b.addChild(rectHit(size + pad * 2, size + pad * 2), glyph);
|
|
6250
|
+
b.eventMode = 'static';
|
|
6251
|
+
b.cursor = 'pointer';
|
|
6252
|
+
b.on('pointerover', () => glyph.setColor(accent));
|
|
6253
|
+
b.on('pointerout', () => glyph.setColor('#ffffff'));
|
|
6254
|
+
b.on('pointertap', (e) => {
|
|
6255
|
+
e.stopPropagation();
|
|
6256
|
+
onStep(dir);
|
|
6257
|
+
});
|
|
6258
|
+
return b;
|
|
6259
|
+
}
|
|
6260
|
+
/** Position indicator for a carousel slot: one dot per member, the current one in the accent. */
|
|
6261
|
+
function dotRow(count, index, accent, r, gap) {
|
|
6262
|
+
const c = new pixi_js.Container();
|
|
6263
|
+
for (let i = 0; i < count; i++) {
|
|
6264
|
+
const dot = new pixi_js.Graphics();
|
|
6265
|
+
const on = i === index;
|
|
6266
|
+
dot.circle(r, r, r).fill(on ? accent : 'rgba(255,255,255,.28)');
|
|
6267
|
+
dot.position.set(i * (2 * r + gap), 0);
|
|
6268
|
+
c.addChild(dot);
|
|
6269
|
+
}
|
|
6270
|
+
return c;
|
|
6271
|
+
}
|
|
5997
6272
|
function thumbNode(host, bonus, accent, h) {
|
|
5998
6273
|
const c = new pixi_js.Container();
|
|
5999
6274
|
c.addChild(rectHit(h, h, 0)); // size anchor (transparent)
|