@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.
- package/dist/html.cjs.js +3894 -0
- package/dist/html.cjs.js.map +1 -0
- package/dist/html.d.ts +616 -0
- package/dist/html.esm.js +3879 -0
- package/dist/html.esm.js.map +1 -0
- package/dist/index.cjs.js +1593 -0
- package/dist/index.cjs.js.map +1 -0
- package/dist/index.d.ts +554 -0
- package/dist/index.esm.js +1582 -0
- package/dist/index.esm.js.map +1 -0
- package/dist/pixi.cjs.js +5740 -0
- package/dist/pixi.cjs.js.map +1 -0
- package/dist/pixi.d.ts +682 -0
- package/dist/pixi.esm.js +5726 -0
- package/dist/pixi.esm.js.map +1 -0
- package/package.json +79 -0
- package/src/core/EventEmitter.ts +55 -0
- package/src/core/ShellController.ts +234 -0
- package/src/core/colors.ts +32 -0
- package/src/core/fonts-digits.ts +12 -0
- package/src/core/fonts.ts +13 -0
- package/src/core/format.ts +39 -0
- package/src/core/i18n.ts +96 -0
- package/src/core/index.ts +35 -0
- package/src/core/keyboard.ts +229 -0
- package/src/core/locales.ts +864 -0
- package/src/core/motion.ts +10 -0
- package/src/core/renderer.ts +121 -0
- package/src/core/state.ts +31 -0
- package/src/core/theme.ts +81 -0
- package/src/core/types.ts +269 -0
- package/src/core/version.ts +3 -0
- package/src/ui/html/HtmlRenderer.ts +292 -0
- package/src/ui/html/components/BottomBar.ts +240 -0
- package/src/ui/html/components/BuyBonus.ts +326 -0
- package/src/ui/html/components/GameInfo.ts +384 -0
- package/src/ui/html/components/Modal.ts +36 -0
- package/src/ui/html/components/ReplayModal.ts +58 -0
- package/src/ui/html/components/Settings.ts +59 -0
- package/src/ui/html/components/pickers.ts +146 -0
- package/src/ui/html/icons-preview.svg +224 -0
- package/src/ui/html/icons.ts +31 -0
- package/src/ui/html/index.ts +36 -0
- package/src/ui/html/motion-dom.ts +29 -0
- package/src/ui/html/primitives.ts +85 -0
- package/src/ui/html/shell.css.ts +528 -0
- package/src/ui/html/theme-css.ts +21 -0
- package/src/ui/pixi/PixiRenderer.ts +350 -0
- package/src/ui/pixi/components/BottomBar.ts +477 -0
- package/src/ui/pixi/components/BuyBonus.ts +763 -0
- package/src/ui/pixi/components/GameInfo.ts +559 -0
- package/src/ui/pixi/components/Modal.ts +40 -0
- package/src/ui/pixi/components/ReplayModal.ts +80 -0
- package/src/ui/pixi/components/Settings.ts +117 -0
- package/src/ui/pixi/components/pickers.ts +170 -0
- package/src/ui/pixi/context.ts +52 -0
- package/src/ui/pixi/icons.ts +45 -0
- package/src/ui/pixi/index.ts +56 -0
- package/src/ui/pixi/motion-pixi.ts +58 -0
- package/src/ui/pixi/pixi-icon.ts +119 -0
- package/src/ui/pixi/primitives/card.ts +227 -0
- package/src/ui/pixi/primitives/controls.ts +243 -0
- package/src/ui/pixi/primitives/flex.ts +335 -0
- package/src/ui/pixi/primitives/overlay.ts +181 -0
- package/src/ui/pixi/primitives/scroll.ts +117 -0
- package/src/ui/pixi/primitives/widgets.ts +680 -0
- 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 };
|