@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@energy8platform/shell",
3
- "version": "0.7.1",
3
+ "version": "0.8.0",
4
4
  "description": "Energy8 branded game shell — one logic core, pluggable html/pixi renderers behind a stable contract.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs.js",
@@ -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,
@@ -1,3 +1,3 @@
1
1
  // AUTO-GENERATED by scripts/gen-version.mjs — do not edit. Mirrors package.json "version".
2
2
  /** The @energy8platform/shell package version, stamped into the game-info footer. */
3
- export const PACKAGE_VERSION = '0.7.1';
3
+ export const PACKAGE_VERSION = '0.8.0';
@@ -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
- const n = Math.max(1, this.bonuses.length);
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 = this.bonuses.map((b) => this.buildCard(b, cardW, em, stack, areaH));
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 view = affordable[i].view;
438
- if (view instanceof BonusCard) {
439
- view.setFocused(i === this.focusIndex);
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
- const title = makeText(bonus.title, { size: 1.3 * em, weight: '800', color: titleColor, letterSpacing: 1.3 * em * 0.04, upper: true, align: 'center', wrapWidth: innerW });
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
- /** Tween 0→1 on the Pixi ticker. Returns a canceler. Skips to the end when motion is reduced. */
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, {