@energy8platform/shell 0.7.1 → 0.8.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 +1 -1
- package/dist/html.d.ts +7 -2
- package/dist/html.esm.js +1 -1
- package/dist/index.cjs.js +1 -1
- package/dist/index.d.ts +7 -2
- package/dist/index.esm.js +1 -1
- package/dist/pixi.cjs.js +226 -19
- package/dist/pixi.cjs.js.map +1 -1
- package/dist/pixi.d.ts +7 -2
- package/dist/pixi.esm.js +226 -19
- package/dist/pixi.esm.js.map +1 -1
- package/package.json +1 -1
- package/src/core/bonusGroups.ts +29 -0
- package/src/core/types.ts +5 -0
- package/src/core/version.ts +1 -1
- package/src/ui/pixi/components/BuyBonus.ts +195 -19
- package/src/ui/pixi/motion-pixi.ts +16 -1
- package/src/ui/pixi/primitives/widgets.ts +9 -0
package/package.json
CHANGED
|
@@ -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
|
+
}
|
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,
|
package/src/core/version.ts
CHANGED
|
@@ -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)
|
|
@@ -9,7 +9,22 @@ export interface TweenOpts {
|
|
|
9
9
|
onComplete?: () => void;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
/**
|
|
12
|
+
/**
|
|
13
|
+
* Tween 0→1 on the Pixi ticker. Returns a canceler. Skips to the end when motion is reduced.
|
|
14
|
+
*
|
|
15
|
+
* CONTRACT — when motion is reduced (or `duration <= 0`) this tween does NOT animate: it applies
|
|
16
|
+
* the final value and calls `onComplete` SYNCHRONOUSLY, before returning. Callers depend on that
|
|
17
|
+
* (`PixiRenderer.destroy` resolves its teardown promise from `onComplete`, and must not wait on a
|
|
18
|
+
* ticker that may already be stopped), so it must stay synchronous.
|
|
19
|
+
*
|
|
20
|
+
* The consequence for callers: `onComplete` is NOT an async boundary. An `onComplete` that starts
|
|
21
|
+
* another tween is then direct recursion with no unwind — tween → onComplete → tween → … until
|
|
22
|
+
* `RangeError: Maximum call stack size exceeded`. That shipped once: the CTA pulse loop
|
|
23
|
+
* (`widgets.ts` startPulse) restarted itself from `onComplete`, so every player running the OS
|
|
24
|
+
* "reduce motion" setting crashed the instant the buy-bonus panel painted its hovered CTA. Any
|
|
25
|
+
* looping animation must therefore check `prefersReducedMotion()` and not loop — which is also
|
|
26
|
+
* what reduced motion is asking for.
|
|
27
|
+
*/
|
|
13
28
|
export function tween(ticker: Ticker, opts: TweenOpts): () => void {
|
|
14
29
|
const ease = opts.ease ?? easeOutCubic;
|
|
15
30
|
if (prefersReducedMotion() || opts.duration <= 0) {
|
|
@@ -4,6 +4,7 @@ import type { IconName } from '../icons';
|
|
|
4
4
|
import { makeIcon, IconView } from '../pixi-icon';
|
|
5
5
|
import { makeText, setText, NUM_FONT_FAMILY, NUM_FONT_SCALE } from '../text';
|
|
6
6
|
import { tween, type TweenOpts } from '../motion-pixi';
|
|
7
|
+
import { prefersReducedMotion } from '@/core/motion';
|
|
7
8
|
import { FlexBox, type Sizable } from './flex';
|
|
8
9
|
|
|
9
10
|
// Shared interactive primitives, ported from the DOM shell's CSS rules. Each widget reproduces
|
|
@@ -620,6 +621,14 @@ export class BuyBonusBadge extends Container implements Sizable {
|
|
|
620
621
|
|
|
621
622
|
private startPulse(): void {
|
|
622
623
|
if (this.pulseCancel) return;
|
|
624
|
+
// A pulse is exactly the kind of looping decoration "reduce motion" asks us to drop — and
|
|
625
|
+
// under it `tween` has nothing to animate anyway: it snaps to the end and calls `onComplete`
|
|
626
|
+
// SYNCHRONOUSLY (see its contract), which this loop would answer by starting another one, and
|
|
627
|
+
// another, with no unwind — `RangeError: Maximum call stack size exceeded`. That was a live
|
|
628
|
+
// Stake crash: with the OS "reduce motion" setting on, opening the buy-bonus panel painted its
|
|
629
|
+
// hovered CTA and killed the game, so the player could never buy. Now the button simply sits
|
|
630
|
+
// still at scale 1.
|
|
631
|
+
if (prefersReducedMotion()) return;
|
|
623
632
|
// both the label (anchor 0.5) and the icon (pivot centred) scale around their centre
|
|
624
633
|
const loop = (): void => {
|
|
625
634
|
this.pulseCancel = tween(this.ticker, {
|