@energy8platform/shell 0.9.0 → 0.11.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.cjs.js CHANGED
@@ -1309,6 +1309,19 @@ const D = {
1309
1309
  L4: 'The game display is not representative of any physical device and is for illustrative purposes only.',
1310
1310
  L5: 'Winnings are settled according to the amount received from the Remote Game Server and not from events within the web browser.',
1311
1311
  };
1312
+ /**
1313
+ * The disclaimer body every launch shows, in source English — and the SAME strings that key the
1314
+ * translations below. One constant for both on purpose: a host that kept its own copy would drift
1315
+ * from these keys by one character and the lookup would miss in silence, leaving a player on any
1316
+ * language with English legal text. Certification asks for the opposite ("if multiple languages are
1317
+ * supported, the disclaimer is translated and displayed in each language").
1318
+ *
1319
+ * Brand-free by construction. Stake's own template ends with a seventh line — "TM and © {year}
1320
+ * Stake Engine." — and that one belongs to a Stake launch alone: it arrives with the Stake bridge's
1321
+ * `INIT.config.disclaimerLines`, which outrank this default, and renders verbatim (see the host's
1322
+ * `isBrandLine`). Every other platform gets the same mandated wording without someone else's mark.
1323
+ */
1324
+ const DISCLAIMER_LINES = [D.L1, D.L2, D.L3, D.L4, D.L5];
1312
1325
  const DISCLAIMER_LOCALES = {
1313
1326
  da: {
1314
1327
  [D.L1]: 'Fejlfunktion annullerer alle gevinster og spil.',
@@ -1726,7 +1739,7 @@ function keyboardCapable(win = typeof window === 'undefined' ? undefined : windo
1726
1739
 
1727
1740
  // AUTO-GENERATED by scripts/gen-version.mjs — do not edit. Mirrors package.json "version".
1728
1741
  /** The @energy8platform/shell package version, stamped into the game-info footer. */
1729
- const PACKAGE_VERSION = '0.9.0';
1742
+ const PACKAGE_VERSION = '0.11.0';
1730
1743
 
1731
1744
  /** Apply defaults to the raw config (the mount target lives on the renderer, not here). */
1732
1745
  function resolveConfig(config) {
@@ -2212,6 +2225,66 @@ function placePopover(anchor, surface, size, pointer = null) {
2212
2225
  return { x, y, maxH, arrowX, below };
2213
2226
  }
2214
2227
 
2228
+ /**
2229
+ * What a scrollable region should ADVERTISE about itself.
2230
+ *
2231
+ * A slot in a Stake popout is 400×225. At that size the game-info overlay holds twelve screens of
2232
+ * content, the buy-bonus switches to a vertical card stack, and the menu popover hides its last
2233
+ * row — all of them scroll, and (before this module) none of them said so. macOS makes it worse:
2234
+ * overlay scrollbars stay invisible until something is already scrolling, so the very affordance a
2235
+ * player needs BEFORE they touch anything is the one the OS withholds. A certification reviewer
2236
+ * reads that as content the player can't reach.
2237
+ *
2238
+ * The maths lives here, apart from both renderers, for one reason: the DOM shell reads
2239
+ * `scrollTop`/`scrollHeight` while the Pixi shell tracks its own offset against a mask, and those
2240
+ * two must never disagree about whether a fade belongs at the bottom edge. Renderers decide how a
2241
+ * thumb LOOKS; this decides when there is one and where it sits.
2242
+ */
2243
+ /** Fractions of a pixel are layout rounding, not reachable content. */
2244
+ const EPSILON = 1;
2245
+ /** Landing within half a pixel of an edge counts as arriving: a browser settles a flung scroll on
2246
+ * 199.5 of 200, and a fade left glowing over content the player has already reached reads as a
2247
+ * bug. */
2248
+ const EDGE_EPSILON = 0.5;
2249
+ /**
2250
+ * Shortest thumb we will draw, as a fraction of its track.
2251
+ *
2252
+ * The honest ratio for game info at Popout S is 8% — an 18px speck on a 226px track, which reads
2253
+ * as a rendering artifact rather than a scrollbar. Floored at 18% it stays recognisably a thumb,
2254
+ * and it still travels the whole track, so the position it reports remains truthful even though
2255
+ * its length no longer is. That is the right trade: length is decoration, position is information.
2256
+ */
2257
+ const SCROLL_THUMB_MIN = 0.18;
2258
+ /** Resolve one axis of a scroll region into everything a renderer needs to draw its affordance. */
2259
+ function scrollHint(m) {
2260
+ const view = Math.max(0, m.clientHeight);
2261
+ const content = Math.max(0, m.scrollHeight);
2262
+ const maxScroll = Math.max(0, content - view);
2263
+ if (maxScroll <= EPSILON || view <= 0) {
2264
+ // Both edges are "the edge" when there is nowhere to go — callers gate every fade on the
2265
+ // matching flag, so a region that fits draws nothing without needing to check `overflowing`.
2266
+ return { overflowing: false, atStart: true, atEnd: true, thumbSize: 1, thumbOffset: 0, maxScroll: 0 };
2267
+ }
2268
+ const at = Math.max(0, Math.min(maxScroll, m.scrollTop));
2269
+ const thumbSize = Math.min(1, Math.max(SCROLL_THUMB_MIN, view / content));
2270
+ const progress = at / maxScroll;
2271
+ return {
2272
+ overflowing: true,
2273
+ atStart: at <= EDGE_EPSILON,
2274
+ atEnd: maxScroll - at <= EDGE_EPSILON,
2275
+ thumbSize,
2276
+ thumbOffset: progress * (1 - thumbSize),
2277
+ maxScroll,
2278
+ };
2279
+ }
2280
+ function scrollEdge(h) {
2281
+ if (!h.overflowing)
2282
+ return 'none';
2283
+ if (h.atStart)
2284
+ return 'start';
2285
+ return h.atEnd ? 'end' : 'mid';
2286
+ }
2287
+
2215
2288
  const NO_INSET = { top: 0, right: 0, bottom: 0, left: 0 };
2216
2289
  /** Create a shell with an explicit renderer instance (custom or a built-in HtmlRenderer/PixiRenderer).
2217
2290
  * Built-in renderers also have the createGameShell/createPixiShell sugar in /html and /pixi.
@@ -4137,11 +4210,207 @@ function SPIN_POP_MOBILE() {
4137
4210
  return (SPIN - M_CTRL_H) / 2; // 11
4138
4211
  }
4139
4212
 
4213
+ /** Thickness of the thumb, and how far it is inset from the edge it rides. */
4214
+ const THUMB_THICK = 4;
4215
+ const THUMB_INSET = 3;
4216
+ /** Shortest thumb we will draw in pixels — the fractional floor still degenerates on a tiny frame. */
4217
+ const THUMB_MIN_PX = 22;
4218
+ /** Depth of the edge scrim. */
4219
+ const SCRIM = 34;
4220
+ const SCRIM_ALPHA = 0.72;
4221
+ /** The overlay chrome — plaques, bar, backdrop — is scheme-independent in this shell (see
4222
+ * resolveTheme: those tokens are fixed, only the palette behind them flips). The affordance is
4223
+ * chrome, so it is a fixed neutral too: white marks over a tint of the same hue as the veil, which
4224
+ * makes the fade read as content dissolving INTO the overlay rather than as a grey band on top. */
4225
+ const SCRIM_TINT = 0x0c111c;
4226
+ const CUE_R = 11;
4227
+ const CUE_BOB = 4;
4228
+ const CUE_PERIOD = 1600; // ms
4229
+ /**
4230
+ * The scrim's alpha ramp, densest AT the edge and clear by `depth` inward.
4231
+ *
4232
+ * Exported because the direction is the whole point and is easy to get backwards: an inverted ramp
4233
+ * draws a hard-edged dark band floating over the content instead of a fade into the frame, and it
4234
+ * looks deliberate enough that nobody questions it. The squared falloff keeps the outer half of the
4235
+ * ramp faint, so the scrim reads as content thinning out rather than as a panel laid on top.
4236
+ */
4237
+ function scrimRamp(depth) {
4238
+ const slices = Math.max(1, Math.round(depth));
4239
+ const thickness = depth / slices;
4240
+ const out = [];
4241
+ for (let i = 0; i < slices; i++) {
4242
+ const offset = i * thickness;
4243
+ const t = 1 - (offset + thickness / 2) / depth; // 1 at the edge → 0 at `depth`
4244
+ out.push({ offset, thickness, alpha: SCRIM_ALPHA * t * t });
4245
+ }
4246
+ return out;
4247
+ }
4248
+ class ScrollAffordanceView extends pixi_js.Container {
4249
+ thumb = new pixi_js.Graphics();
4250
+ scrim = new pixi_js.Graphics();
4251
+ cue = new pixi_js.Container();
4252
+ ticker;
4253
+ bounds = null;
4254
+ cueOn = false;
4255
+ // Once the region has moved the player knows the gesture; re-offering it on every return to the
4256
+ // top would nag rather than inform.
4257
+ cueRetired = false;
4258
+ cueBaseY = 0;
4259
+ elapsed = 0;
4260
+ tick;
4261
+ constructor(ticker) {
4262
+ super();
4263
+ this.ticker = ticker;
4264
+ this.eventMode = 'none'; // decoration: never intercept the drag it is advertising
4265
+ this.addChild(this.scrim, this.thumb, this.cue);
4266
+ this.buildCue();
4267
+ this.visible = false;
4268
+ }
4269
+ /** Where the thumb is drawn, in the parent's space — `null` when nothing is drawn. */
4270
+ get thumbBounds() {
4271
+ return this.visible ? this.bounds : null;
4272
+ }
4273
+ get cueVisible() {
4274
+ return this.visible && this.cueOn;
4275
+ }
4276
+ /** Re-draw for a viewport and a hint. Cheap enough to call on every scroll frame. */
4277
+ update(vp, hint) {
4278
+ if (this.destroyed)
4279
+ return;
4280
+ if (!hint.overflowing) {
4281
+ this.visible = false;
4282
+ this.bounds = null;
4283
+ this.showCue(false);
4284
+ return;
4285
+ }
4286
+ this.visible = true;
4287
+ // Any movement off the start retires the cue permanently.
4288
+ if (!hint.atStart)
4289
+ this.cueRetired = true;
4290
+ this.drawThumb(vp, hint);
4291
+ this.drawScrim(vp, hint);
4292
+ this.placeCue(vp);
4293
+ this.showCue(hint.atStart && !this.cueRetired);
4294
+ }
4295
+ drawThumb(vp, hint) {
4296
+ const along = vp.axis === 'y' ? vp.h : vp.w;
4297
+ const track = Math.max(0, along - THUMB_INSET * 2);
4298
+ const len = Math.max(Math.min(track, THUMB_MIN_PX), track * hint.thumbSize);
4299
+ // Recover the travel from the (possibly floored) length so the thumb still reaches both ends.
4300
+ const travel = Math.max(0, track - len);
4301
+ const progress = hint.thumbSize < 1 ? hint.thumbOffset / (1 - hint.thumbSize) : 0;
4302
+ const at = THUMB_INSET + travel * progress;
4303
+ this.bounds =
4304
+ vp.axis === 'y'
4305
+ ? { x: vp.x + vp.w - THUMB_INSET - THUMB_THICK, y: vp.y + at, w: THUMB_THICK, h: len }
4306
+ : { x: vp.x + at, y: vp.y + vp.h - THUMB_INSET - THUMB_THICK, w: len, h: THUMB_THICK };
4307
+ const b = this.bounds;
4308
+ this.thumb.clear();
4309
+ // A faint track behind the thumb: on a dark overlay a lone thumb can read as a stray highlight;
4310
+ // with the groove behind it, it reads as a scrollbar.
4311
+ if (vp.axis === 'y') {
4312
+ this.thumb
4313
+ .roundRect(b.x, vp.y + THUMB_INSET, THUMB_THICK, track, THUMB_THICK / 2)
4314
+ .fill({ color: 0xffffff, alpha: 0.1 });
4315
+ }
4316
+ else {
4317
+ this.thumb
4318
+ .roundRect(vp.x + THUMB_INSET, b.y, track, THUMB_THICK, THUMB_THICK / 2)
4319
+ .fill({ color: 0xffffff, alpha: 0.1 });
4320
+ }
4321
+ this.thumb.roundRect(b.x, b.y, b.w, b.h, THUMB_THICK / 2).fill({ color: 0xffffff, alpha: 0.55 });
4322
+ }
4323
+ /** Stacked 1px slices rather than a gradient fill: no texture, no backend-specific paths, and at
4324
+ * one device pixel per slice there is nothing left to band. */
4325
+ drawScrim(vp, hint) {
4326
+ this.scrim.clear();
4327
+ const depth = Math.min(SCRIM, (vp.axis === 'y' ? vp.h : vp.w) / 3);
4328
+ if (depth <= 0)
4329
+ return;
4330
+ for (const { offset, thickness, alpha } of scrimRamp(depth)) {
4331
+ // `offset` is measured INWARD from whichever edge is being faded.
4332
+ if (!hint.atEnd) {
4333
+ if (vp.axis === 'y')
4334
+ this.scrim.rect(vp.x, vp.y + vp.h - offset - thickness, vp.w, thickness);
4335
+ else
4336
+ this.scrim.rect(vp.x + vp.w - offset - thickness, vp.y, thickness, vp.h);
4337
+ this.scrim.fill({ color: SCRIM_TINT, alpha });
4338
+ }
4339
+ if (!hint.atStart) {
4340
+ if (vp.axis === 'y')
4341
+ this.scrim.rect(vp.x, vp.y + offset, vp.w, thickness);
4342
+ else
4343
+ this.scrim.rect(vp.x + offset, vp.y, thickness, vp.h);
4344
+ this.scrim.fill({ color: SCRIM_TINT, alpha });
4345
+ }
4346
+ }
4347
+ }
4348
+ buildCue() {
4349
+ const disc = new pixi_js.Graphics();
4350
+ disc.circle(0, 0, CUE_R).fill({ color: 0x0b0f18, alpha: 0.9 });
4351
+ disc.circle(0, 0, CUE_R).stroke({ color: 0xffffff, alpha: 0.22, width: 1 });
4352
+ const size = Math.round(CUE_R * 1.3);
4353
+ const glyph = makeIcon('chevronDown', size, '#ffffff');
4354
+ glyph.position.set(-size / 2, -size / 2);
4355
+ this.cue.addChild(disc, glyph);
4356
+ this.cue.visible = false;
4357
+ }
4358
+ placeCue(vp) {
4359
+ // Bottom-centre for a vertical region; hugging the trailing edge for a horizontal one.
4360
+ if (vp.axis === 'y') {
4361
+ this.cue.x = vp.x + vp.w / 2;
4362
+ this.cueBaseY = vp.y + vp.h - CUE_R - 6;
4363
+ }
4364
+ else {
4365
+ this.cue.x = vp.x + vp.w - CUE_R - 6;
4366
+ this.cueBaseY = vp.y + vp.h / 2;
4367
+ this.cue.rotation = -Math.PI / 2; // chevron points the way the strip moves
4368
+ }
4369
+ this.cue.y = this.cueBaseY;
4370
+ }
4371
+ showCue(on) {
4372
+ if (on === this.cueOn)
4373
+ return;
4374
+ this.cueOn = on;
4375
+ this.cue.visible = on;
4376
+ if (on)
4377
+ this.startBob();
4378
+ else
4379
+ this.stopBob();
4380
+ }
4381
+ startBob() {
4382
+ // A player who asked for stillness still needs the hint; they just do not need it moving.
4383
+ if (this.tick || !this.ticker || prefersReducedMotion())
4384
+ return;
4385
+ this.elapsed = 0;
4386
+ this.tick = (t) => {
4387
+ this.elapsed += t.deltaMS;
4388
+ const phase = (this.elapsed % CUE_PERIOD) / CUE_PERIOD;
4389
+ this.cue.y = this.cueBaseY + Math.sin(phase * Math.PI * 2) * (CUE_BOB / 2) + CUE_BOB / 2;
4390
+ };
4391
+ this.ticker.add(this.tick);
4392
+ }
4393
+ stopBob() {
4394
+ if (!this.tick)
4395
+ return;
4396
+ this.ticker?.remove(this.tick);
4397
+ this.tick = undefined;
4398
+ this.cue.y = this.cueBaseY;
4399
+ }
4400
+ destroy(options) {
4401
+ this.stopBob();
4402
+ super.destroy(options);
4403
+ }
4404
+ }
4405
+
4140
4406
  /** A vertically-scrolling viewport — mask + drag + mouse-wheel, the Pixi analogue of the
4141
4407
  * overlay's `overflow-y:auto` scroll region. Add content to `.content`; call `refresh()`
4142
4408
  * after content changes or a resize. */
4143
4409
  class ScrollBox extends pixi_js.Container {
4144
4410
  content = new pixi_js.Container();
4411
+ /** The drawn "this scrolls" marks — thumb, edge scrim, chevron. Public so the overlay that owns
4412
+ * the box (and the tests) can read what a player would see. */
4413
+ affordance;
4145
4414
  maskG = new pixi_js.Graphics();
4146
4415
  viewW = 0;
4147
4416
  viewH = 0;
@@ -4152,10 +4421,13 @@ class ScrollBox extends pixi_js.Container {
4152
4421
  moved = false;
4153
4422
  canvas;
4154
4423
  wheelHandler;
4155
- constructor(canvas) {
4424
+ constructor(canvas, ticker) {
4156
4425
  super();
4157
4426
  this.canvas = canvas;
4158
- this.addChild(this.content);
4427
+ this.affordance = new ScrollAffordanceView(ticker);
4428
+ // Added AFTER content, and never masked: the marks describe the viewport, so clipping them to
4429
+ // the content they describe would hide them exactly when the content overflows.
4430
+ this.addChild(this.content, this.affordance);
4159
4431
  // maskG is added to the scene only while scrolling (see refresh) — a leftover unused mask
4160
4432
  // graphic renders as a white rect. (Masking does NOT gate pointer events to the content, despite
4161
4433
  // what an earlier version of this comment claimed — see the correction in refresh() below.)
@@ -4210,6 +4482,7 @@ class ScrollBox extends pixi_js.Container {
4210
4482
  }
4211
4483
  this.eventMode = scrollable ? 'static' : 'passive';
4212
4484
  this.setScroll(this.scrollY);
4485
+ this.syncAffordance();
4213
4486
  }
4214
4487
  /** Max scrollable distance (0 when content fits) — exposed for tests. */
4215
4488
  get maxScrollY() {
@@ -4226,6 +4499,12 @@ class ScrollBox extends pixi_js.Container {
4226
4499
  return;
4227
4500
  this.scrollY = Math.max(0, Math.min(this.maxScroll, y)) || 0; // || 0 converts -0 to 0
4228
4501
  this.content.y = this.scrollY === 0 ? 0 : -this.scrollY;
4502
+ this.syncAffordance();
4503
+ }
4504
+ syncAffordance() {
4505
+ if (this.destroyed || this.affordance.destroyed)
4506
+ return;
4507
+ this.affordance.update({ x: 0, y: 0, w: this.viewW, h: this.viewH, axis: 'y' }, scrollHint({ scrollTop: this.scrollY, scrollHeight: this.viewH + this.maxScroll, clientHeight: this.viewH }));
4229
4508
  }
4230
4509
  onDown = (e) => {
4231
4510
  this.dragging = true;
@@ -4796,7 +5075,7 @@ class Overlay extends pixi_js.Container {
4796
5075
  this.host = host;
4797
5076
  this.opts = opts;
4798
5077
  this.tag = opts.tag;
4799
- this.scroll = new ScrollBox(host.canvas);
5078
+ this.scroll = new ScrollBox(host.canvas, host.ticker);
4800
5079
  this.addChild(this.veil, this.scroll, this.header);
4801
5080
  this.veil.eventMode = 'static'; // swallow clicks to the game behind
4802
5081
  this.resize(host.screenW, host.screenH);
@@ -5519,6 +5798,10 @@ class BuyBonusOverlay extends pixi_js.Container {
5519
5798
  header = new pixi_js.Container();
5520
5799
  strip = new pixi_js.Container(); // cards live here (drag-scrollable when wide)
5521
5800
  stripMask = new pixi_js.Graphics();
5801
+ /** "the strip moves" — the drawn marks over the card band. Unmasked and outside `strip`, so a
5802
+ * drag moves the cards under it while the marks hold still. Public for the same reason
5803
+ * ScrollBox.affordance is: it is the only readable account of what a player sees here. */
5804
+ scrollCue;
5522
5805
  footer = new pixi_js.Container();
5523
5806
  confirm;
5524
5807
  /** The bonus shown in the current confirm dialog (set by openConfirm, cleared by removeConfirm). */
@@ -5555,7 +5838,8 @@ class BuyBonusOverlay extends pixi_js.Container {
5555
5838
  super();
5556
5839
  this.host = host;
5557
5840
  this.bonuses = bonuses;
5558
- this.addChild(this.veil, this.strip, this.header, this.footer);
5841
+ this.scrollCue = new ScrollAffordanceView(host.ticker);
5842
+ this.addChild(this.veil, this.strip, this.scrollCue, this.header, this.footer);
5559
5843
  this.veil.eventMode = 'static';
5560
5844
  this.strip.mask = this.stripMask;
5561
5845
  this.addChild(this.stripMask);
@@ -5588,6 +5872,7 @@ class BuyBonusOverlay extends pixi_js.Container {
5588
5872
  this.strip.position.x = this.dragBaseX + this.dragX;
5589
5873
  else
5590
5874
  this.strip.position.y = this.dragBaseY + this.dragX;
5875
+ this.syncScrollCue();
5591
5876
  };
5592
5877
  onDragUp = () => {
5593
5878
  this.dragging = false;
@@ -5605,6 +5890,14 @@ class BuyBonusOverlay extends pixi_js.Container {
5605
5890
  this.dragging = false;
5606
5891
  this.strip.eventMode = 'auto'; // visual-only; its children (cards) are still hit-tested in-band
5607
5892
  this.strip.position.set(baseX, baseY);
5893
+ this.syncScrollCue();
5894
+ }
5895
+ /** Re-draw the band's scroll marks from the current drag state. `dragX` runs [-dragMax, 0] as the
5896
+ * strip is pulled, so the scroll position is its negation. */
5897
+ syncScrollCue() {
5898
+ if (this.destroyed || this.scrollCue.destroyed)
5899
+ return;
5900
+ this.scrollCue.update({ x: 0, y: this.bandTop, w: this.w, h: this.bandH, axis: this.dragAxis }, scrollHint({ scrollTop: -this.dragX, scrollHeight: this.bandH + this.dragMax, clientHeight: this.bandH }));
5608
5901
  }
5609
5902
  resize(w, h) {
5610
5903
  this.w = w;
@@ -7187,10 +7480,12 @@ function removePixiShell() {
7187
7480
 
7188
7481
  exports.DEFAULT_ACCENT = DEFAULT_ACCENT;
7189
7482
  exports.DEFAULT_MENU = DEFAULT_MENU;
7483
+ exports.DISCLAIMER_LINES = DISCLAIMER_LINES;
7190
7484
  exports.PACKAGE_VERSION = PACKAGE_VERSION;
7191
7485
  exports.POPOVER = POPOVER;
7192
7486
  exports.PixiRenderer = PixiRenderer;
7193
7487
  exports.SCHEMES = SCHEMES;
7488
+ exports.SCROLL_THUMB_MIN = SCROLL_THUMB_MIN;
7194
7489
  exports.ShellController = ShellController;
7195
7490
  exports.createI18n = createI18n;
7196
7491
  exports.createPixiShell = createPixiShell;
@@ -7204,6 +7499,8 @@ exports.removePixiShell = removePixiShell;
7204
7499
  exports.resolveConfig = resolveConfig;
7205
7500
  exports.resolveMenu = resolveMenu;
7206
7501
  exports.resolveTheme = resolveTheme;
7502
+ exports.scrollEdge = scrollEdge;
7503
+ exports.scrollHint = scrollHint;
7207
7504
  exports.seedMenuValues = seedMenuValues;
7208
7505
  exports.socialize = socialize;
7209
7506
  //# sourceMappingURL=pixi.cjs.js.map