@energy8platform/shell 0.8.0 → 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/pixi.d.ts CHANGED
@@ -305,8 +305,13 @@ interface AutoplayConfig {
305
305
  }
306
306
  interface ShellFeatures {
307
307
  turbo: 0 | 1 | 2 | 3;
308
- /** Master keyboard-shortcut switch. Defaults to `true`; set `false` to disable ALL hotkeys
309
- * (overrides `spacebar` and any future hotkey). */
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. */
310
315
  hotkeys?: boolean;
311
316
  /** Spacebar starts a spin in base mode. Defaults to `true`; set `false` to disable the
312
317
  * keyboard shortcut (e.g. jurisdictions that forbid quick-spin keys). */
@@ -638,7 +643,7 @@ declare class ShellController extends EventEmitter<ShellEvents> implements Shell
638
643
  tokens: ShellTokens;
639
644
  layout: ShellLayoutMode;
640
645
  soundOn: boolean;
641
- readonly engineVersion = "0.8.0";
646
+ readonly engineVersion = "0.9.0";
642
647
  readonly actions: ShellActions;
643
648
  private renderer;
644
649
  private i18n;
@@ -783,7 +788,7 @@ interface I18n {
783
788
  declare function createI18n(opts: I18nOptions): I18n;
784
789
 
785
790
  /** The @energy8platform/shell package version, stamped into the game-info footer. */
786
- declare const PACKAGE_VERSION = "0.8.0";
791
+ declare const PACKAGE_VERSION = "0.9.0";
787
792
 
788
793
  /** A shell: the renderer-agnostic controller plus the surface facade (safeArea/barHeight/setVisible)
789
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.8.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
- features: config.features,
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
- if (this.state.autoplay.active)
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, when autoplay runs. */
3141
- setAutoplay(active, remaining) {
3142
- if (active) {
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;
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
3165
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
- if (state.autoplay.active)
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
- if (state.autoplay.active)
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
- if (this.host.state.autoplay.active)
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 rawSections = host.config.gameInfo.sections ?? [];
4828
- // Auto-inject a hotkeys section unless the game already provides one or features.hotkeys === false.
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 (host.config.features.hotkeys !== false && !rawSections.some((s) => s.type === 'hotkeys')) {
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;