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