@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/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,
@@ -633,7 +638,7 @@ declare class ShellController extends EventEmitter<ShellEvents> implements Shell
633
638
  tokens: ShellTokens;
634
639
  layout: ShellLayoutMode;
635
640
  soundOn: boolean;
636
- readonly engineVersion = "0.7.1";
641
+ readonly engineVersion = "0.8.0";
637
642
  readonly actions: ShellActions;
638
643
  private renderer;
639
644
  private i18n;
@@ -778,7 +783,7 @@ interface I18n {
778
783
  declare function createI18n(opts: I18nOptions): I18n;
779
784
 
780
785
  /** The @energy8platform/shell package version, stamped into the game-info footer. */
781
- declare const PACKAGE_VERSION = "0.7.1";
786
+ declare const PACKAGE_VERSION = "0.8.0";
782
787
 
783
788
  /** A shell: the renderer-agnostic controller plus the surface facade (safeArea/barHeight/setVisible)
784
789
  * an embedding host reads. `createShell`, `createGameShell` and `createPixiShell` all return this. */
package/dist/pixi.esm.js CHANGED
@@ -1692,7 +1692,7 @@ class KeyboardController {
1692
1692
 
1693
1693
  // AUTO-GENERATED by scripts/gen-version.mjs — do not edit. Mirrors package.json "version".
1694
1694
  /** The @energy8platform/shell package version, stamped into the game-info footer. */
1695
- const PACKAGE_VERSION = '0.7.1';
1695
+ const PACKAGE_VERSION = '0.8.0';
1696
1696
 
1697
1697
  /** Apply defaults to the raw config (the mount target lives on the renderer, not here). */
1698
1698
  function resolveConfig(config) {
@@ -2324,7 +2324,22 @@ function prefersReducedMotion() {
2324
2324
  }
2325
2325
  const easeOutCubic = (p) => 1 - Math.pow(1 - p, 3);
2326
2326
 
2327
- /** Tween 0→1 on the Pixi ticker. Returns a canceler. Skips to the end when motion is reduced. */
2327
+ /**
2328
+ * Tween 0→1 on the Pixi ticker. Returns a canceler. Skips to the end when motion is reduced.
2329
+ *
2330
+ * CONTRACT — when motion is reduced (or `duration <= 0`) this tween does NOT animate: it applies
2331
+ * the final value and calls `onComplete` SYNCHRONOUSLY, before returning. Callers depend on that
2332
+ * (`PixiRenderer.destroy` resolves its teardown promise from `onComplete`, and must not wait on a
2333
+ * ticker that may already be stopped), so it must stay synchronous.
2334
+ *
2335
+ * The consequence for callers: `onComplete` is NOT an async boundary. An `onComplete` that starts
2336
+ * another tween is then direct recursion with no unwind — tween → onComplete → tween → … until
2337
+ * `RangeError: Maximum call stack size exceeded`. That shipped once: the CTA pulse loop
2338
+ * (`widgets.ts` startPulse) restarted itself from `onComplete`, so every player running the OS
2339
+ * "reduce motion" setting crashed the instant the buy-bonus panel painted its hovered CTA. Any
2340
+ * looping animation must therefore check `prefersReducedMotion()` and not loop — which is also
2341
+ * what reduced motion is asking for.
2342
+ */
2328
2343
  function tween(ticker, opts) {
2329
2344
  const ease = opts.ease ?? easeOutCubic;
2330
2345
  if (prefersReducedMotion() || opts.duration <= 0) {
@@ -3296,6 +3311,15 @@ class BuyBonusBadge extends Container {
3296
3311
  startPulse() {
3297
3312
  if (this.pulseCancel)
3298
3313
  return;
3314
+ // A pulse is exactly the kind of looping decoration "reduce motion" asks us to drop — and
3315
+ // under it `tween` has nothing to animate anyway: it snaps to the end and calls `onComplete`
3316
+ // SYNCHRONOUSLY (see its contract), which this loop would answer by starting another one, and
3317
+ // another, with no unwind — `RangeError: Maximum call stack size exceeded`. That was a live
3318
+ // Stake crash: with the OS "reduce motion" setting on, opening the buy-bonus panel painted its
3319
+ // hovered CTA and killed the game, so the player could never buy. Now the button simply sits
3320
+ // still at scale 1.
3321
+ if (prefersReducedMotion())
3322
+ return;
3299
3323
  // both the label (anchor 0.5) and the icon (pivot centred) scale around their centre
3300
3324
  const loop = () => {
3301
3325
  this.pulseCancel = tween(this.ticker, {
@@ -5353,6 +5377,34 @@ function imageBox(url, w, h) {
5353
5377
  return c;
5354
5378
  }
5355
5379
 
5380
+ /** Collapse buy-bonus options that share a `groupedBy` key into one slot. A slot with more than one
5381
+ * member is rendered by the pixi shell as a single card with arrows (the DOM shell ignores the key
5382
+ * and keeps a card per option).
5383
+ *
5384
+ * A group takes the position of its FIRST member, so the strip order stays predictable however the
5385
+ * members are scattered through the array. Options with a game-supplied `custom` renderer never
5386
+ * join a group — the shell has no layout for their interior and could not place arrows over it. */
5387
+ function groupBonusSlots(bonuses) {
5388
+ const slots = [];
5389
+ const byKey = new Map();
5390
+ for (const bonus of bonuses) {
5391
+ const key = bonus.groupedBy;
5392
+ if (!key || bonus.custom) {
5393
+ slots.push([bonus]);
5394
+ continue;
5395
+ }
5396
+ const open = byKey.get(key);
5397
+ if (open) {
5398
+ open.push(bonus);
5399
+ continue;
5400
+ }
5401
+ const slot = [bonus];
5402
+ byKey.set(key, slot);
5403
+ slots.push(slot);
5404
+ }
5405
+ return slots;
5406
+ }
5407
+
5356
5408
  /** Below this frame height (px), a wide/landscape popout (e.g. Popout S 400×225) stacks its cards
5357
5409
  * vertically and scrolls — like mobile — so descriptions stay readable instead of shrinking to a
5358
5410
  * ~4px floor. Wide + taller frames (Popout L and up) keep the centred horizontal row. */
@@ -5386,6 +5438,9 @@ class BuyBonusOverlay extends Container {
5386
5438
  cardEntries = [];
5387
5439
  /** Keyboard focus index into the affordable subset of cardEntries. -1 = none. */
5388
5440
  focusIndex = -1;
5441
+ /** Which member each `groupedBy` slot is showing, kept across rebuilds (bet steps, resizes) so a
5442
+ * flipped-to variant doesn't snap back to the first one under the player. */
5443
+ groupIndex = new Map();
5389
5444
  /** true when cards are stacked vertically (mobile, or a short landscape popout). */
5390
5445
  stack = false;
5391
5446
  // Drag-scroll state. The drag is handled on the (unmasked) overlay, not the masked strip — a mask
@@ -5504,7 +5559,9 @@ class BuyBonusOverlay extends Container {
5504
5559
  const top = this.headerH + 6;
5505
5560
  const areaH = this.h - top - this.footerH - 6;
5506
5561
  const gap = 14;
5507
- const n = Math.max(1, this.bonuses.length);
5562
+ // Grouped options share ONE slot (a carousel card), so the width fit counts SLOTS, not options.
5563
+ const slots = groupBonusSlots(this.bonuses);
5564
+ const n = Math.max(1, slots.length);
5508
5565
  // The whole card is laid out in `em`; pick the largest em that fits BOTH dimensions of the
5509
5566
  // available area, so the card is always fully visible (no horizontal clip, CTA never under the
5510
5567
  // footer). emH keeps the card height within the band between header and footer; emW makes the
@@ -5516,16 +5573,44 @@ class BuyBonusOverlay extends Container {
5516
5573
  : (this.w - 48 - (n - 1) * gap) / (18 * n); // row: N cards + gaps fit the frame width
5517
5574
  const em = stack ? Math.min(12, emW) : clamp(4, Math.min(emH, emW), 12);
5518
5575
  const cardW = Math.min(18 * em, this.w - 48);
5519
- const cards = this.bonuses.map((b) => this.buildCard(b, cardW, em, stack, areaH));
5576
+ const cards = [];
5577
+ this.cardEntries = [];
5578
+ for (const slot of slots) {
5579
+ if (slot.length === 1) {
5580
+ const view = this.buildCard(slot[0], cardW, em, stack, areaH);
5581
+ cards.push(view);
5582
+ this.cardEntries.push({ view, bonus: slot[0], affordable: this.isAffordable(slot[0]) });
5583
+ continue;
5584
+ }
5585
+ // Carousel slot: every member is built (the slot sizes to the tallest and flipping costs
5586
+ // nothing), but only the current one is visible.
5587
+ const key = slot[0].groupedBy;
5588
+ // Flipping the card also moves keyboard focus onto the shown member: one cursor, not two
5589
+ // (otherwise the next rebuild would snap the card back to whatever the focus still pointed at).
5590
+ const group = new GroupCard((i) => {
5591
+ this.groupIndex.set(key, i);
5592
+ this.focusShownMember(group, i);
5593
+ });
5594
+ const members = slot.map((b, i) => this.buildCard(b, cardW, em, stack, areaH, {
5595
+ index: i,
5596
+ count: slot.length,
5597
+ prevLabel: `bb-nav-prev:${key}`,
5598
+ nextLabel: `bb-nav-next:${key}`,
5599
+ onStep: (dir) => {
5600
+ if (this.dragged)
5601
+ return; // a scroll gesture, not a tap
5602
+ group.step(dir);
5603
+ },
5604
+ }));
5605
+ group.setCards(members, this.groupIndex.get(key) ?? 0);
5606
+ cards.push(group);
5607
+ for (let i = 0; i < slot.length; i++) {
5608
+ this.cardEntries.push({ view: members[i], bonus: slot[i], affordable: this.isAffordable(slot[i]), group });
5609
+ }
5610
+ }
5520
5611
  const cardH = Math.max(...cards.map((c) => c.height));
5521
5612
  for (const c of cards)
5522
5613
  c.setHeight(cardH);
5523
- // Rebuild card entries for keyboard navigation
5524
- this.cardEntries = this.bonuses.map((b, i) => ({
5525
- view: cards[i],
5526
- bonus: b,
5527
- affordable: this.isAffordable(b),
5528
- }));
5529
5614
  // Restore or init keyboard focus on the first affordable card
5530
5615
  const affordable = this.cardEntries.filter((e) => e.affordable);
5531
5616
  if (affordable.length > 0) {
@@ -5566,7 +5651,7 @@ class BuyBonusOverlay extends Container {
5566
5651
  }
5567
5652
  }
5568
5653
  // ── one card ──────────────────────────────────────────────────────────────
5569
- buildCard(bonus, cardW, em, stack, areaH) {
5654
+ buildCard(bonus, cardW, em, stack, areaH, nav) {
5570
5655
  const accent = effectiveAccent(bonus);
5571
5656
  const ink = contrastText(accent);
5572
5657
  const price = bonus.priceMultiplier * this.host.state.bet;
@@ -5602,6 +5687,7 @@ class BuyBonusOverlay extends Container {
5602
5687
  enabled,
5603
5688
  ctaLabel: this.host.t(bonus.type === 'feature' ? 'Activate' : 'Buy'),
5604
5689
  onSelect: select,
5690
+ nav,
5605
5691
  });
5606
5692
  return card;
5607
5693
  }
@@ -5720,7 +5806,7 @@ class BuyBonusOverlay extends Container {
5720
5806
  // the buttons keep the card silhouette without a mask blocking their pointer events.
5721
5807
  const half = cardW / 2;
5722
5808
  const r = 1.3 * em;
5723
- 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, () => {
5809
+ 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, () => {
5724
5810
  if (!this.isAffordable(bonus))
5725
5811
  return;
5726
5812
  if (bonus.type === 'feature')
@@ -5728,7 +5814,7 @@ class BuyBonusOverlay extends Container {
5728
5814
  else
5729
5815
  this.host.actions.selectBuyBonus(bonus.id);
5730
5816
  this.host.closeLayer();
5731
- }, ink, 0, r));
5817
+ }, ink, 0, r)));
5732
5818
  card.position.set((this.w - cardW) / 2, (this.h - cardH) / 2);
5733
5819
  layer.addChild(card);
5734
5820
  // close X pinned to screen corner
@@ -5754,14 +5840,28 @@ class BuyBonusOverlay extends Container {
5754
5840
  this.buildCards();
5755
5841
  this.buildFooter();
5756
5842
  }
5843
+ /** Move keyboard focus onto the member a carousel slot just flipped to. Skipped when that member
5844
+ * can't be bought at the current bet — focus only ever sits on affordable cards. */
5845
+ focusShownMember(group, index) {
5846
+ const card = group.cardAt(index);
5847
+ const at = this.cardEntries.filter((ce) => ce.affordable).findIndex((ce) => ce.group === group && ce.view === card);
5848
+ if (at < 0)
5849
+ return;
5850
+ this.focusIndex = at;
5851
+ this.applyFocusRing();
5852
+ }
5757
5853
  /** Apply or clear the focus ring on affordable cards. */
5758
5854
  applyFocusRing() {
5759
5855
  const affordable = this.cardEntries.filter((ce) => ce.affordable);
5760
5856
  for (let i = 0; i < affordable.length; i++) {
5761
- const view = affordable[i].view;
5762
- if (view instanceof BonusCard) {
5763
- view.setFocused(i === this.focusIndex);
5764
- }
5857
+ const entry = affordable[i];
5858
+ const focused = i === this.focusIndex;
5859
+ // Focusing a member of a carousel slot brings it to the front — the keyboard walks options,
5860
+ // and the card follows.
5861
+ if (focused && entry.group)
5862
+ entry.group.showCard(entry.view);
5863
+ if (entry.view instanceof BonusCard)
5864
+ entry.view.setFocused(focused);
5765
5865
  }
5766
5866
  }
5767
5867
  /** Two-phase keyboard handler.
@@ -5870,6 +5970,55 @@ class CustomCard {
5870
5970
  this.height = total; // the game owns its own layout; we only record the slot height
5871
5971
  }
5872
5972
  }
5973
+ /** One slot shared by several options (`groupedBy`) — e.g. four same-priced Ante characters. Every
5974
+ * member card is built (the slot sizes to the tallest, and flipping is then just a visibility
5975
+ * swap, so the card never resizes under the player); one is visible at a time. */
5976
+ class GroupCard {
5977
+ node = new Container();
5978
+ height = 0;
5979
+ cards = [];
5980
+ index = 0;
5981
+ /** Reports the shown member back to the overlay, which keeps it across rebuilds. */
5982
+ onIndex;
5983
+ constructor(onIndex) {
5984
+ this.onIndex = onIndex;
5985
+ }
5986
+ setCards(cards, index) {
5987
+ this.cards = cards;
5988
+ for (const c of cards)
5989
+ this.node.addChild(c.node);
5990
+ this.height = Math.max(...cards.map((c) => c.height));
5991
+ this.show(index, false); // restoring a remembered position is not a player action
5992
+ }
5993
+ /** Flip to member `i`, wrapping around in both directions. */
5994
+ show(i, notify = true) {
5995
+ const n = this.cards.length;
5996
+ if (n === 0)
5997
+ return;
5998
+ this.index = ((i % n) + n) % n;
5999
+ for (let k = 0; k < n; k++)
6000
+ this.cards[k].node.visible = k === this.index;
6001
+ if (notify)
6002
+ this.onIndex(this.index);
6003
+ }
6004
+ step(dir) {
6005
+ this.show(this.index + dir);
6006
+ }
6007
+ cardAt(index) {
6008
+ return this.cards[index];
6009
+ }
6010
+ /** Bring a specific member's card to the front (keyboard focus landing on that option). */
6011
+ showCard(card) {
6012
+ const i = this.cards.findIndex((c) => c === card);
6013
+ if (i >= 0 && i !== this.index)
6014
+ this.show(i);
6015
+ }
6016
+ setHeight(total) {
6017
+ this.height = total;
6018
+ for (const c of this.cards)
6019
+ c.setHeight(total);
6020
+ }
6021
+ }
5873
6022
  class BonusCard {
5874
6023
  node = new Container();
5875
6024
  height = 0;
@@ -5891,9 +6040,22 @@ class BonusCard {
5891
6040
  const volColor = opts.enabled ? accent : 'rgba(255,255,255,.4)';
5892
6041
  const top = new Container();
5893
6042
  let y = 1.25 * em;
5894
- 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 });
6043
+ // With arrows the title has to clear them on both sides, or a long name wraps under the glyphs.
6044
+ const arrowSz = 1.5 * em;
6045
+ const arrowPad = 0.45 * em;
6046
+ const titleW = opts.nav ? innerW - 2 * (arrowSz + 2 * arrowPad) : innerW;
6047
+ 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 });
5895
6048
  title.position.set((cardW - title.width) / 2, y);
5896
6049
  top.addChild(title);
6050
+ if (opts.nav) {
6051
+ const boxSz = arrowSz + 2 * arrowPad;
6052
+ const rowY = y + (title.height - boxSz) / 2;
6053
+ const prev = arrowButton(opts.nav.prevLabel, -1, arrowSz, arrowPad, accent, opts.nav.onStep);
6054
+ const next = arrowButton(opts.nav.nextLabel, 1, arrowSz, arrowPad, accent, opts.nav.onStep);
6055
+ prev.position.set(0.6 * em, rowY);
6056
+ next.position.set(cardW - 0.6 * em - boxSz, rowY);
6057
+ top.addChild(prev, next);
6058
+ }
5897
6059
  y += title.height + 0.75 * em;
5898
6060
  const thumb = thumbNode(host, bonus, accent, 6.2 * em);
5899
6061
  thumb.position.set((cardW - 6.2 * em) / 2, y);
@@ -5903,6 +6065,13 @@ class BonusCard {
5903
6065
  desc.position.set((cardW - desc.width) / 2, y);
5904
6066
  top.addChild(desc);
5905
6067
  y += desc.height;
6068
+ if (opts.nav) {
6069
+ y += 0.7 * em;
6070
+ const dots = dotRow(opts.nav.count, opts.nav.index, accent, 0.24 * em, 0.5 * em);
6071
+ dots.position.set((cardW - dots.width) / 2, y);
6072
+ top.addChild(dots);
6073
+ y += 0.48 * em;
6074
+ }
5906
6075
  this.topH = y;
5907
6076
  // bottom block: volatility + price
5908
6077
  let by = 0;
@@ -5917,7 +6086,8 @@ class BonusCard {
5917
6086
  this.bottomBlock.addChild(priceText);
5918
6087
  by += priceText.height;
5919
6088
  this.bottomH = by;
5920
- this.cta = ctaButton(host, opts.ctaLabel, accent, opts.ink, cardW, this.ctaH, opts.enabled, opts.onSelect, 1.4 * em);
6089
+ this.cta = labelled(`bb-cta:${bonus.id}`, ctaButton(host, opts.ctaLabel, accent, opts.ink, cardW, this.ctaH, opts.enabled, opts.onSelect, 1.4 * em));
6090
+ this.node.label = `bb-card:${bonus.id}`;
5921
6091
  this.node.addChild(this.bg, top, this.bottomBlock, this.cta);
5922
6092
  if (opts.enabled) {
5923
6093
  this.node.eventMode = 'static';
@@ -5968,6 +6138,43 @@ class BonusCard {
5968
6138
  }
5969
6139
  }
5970
6140
  // ── shared card bits ─────────────────────────────────────────────────────────
6141
+ /** Tag a node so the shell (and its tests) can find it in the display tree. */
6142
+ function labelled(label, node) {
6143
+ node.label = label;
6144
+ return node;
6145
+ }
6146
+ /** One carousel arrow. `stopPropagation` keeps the tap off the card underneath, which would
6147
+ * otherwise open the confirm dialog for the member being flipped away from. */
6148
+ function arrowButton(label, dir, size, pad, accent, onStep) {
6149
+ const b = new Container();
6150
+ b.label = label;
6151
+ const glyph = makeIcon('chevronRight', size, '#ffffff');
6152
+ if (dir === -1)
6153
+ glyph.spin = Math.PI; // no chevronLeft in the set — the glyph spins about its centre
6154
+ glyph.position.set(pad, pad);
6155
+ b.addChild(rectHit(size + pad * 2, size + pad * 2), glyph);
6156
+ b.eventMode = 'static';
6157
+ b.cursor = 'pointer';
6158
+ b.on('pointerover', () => glyph.setColor(accent));
6159
+ b.on('pointerout', () => glyph.setColor('#ffffff'));
6160
+ b.on('pointertap', (e) => {
6161
+ e.stopPropagation();
6162
+ onStep(dir);
6163
+ });
6164
+ return b;
6165
+ }
6166
+ /** Position indicator for a carousel slot: one dot per member, the current one in the accent. */
6167
+ function dotRow(count, index, accent, r, gap) {
6168
+ const c = new Container();
6169
+ for (let i = 0; i < count; i++) {
6170
+ const dot = new Graphics();
6171
+ const on = i === index;
6172
+ dot.circle(r, r, r).fill(on ? accent : 'rgba(255,255,255,.28)');
6173
+ dot.position.set(i * (2 * r + gap), 0);
6174
+ c.addChild(dot);
6175
+ }
6176
+ return c;
6177
+ }
5971
6178
  function thumbNode(host, bonus, accent, h) {
5972
6179
  const c = new Container();
5973
6180
  c.addChild(rectHit(h, h, 0)); // size anchor (transparent)