@energy8platform/shell 0.2.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.
Files changed (67) hide show
  1. package/dist/html.cjs.js +3894 -0
  2. package/dist/html.cjs.js.map +1 -0
  3. package/dist/html.d.ts +616 -0
  4. package/dist/html.esm.js +3879 -0
  5. package/dist/html.esm.js.map +1 -0
  6. package/dist/index.cjs.js +1593 -0
  7. package/dist/index.cjs.js.map +1 -0
  8. package/dist/index.d.ts +554 -0
  9. package/dist/index.esm.js +1582 -0
  10. package/dist/index.esm.js.map +1 -0
  11. package/dist/pixi.cjs.js +5740 -0
  12. package/dist/pixi.cjs.js.map +1 -0
  13. package/dist/pixi.d.ts +682 -0
  14. package/dist/pixi.esm.js +5726 -0
  15. package/dist/pixi.esm.js.map +1 -0
  16. package/package.json +79 -0
  17. package/src/core/EventEmitter.ts +55 -0
  18. package/src/core/ShellController.ts +234 -0
  19. package/src/core/colors.ts +32 -0
  20. package/src/core/fonts-digits.ts +12 -0
  21. package/src/core/fonts.ts +13 -0
  22. package/src/core/format.ts +39 -0
  23. package/src/core/i18n.ts +96 -0
  24. package/src/core/index.ts +35 -0
  25. package/src/core/keyboard.ts +229 -0
  26. package/src/core/locales.ts +864 -0
  27. package/src/core/motion.ts +10 -0
  28. package/src/core/renderer.ts +121 -0
  29. package/src/core/state.ts +31 -0
  30. package/src/core/theme.ts +81 -0
  31. package/src/core/types.ts +269 -0
  32. package/src/core/version.ts +3 -0
  33. package/src/ui/html/HtmlRenderer.ts +292 -0
  34. package/src/ui/html/components/BottomBar.ts +240 -0
  35. package/src/ui/html/components/BuyBonus.ts +326 -0
  36. package/src/ui/html/components/GameInfo.ts +384 -0
  37. package/src/ui/html/components/Modal.ts +36 -0
  38. package/src/ui/html/components/ReplayModal.ts +58 -0
  39. package/src/ui/html/components/Settings.ts +59 -0
  40. package/src/ui/html/components/pickers.ts +146 -0
  41. package/src/ui/html/icons-preview.svg +224 -0
  42. package/src/ui/html/icons.ts +31 -0
  43. package/src/ui/html/index.ts +36 -0
  44. package/src/ui/html/motion-dom.ts +29 -0
  45. package/src/ui/html/primitives.ts +85 -0
  46. package/src/ui/html/shell.css.ts +528 -0
  47. package/src/ui/html/theme-css.ts +21 -0
  48. package/src/ui/pixi/PixiRenderer.ts +350 -0
  49. package/src/ui/pixi/components/BottomBar.ts +477 -0
  50. package/src/ui/pixi/components/BuyBonus.ts +763 -0
  51. package/src/ui/pixi/components/GameInfo.ts +559 -0
  52. package/src/ui/pixi/components/Modal.ts +40 -0
  53. package/src/ui/pixi/components/ReplayModal.ts +80 -0
  54. package/src/ui/pixi/components/Settings.ts +117 -0
  55. package/src/ui/pixi/components/pickers.ts +170 -0
  56. package/src/ui/pixi/context.ts +52 -0
  57. package/src/ui/pixi/icons.ts +45 -0
  58. package/src/ui/pixi/index.ts +56 -0
  59. package/src/ui/pixi/motion-pixi.ts +58 -0
  60. package/src/ui/pixi/pixi-icon.ts +119 -0
  61. package/src/ui/pixi/primitives/card.ts +227 -0
  62. package/src/ui/pixi/primitives/controls.ts +243 -0
  63. package/src/ui/pixi/primitives/flex.ts +335 -0
  64. package/src/ui/pixi/primitives/overlay.ts +181 -0
  65. package/src/ui/pixi/primitives/scroll.ts +117 -0
  66. package/src/ui/pixi/primitives/widgets.ts +680 -0
  67. package/src/ui/pixi/text.ts +131 -0
@@ -0,0 +1,680 @@
1
+ import { BlurFilter, Color, Container, Graphics, Rectangle, Text, Ticker } from 'pixi.js';
2
+ import type { ShellTokens } from '@/core/theme';
3
+ import type { IconName } from '../icons';
4
+ import { makeIcon, IconView } from '../pixi-icon';
5
+ import { makeText, setText, NUM_FONT_FAMILY, NUM_FONT_SCALE } from '../text';
6
+ import { tween, type TweenOpts } from '../motion-pixi';
7
+ import { FlexBox, type Sizable } from './flex';
8
+
9
+ // Shared interactive primitives, ported from the DOM shell's CSS rules. Each widget reproduces
10
+ // one CSS class (.ge-iconbtn, .ge-rd, .ge-shell-spin, .ge-shell-buybonus, .ge-chip, .ge-slider …)
11
+ // — same sizes, colours, hover/press behaviour.
12
+
13
+ // ── interaction helpers ──────────────────────────────────────────────────────
14
+ /** grayscale(.5) brightness(.72) baked into a colour — the DOM's disabled-button filter applied to
15
+ * the fill directly. A ColorMatrixFilter would render the badge to a low-res texture and pixelate
16
+ * its ring; this keeps the geometry crisp. Pixi's Color parses named accents ('green') too. */
17
+ function dimColor(input: string): string {
18
+ let r: number, g: number, b: number;
19
+ try {
20
+ const [cr, cg, cb] = new Color(input).toRgbArray();
21
+ r = cr * 255;
22
+ g = cg * 255;
23
+ b = cb * 255;
24
+ } catch {
25
+ return input;
26
+ }
27
+ const luma = 0.299 * r + 0.587 * g + 0.114 * b;
28
+ const ch = (c: number): string =>
29
+ Math.round(Math.max(0, Math.min(255, (c + (luma - c) * 0.5) * 0.72)))
30
+ .toString(16)
31
+ .padStart(2, '0');
32
+ return `#${ch(r)}${ch(g)}${ch(b)}`;
33
+ }
34
+
35
+ export function attachHover(node: Container, over: () => void, out: () => void): void {
36
+ node.on('pointerover', over);
37
+ node.on('pointerout', out);
38
+ }
39
+
40
+ /** Press-scale on pointerdown, restore on up/out — the CSS `:active { transform:scale(x) }`.
41
+ * Scales around the visual centre (CSS transform-origin:50% 50%), not the top-left origin, and
42
+ * holds the hit area at full WORLD size while pressed — otherwise origin-scaling slid the box away
43
+ * from the pointer, so edge presses released off-target and only centre taps registered. */
44
+ export function attachPress(node: Container, scale: number, onTap: () => void): void {
45
+ let down = false;
46
+ let home: { x: number; y: number } | null = null;
47
+ let savedHit: Rectangle | null = null;
48
+ const box = (): { x: number; y: number; width: number; height: number } =>
49
+ node.hitArea instanceof Rectangle ? node.hitArea : node.getLocalBounds();
50
+ const press = (on: boolean): void => {
51
+ if (on) {
52
+ const b = box();
53
+ const cx = b.x + b.width / 2;
54
+ const cy = b.y + b.height / 2;
55
+ home = { x: node.x, y: node.y };
56
+ node.scale.set(scale);
57
+ node.position.set(home.x + cx * (1 - scale), home.y + cy * (1 - scale)); // keep centre fixed
58
+ if (node.hitArea instanceof Rectangle) {
59
+ savedHit = node.hitArea;
60
+ const inv = 1 / scale; // grow local box so its world size is unchanged → edge taps still land
61
+ node.hitArea = new Rectangle(cx - (b.width * inv) / 2, cy - (b.height * inv) / 2, b.width * inv, b.height * inv);
62
+ }
63
+ } else if (home) {
64
+ node.scale.set(1);
65
+ node.position.set(home.x, home.y);
66
+ if (savedHit) node.hitArea = savedHit;
67
+ savedHit = null;
68
+ home = null;
69
+ }
70
+ };
71
+ node.on('pointerdown', () => {
72
+ down = true;
73
+ press(true);
74
+ });
75
+ const release = (): void => {
76
+ if (down) press(false);
77
+ down = false;
78
+ };
79
+ node.on('pointerup', release);
80
+ node.on('pointerupoutside', release);
81
+ node.on('pointertap', () => onTap());
82
+ }
83
+
84
+ // ── IconButton — .ge-iconbtn (borderless icon button) ────────────────────────
85
+ export interface IconButtonOpts {
86
+ size?: number; // box (CSS default 40×40)
87
+ glyph?: number; // glyph font-size (CSS default 24)
88
+ color: string; // resting colour (token.icon, or white in plaques)
89
+ hover: string; // hover colour (token.accent)
90
+ activeColor?: string; // .ge-active colour (token.iconActive)
91
+ active?: boolean;
92
+ /** White-disc style (auto/turbo on the desktop panel): a `disc` fill + `border` ring. Hover/active
93
+ * tint BOTH the icon and the ring with `hover`. Omit for a plain borderless icon (menu, +/-). */
94
+ disc?: string;
95
+ discBorder?: number;
96
+ onTap?: () => void;
97
+ }
98
+
99
+ export class IconButton extends Container implements Sizable {
100
+ private box: number;
101
+ private view: IconView;
102
+ private _color: string;
103
+ private hoverColor: string;
104
+ private activeColor: string;
105
+ private _active: boolean;
106
+ private _disabled = false;
107
+ private _name: IconName;
108
+ private discFill?: string;
109
+ private discBorder: number;
110
+ private discG?: Graphics;
111
+
112
+ constructor(name: IconName, opts: IconButtonOpts) {
113
+ super();
114
+ this._name = name;
115
+ this.box = opts.size ?? 40;
116
+ this._color = opts.color;
117
+ this.hoverColor = opts.hover;
118
+ this.activeColor = opts.activeColor ?? opts.color;
119
+ this._active = opts.active ?? false;
120
+ this.discFill = opts.disc;
121
+ this.discBorder = opts.discBorder ?? 2;
122
+ const glyph = opts.glyph ?? 24;
123
+ if (this.discFill) {
124
+ this.discG = new Graphics();
125
+ this.addChild(this.discG);
126
+ }
127
+ this.view = makeIcon(name, glyph, this._active ? this.activeColor : this._color);
128
+ this.view.position.set((this.box - glyph) / 2, (this.box - glyph) / 2);
129
+ this.addChild(this.view);
130
+ this.eventMode = 'static';
131
+ this.cursor = 'pointer';
132
+ this.hitArea = new Rectangle(0, 0, this.box, this.box);
133
+ this.paint(false);
134
+ attachHover(this, () => this.paint(true), () => this.paint(false));
135
+ attachPress(this, 0.92, () => {
136
+ if (!this._disabled) opts.onTap?.();
137
+ });
138
+ }
139
+
140
+ private _glow = false;
141
+
142
+ private paint(hovering: boolean): void {
143
+ if (this._disabled && !this.discG) return;
144
+ const lit = hovering || this._active || this._glow;
145
+ const c = this._disabled
146
+ ? this._color
147
+ : hovering || this._glow ? this.hoverColor : this._active ? this.activeColor : this._color;
148
+ this.view.setColor(c);
149
+ if (this.discG && this.discFill) {
150
+ // white disc + black ring; lit (hover/active) → accent ring (mirrors `.ge-bar-panel .ge-iconbtn`)
151
+ const r = this.box / 2 - this.discBorder / 2;
152
+ this.discG.clear();
153
+ this.discG.circle(this.box / 2, this.box / 2, r).fill(this.discFill);
154
+ this.discG.stroke({ color: !this._disabled && lit ? this.hoverColor : '#000000', width: this.discBorder });
155
+ }
156
+ }
157
+
158
+ setIcon(name: IconName): void {
159
+ if (name === this._name) return;
160
+ this._name = name;
161
+ const glyph = this.view.size;
162
+ this.removeChild(this.view);
163
+ this.view.destroy();
164
+ this.view = makeIcon(name, glyph, this._active ? this.activeColor : this._color);
165
+ this.view.position.set((this.box - glyph) / 2, (this.box - glyph) / 2);
166
+ this.addChild(this.view);
167
+ }
168
+
169
+ set active(v: boolean) {
170
+ this._active = v;
171
+ this.paint(false);
172
+ }
173
+ get active(): boolean {
174
+ return this._active;
175
+ }
176
+
177
+ set disabled(v: boolean) {
178
+ this._disabled = v;
179
+ this.alpha = v ? 0.35 : 1;
180
+ this.cursor = v ? 'default' : 'pointer';
181
+ this.eventMode = v ? 'none' : 'static';
182
+ }
183
+ get disabled(): boolean {
184
+ return this._disabled;
185
+ }
186
+
187
+ /** Accent recolour while autoplay runs — `.ge-iconbtn.ge-glow` (the CSS also adds a
188
+ * drop-shadow; the colour change carries the state legibly here). */
189
+ setGlow(on: boolean): void {
190
+ this._glow = on;
191
+ this.paint(false);
192
+ }
193
+
194
+ setColors(color: string, hover: string, activeColor?: string): void {
195
+ this._color = color;
196
+ this.hoverColor = hover;
197
+ if (activeColor) this.activeColor = activeColor;
198
+ this.paint(false);
199
+ }
200
+
201
+ setLayoutSize(): void {
202
+ /* fixed box */
203
+ }
204
+ measureSize(): { w: number; h: number } {
205
+ return { w: this.box, h: this.box };
206
+ }
207
+ }
208
+
209
+ // ── Readout — .ge-rd (label over value) ──────────────────────────────────────
210
+ export interface ReadoutOpts {
211
+ label: string;
212
+ value: string;
213
+ muted: string; // label colour
214
+ fg: string; // value colour
215
+ align?: 'left' | 'center' | 'right';
216
+ shadow?: boolean; // floating readouts have a text-shadow; plaque ones don't
217
+ valueSize?: number; // value font-size BEFORE the Oswald 1.15× bump (default 13)
218
+ /** Fixed slot width — the value shrinks to fit it (no jiggle on +/-, mirrors the DOM `.ge-rd-val`
219
+ * fit). The label aligns within this width too. Omit for content sizing. */
220
+ fixedWidth?: number;
221
+ /** Max slot width — like fixedWidth but only CAPS: the readout is content-sized until it would
222
+ * exceed maxWidth, then the value shrinks to fit (mirrors the DOM's shrinkable total-win slot).
223
+ * Ignored when fixedWidth is set. */
224
+ maxWidth?: number;
225
+ }
226
+
227
+ export class Readout extends Container implements Sizable {
228
+ readonly valueText: Text;
229
+ private labelText: Text;
230
+ private align: 'left' | 'center' | 'right';
231
+ private fixedW?: number;
232
+ private maxW?: number;
233
+ // Value baseline ascent at scale 1 (for stable bottom alignment as the value scales down).
234
+ private valueH: number;
235
+
236
+ constructor(opts: ReadoutOpts) {
237
+ super();
238
+ this.align = opts.align ?? 'left';
239
+ this.fixedW = opts.fixedWidth;
240
+ this.maxW = opts.maxWidth;
241
+ this.labelText = makeText(opts.label, {
242
+ size: 9,
243
+ weight: '600',
244
+ color: opts.muted,
245
+ letterSpacing: 0.9, // .1em at 9px
246
+ upper: true,
247
+ });
248
+ // The VALUE uses the Oswald numeral font, bumped 1.15× (mirrors `.ge-rd-val`).
249
+ this.valueText = makeText(opts.value, {
250
+ size: Math.round((opts.valueSize ?? 13) * NUM_FONT_SCALE),
251
+ family: NUM_FONT_FAMILY,
252
+ weight: '700',
253
+ color: opts.fg,
254
+ shadow: opts.shadow,
255
+ });
256
+ this.valueH = this.valueText.height;
257
+ this.addChild(this.labelText, this.valueText);
258
+ this.relayout();
259
+ }
260
+
261
+ private relayout(): void {
262
+ // shrink the value to fit its slot (transform-scale, like the DOM fit): a fixed slot always,
263
+ // a max slot only when the value would overflow it. Content-sized otherwise.
264
+ this.valueText.scale.set(1);
265
+ const cap = this.fixedW ?? this.maxW;
266
+ if (cap != null && this.valueText.width > cap && this.valueText.width > 0) {
267
+ this.valueText.scale.set(cap / this.valueText.width);
268
+ }
269
+ const vW = this.valueText.width; // scaled width (== cap when it overflowed a max/fixed slot)
270
+ const w = this.fixedW ?? Math.max(this.labelText.width, vW);
271
+ const x = (tw: number): number => (this.align === 'center' ? (w - tw) / 2 : this.align === 'right' ? w - tw : 0);
272
+ this.labelText.position.set(x(this.labelText.width), 0);
273
+ this.valueText.position.set(x(vW), this.labelText.height + 4);
274
+ }
275
+
276
+ setValue(v: string): void {
277
+ setText(this.valueText, v);
278
+ this.relayout();
279
+ }
280
+
281
+ setLabel(v: string): void {
282
+ setText(this.labelText, v, true);
283
+ this.relayout();
284
+ }
285
+
286
+ setColors(label: string, value: string): void {
287
+ this.labelText.style.fill = label;
288
+ this.valueText.style.fill = value;
289
+ }
290
+
291
+ setLayoutSize(): void {
292
+ /* content sized (or fixed via fixedWidth) */
293
+ }
294
+ measureSize(): { w: number; h: number } {
295
+ const w = this.fixedW ?? Math.max(this.labelText.width, this.valueText.width);
296
+ return { w, h: this.labelText.height + 4 + this.valueH };
297
+ }
298
+ }
299
+
300
+ // ── circular bordered disc helper (SPIN + BUY BONUS share the black-rim coin look) ──
301
+ function drawDisc(g: Graphics, size: number, fill: string, border = 3): void {
302
+ g.clear();
303
+ g.circle(size / 2, size / 2, size / 2 - border / 2);
304
+ g.fill(fill);
305
+ g.stroke({ color: '#000000', width: border });
306
+ }
307
+
308
+ /** A glow Graphics with a soft blur baked in — draw an accent circle into it and it reads as a halo. */
309
+ function makeGlow(): Graphics {
310
+ const g = new Graphics();
311
+ try {
312
+ g.filters = [new BlurFilter({ strength: 6, quality: 3 })];
313
+ } catch {
314
+ /* no WebGL (jsdom tests) → unfiltered; tests don't render so the filter is irrelevant there */
315
+ }
316
+ return g;
317
+ }
318
+
319
+ /** BUY BONUS hover: a SOFT accent glow only — `box-shadow: 0 0 11px 1px accent` (no solid ring). */
320
+ function drawGlow(glow: Graphics, size: number, accent: string, on: boolean): void {
321
+ glow.clear();
322
+ if (!on) return;
323
+ const r = size / 2;
324
+ glow.circle(r, r, r + 6).fill({ color: accent, alpha: 0.7 }); // blurred by the glow's filter → halo
325
+ }
326
+
327
+ // ── SpinDisc — .ge-shell-spin ────────────────────────────────────────────────
328
+ export interface SpinDiscOpts {
329
+ size?: number; // 86 desktop / 84 mobile
330
+ glyph?: number; // 68 / 66
331
+ tokens: ShellTokens;
332
+ ticker: Ticker;
333
+ onSpin: () => void;
334
+ onStop: () => void;
335
+ }
336
+
337
+ export class SpinDisc extends Container implements Sizable {
338
+ private size: number;
339
+ private glyphSize: number;
340
+ private tokens: ShellTokens;
341
+ private ticker: Ticker;
342
+ private glow = makeGlow();
343
+ private ring = new Graphics();
344
+ private disc: Graphics;
345
+ private glyph: IconView;
346
+ private dim = new Graphics();
347
+ private countText?: Text;
348
+ private mode: 'spin' | 'stop' = 'spin';
349
+ private _busy = false;
350
+ private _disabled = false;
351
+ private hovering = false;
352
+ private rotTick?: (t: Ticker) => void;
353
+ private onSpin: () => void;
354
+ private onStop: () => void;
355
+
356
+ constructor(opts: SpinDiscOpts) {
357
+ super();
358
+ this.size = opts.size ?? 84;
359
+ this.glyphSize = opts.glyph ?? 65;
360
+ this.tokens = opts.tokens;
361
+ this.ticker = opts.ticker;
362
+ this.onSpin = opts.onSpin;
363
+ this.onStop = opts.onStop;
364
+ this.disc = new Graphics();
365
+ this.glyph = makeIcon('spin', this.glyphSize, this.tokens.btnInk);
366
+ this.glyph.position.set((this.size - this.glyphSize) / 2, (this.size - this.glyphSize) / 2);
367
+ this.addChild(this.glow, this.ring, this.disc, this.glyph, this.dim);
368
+ this.paint();
369
+ this.eventMode = 'static';
370
+ this.cursor = 'pointer';
371
+ this.hitArea = new Rectangle(0, 0, this.size, this.size);
372
+ attachHover(this, () => {
373
+ this.hovering = true;
374
+ this.paint();
375
+ }, () => {
376
+ this.hovering = false;
377
+ this.paint();
378
+ });
379
+ attachPress(this, 0.94, () => {
380
+ if (this._disabled) return;
381
+ if (this.mode === 'stop') this.onStop();
382
+ else this.onSpin();
383
+ });
384
+ }
385
+
386
+ private paint(): void {
387
+ const hot = this.hovering && !this._disabled && this.mode === 'spin';
388
+ // white disc, black 4px ring; hover tints ONLY the glyph accent (no fill/halo) — `.ge-bar-panel
389
+ // .ge-shell-spin`. The hero pops above/below the bar (sized by the caller).
390
+ drawDisc(this.disc, this.size, this.tokens.btn, 4);
391
+ const glyphColor = hot ? this.tokens.accent : this.tokens.btnInk;
392
+ this.glyph.setColor(glyphColor);
393
+ if (this.countText) this.countText.style.fill = hot ? this.tokens.accent : this.tokens.btnInk;
394
+ // Disabled (mid-spin / can't spin): darken OPAQUELY (≈ filter:grayscale(.4) brightness(.62))
395
+ // with a dark veil over the disc — not alpha, which would let the bright board show through and
396
+ // read as a translucent/missing button (what looked "transparent" while spinning).
397
+ this.dim.clear();
398
+ if (this._disabled) {
399
+ this.dim.circle(this.size / 2, this.size / 2, this.size / 2 - 1.5).fill({ color: 0x000000, alpha: 0.4 });
400
+ }
401
+ }
402
+
403
+ /** STOP glyph + remaining-count, when autoplay runs. */
404
+ setAutoplay(active: boolean, remaining: number): void {
405
+ if (active) {
406
+ this.mode = 'stop';
407
+ this.stopRotation();
408
+ // STOP glyph at the disc's full size (like SPIN); the count is centred on top of it.
409
+ this.glyph.visible = false;
410
+ this.stopGlyph();
411
+ if (!this.countText) {
412
+ this.countText = makeText('', { size: 22, weight: '800', color: this.tokens.btnInk, align: 'center', family: NUM_FONT_FAMILY });
413
+ this.addChild(this.countText); // added after the STOP glyph → renders on top of it
414
+ }
415
+ const label = Number.isFinite(remaining) ? String(remaining) : '∞';
416
+ setText(this.countText, label);
417
+ this.countText.position.set((this.size - this.countText.width) / 2, (this.size - this.countText.height) / 2);
418
+ } else {
419
+ this.mode = 'spin';
420
+ this.glyph.visible = true;
421
+ this.removeStopGlyph();
422
+ if (this.countText) {
423
+ this.removeChild(this.countText);
424
+ this.countText.destroy();
425
+ this.countText = undefined;
426
+ }
427
+ }
428
+ this.paint();
429
+ }
430
+
431
+ private stopGlyphView?: IconView;
432
+ private stopGlyph(): void {
433
+ if (!this.stopGlyphView) {
434
+ this.stopGlyphView = makeIcon('stop', this.glyphSize, this.tokens.btnInk);
435
+ this.stopGlyphView.position.set((this.size - this.glyphSize) / 2, (this.size - this.glyphSize) / 2);
436
+ this.addChild(this.stopGlyphView);
437
+ }
438
+ }
439
+ private removeStopGlyph(): void {
440
+ if (this.stopGlyphView) {
441
+ this.removeChild(this.stopGlyphView);
442
+ this.stopGlyphView.destroy();
443
+ this.stopGlyphView = undefined;
444
+ }
445
+ }
446
+
447
+ setBusy(busy: boolean): void {
448
+ this._busy = busy;
449
+ if (busy && this.mode === 'spin') this.startRotation();
450
+ else this.stopRotation();
451
+ }
452
+
453
+ private startRotation(): void {
454
+ if (this.rotTick) return;
455
+ this.rotTick = (t: Ticker) => {
456
+ this.glyph.spin += (t.deltaMS / 800) * Math.PI * 2; // .8s/rev linear (CSS ge-spin-rot)
457
+ };
458
+ this.ticker.add(this.rotTick);
459
+ }
460
+ private stopRotation(): void {
461
+ if (this.rotTick) {
462
+ this.ticker.remove(this.rotTick);
463
+ this.rotTick = undefined;
464
+ }
465
+ this.glyph.spin = 0;
466
+ }
467
+
468
+ set disabled(v: boolean) {
469
+ this._disabled = v;
470
+ this.cursor = v ? 'default' : 'pointer';
471
+ this.eventMode = v ? 'none' : 'static';
472
+ this.paint();
473
+ }
474
+ get busy(): boolean {
475
+ return this._busy;
476
+ }
477
+
478
+ destroy(options?: Parameters<Container['destroy']>[0]): void {
479
+ this.stopRotation(); // remove the rotation ticker callback before the glyph is torn down
480
+ super.destroy(options);
481
+ }
482
+
483
+ setLayoutSize(): void {
484
+ /* fixed */
485
+ }
486
+ measureSize(): { w: number; h: number } {
487
+ return { w: this.size, h: this.size };
488
+ }
489
+ }
490
+
491
+ // ── BuyBonusBadge — .ge-shell-buybonus ───────────────────────────────────────
492
+ export interface BuyBonusOpts {
493
+ size?: number; // 80 / 50
494
+ fontSize?: number; // 13 / 9
495
+ border?: number; // 3 / 2
496
+ bg: string; // accent
497
+ fg?: string; // text colour (#fff, or contrast in feature mode)
498
+ label: string; // "DISABLE" (feature mode) — ignored when `icon` is set
499
+ /** Show this icon (the ticket) instead of the text label — the BUY state. */
500
+ icon?: IconName;
501
+ iconSize?: number; // glyph px (≈ size * 0.62)
502
+ iconColor?: string; // ticket ink (black)
503
+ tokens: ShellTokens;
504
+ ticker: Ticker;
505
+ onTap: () => void;
506
+ }
507
+
508
+ export class BuyBonusBadge extends Container implements Sizable {
509
+ private size: number;
510
+ private disc: Graphics;
511
+ private labelText?: Text;
512
+ private iconView?: IconView;
513
+ private content: Container; // labelText or iconView — the pulsed node
514
+ private bg: string;
515
+ private fg: string;
516
+ private border: number;
517
+ private ticker: Ticker;
518
+ private _disabled = false;
519
+ private pulseCancel?: () => void;
520
+ private glow = makeGlow();
521
+
522
+ constructor(opts: BuyBonusOpts) {
523
+ super();
524
+ this.size = opts.size ?? 80;
525
+ this.border = opts.border ?? 3;
526
+ this.bg = opts.bg;
527
+ this.fg = opts.fg ?? '#ffffff';
528
+ this.ticker = opts.ticker;
529
+ this.disc = new Graphics();
530
+ this.addChild(this.glow, this.disc);
531
+ if (opts.icon) {
532
+ const gs = opts.iconSize ?? this.size * 0.62;
533
+ this.iconView = makeIcon(opts.icon, gs, opts.iconColor ?? '#0b0e16');
534
+ this.iconView.pivot.set(gs / 2, gs / 2); // pulse/scale around the glyph centre
535
+ this.iconView.position.set(this.size / 2, this.size / 2);
536
+ this.content = this.iconView;
537
+ this.addChild(this.iconView);
538
+ } else {
539
+ this.labelText = makeText(opts.label, {
540
+ size: opts.fontSize ?? 13,
541
+ weight: '800',
542
+ color: opts.fg ?? '#ffffff',
543
+ letterSpacing: (opts.fontSize ?? 13) * 0.02,
544
+ align: 'center',
545
+ lineHeight: (opts.fontSize ?? 13) * 1.08,
546
+ });
547
+ this.labelText.anchor.set(0.5);
548
+ this.labelText.position.set(this.size / 2, this.size / 2);
549
+ this.content = this.labelText;
550
+ this.addChild(this.labelText);
551
+ }
552
+ this.paint(false);
553
+ this.eventMode = 'static';
554
+ this.cursor = 'pointer';
555
+ this.hitArea = new Rectangle(0, 0, this.size, this.size);
556
+ attachHover(this, () => this.paint(true), () => this.paint(false));
557
+ attachPress(this, 0.96, () => {
558
+ if (!this._disabled) opts.onTap();
559
+ });
560
+ }
561
+
562
+ private paint(hovering: boolean): void {
563
+ // disabled → grayscale(.5) brightness(.72) baked into the fill/label (no filter → crisp)
564
+ drawDisc(this.disc, this.size, this._disabled ? dimColor(this.bg) : this.bg, this.border);
565
+ if (this.labelText) this.labelText.style.fill = this._disabled ? dimColor(this.fg) : this.fg;
566
+ const on = hovering && !this._disabled;
567
+ drawGlow(this.glow, this.size, this.bg, on); // soft accent glow only (no ring) — `.ge-shell-buybonus:hover`
568
+ if (on) this.startPulse();
569
+ else this.stopPulse();
570
+ }
571
+
572
+ private startPulse(): void {
573
+ if (this.pulseCancel) return;
574
+ // both the label (anchor 0.5) and the icon (pivot centred) scale around their centre
575
+ const loop = (): void => {
576
+ this.pulseCancel = tween(this.ticker, {
577
+ duration: 700,
578
+ ease: (p) => p,
579
+ onUpdate: (p) => {
580
+ const tri = p < 0.5 ? p * 2 : (1 - p) * 2; // 0→1→0 triangle → scale 1..1.16 (ge-bb-pulse)
581
+ this.content.scale.set(1 + 0.16 * tri);
582
+ },
583
+ onComplete: () => {
584
+ this.pulseCancel = undefined;
585
+ loop();
586
+ },
587
+ });
588
+ };
589
+ loop();
590
+ }
591
+ private stopPulse(): void {
592
+ this.pulseCancel?.();
593
+ this.pulseCancel = undefined;
594
+ this.content.scale.set(1);
595
+ }
596
+
597
+ setColors(bg: string, fg: string): void {
598
+ this.bg = bg;
599
+ if (this.labelText) this.labelText.style.fill = fg;
600
+ this.paint(false);
601
+ }
602
+
603
+ setLabel(label: string): void {
604
+ if (this.labelText) setText(this.labelText, label);
605
+ }
606
+
607
+ set disabled(v: boolean) {
608
+ this._disabled = v;
609
+ this.cursor = v ? 'default' : 'pointer';
610
+ this.eventMode = v ? 'none' : 'static';
611
+ this.paint(false); // disabled look is baked into the fill/label (see paint) — crisp, no filter
612
+ }
613
+
614
+ setLayoutSize(): void {
615
+ /* fixed */
616
+ }
617
+ measureSize(): { w: number; h: number } {
618
+ return { w: this.size, h: this.size };
619
+ }
620
+
621
+ destroy(options?: Parameters<Container['destroy']>[0]): void {
622
+ this.stopPulse(); // cancel the hover-pulse tween (ticker) before teardown
623
+ super.destroy(options);
624
+ }
625
+ }
626
+
627
+ // ── FsHero — .ge-fs-hero (FS counter in the SPIN slot: white/black-ring RECTANGLE) ──
628
+ export interface FsHeroOpts {
629
+ label: string; // localized "Free spins"
630
+ value: string; // "3 / 10"
631
+ tokens: ShellTokens;
632
+ height?: number; // 84 — matches the SPIN disc so it pops above/below the bar
633
+ minWidth?: number; // 96
634
+ }
635
+
636
+ export class FsHero extends Container implements Sizable {
637
+ private w: number;
638
+ private h: number;
639
+ private bgG = new Graphics();
640
+
641
+ constructor(opts: FsHeroOpts) {
642
+ super();
643
+ this.h = opts.height ?? 84;
644
+ const minW = opts.minWidth ?? 96;
645
+ const padX = 18;
646
+ const gap = 3;
647
+ // label wraps to ≤2 lines for long locales ("Бесплатные вращения")
648
+ const label = makeText(opts.label, {
649
+ size: 9, weight: '700', color: '#454c5a', letterSpacing: 0.72, upper: true,
650
+ align: 'center', wrapWidth: minW - padX * 2,
651
+ });
652
+ const num = makeText(opts.value, { size: 30, weight: '700', color: opts.tokens.btnInk, family: NUM_FONT_FAMILY, align: 'center' });
653
+ const contentW = Math.max(label.width, num.width);
654
+ this.w = Math.max(minW, contentW + padX * 2);
655
+ this.bgG.roundRect(0, 0, this.w, this.h, 20).fill(opts.tokens.btn).stroke({ color: '#000000', width: 4 });
656
+ const stackH = label.height + gap + num.height;
657
+ const top = (this.h - stackH) / 2;
658
+ label.position.set((this.w - label.width) / 2, top);
659
+ num.position.set((this.w - num.width) / 2, top + label.height + gap);
660
+ this.addChild(this.bgG, label, num);
661
+ }
662
+
663
+ measureSize(): { w: number; h: number } {
664
+ return { w: this.w, h: this.h };
665
+ }
666
+ setLayoutSize(): void {
667
+ /* fixed */
668
+ }
669
+ }
670
+
671
+ // ── thin vertical divider — .ge-pl-divider ───────────────────────────────────
672
+ export function divider(tokens: ShellTokens, height = 30): Graphics {
673
+ const g = new Graphics();
674
+ g.rect(0, 0, 1, height);
675
+ g.fill(tokens.plaqueLine);
676
+ return g;
677
+ }
678
+
679
+ export { FlexBox };
680
+ export type { Sizable, TweenOpts };