@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,763 @@
|
|
|
1
|
+
import { Assets, Container, Graphics, Rectangle, Sprite, Text, Texture, type FederatedPointerEvent } from 'pixi.js';
|
|
2
|
+
import type { PixiComponentContext, ShellLayer } from '../context';
|
|
3
|
+
import type { BonusOption } from '@/core/types';
|
|
4
|
+
import { betDir } from '@/core/keyboard';
|
|
5
|
+
import { effectiveAccent, contrastText } from '@/core/colors';
|
|
6
|
+
import { makeText } from '../text';
|
|
7
|
+
import { makeIcon, makeRingedIcon } from '../pixi-icon';
|
|
8
|
+
import { navButton } from '../primitives/overlay';
|
|
9
|
+
import { clamp } from '../primitives/overlay';
|
|
10
|
+
import { attachHover } from '../primitives/widgets';
|
|
11
|
+
import { roundedPath } from '../primitives/flex';
|
|
12
|
+
|
|
13
|
+
/** Buy-bonus overlay — art-forward cards (one per option), a live bet footer, and a confirm modal.
|
|
14
|
+
* Returns null when there are no bonus options. */
|
|
15
|
+
export function openBuyBonus(host: PixiComponentContext): ShellLayer | null {
|
|
16
|
+
const bonuses = host.config.features.buyBonus;
|
|
17
|
+
if (bonuses === false || bonuses.length === 0) return null;
|
|
18
|
+
return new BuyBonusOverlay(host, bonuses);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Pairing of a rendered CardView with its bonus data (affordability included). */
|
|
22
|
+
interface CardEntry {
|
|
23
|
+
view: CardView;
|
|
24
|
+
bonus: BonusOption;
|
|
25
|
+
affordable: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
class BuyBonusOverlay extends Container implements ShellLayer {
|
|
29
|
+
readonly tag = 'buybonus';
|
|
30
|
+
private host: PixiComponentContext;
|
|
31
|
+
private bonuses: BonusOption[];
|
|
32
|
+
private veil = new Graphics();
|
|
33
|
+
private header = new Container();
|
|
34
|
+
private strip = new Container(); // cards live here (drag-scrollable when wide)
|
|
35
|
+
private stripMask = new Graphics();
|
|
36
|
+
private footer = new Container();
|
|
37
|
+
private confirm?: Container;
|
|
38
|
+
/** The bonus shown in the current confirm dialog (set by openConfirm, cleared by removeConfirm). */
|
|
39
|
+
private confirmBonus?: BonusOption;
|
|
40
|
+
private w = 0;
|
|
41
|
+
private h = 0;
|
|
42
|
+
private headerH = 44;
|
|
43
|
+
private footerH = 44;
|
|
44
|
+
private chromeScale = 1; // header + bet footer shrink with the frame on short popouts (see resize)
|
|
45
|
+
/** All card entries (view + bonus + affordable flag), rebuilt by buildCards(). */
|
|
46
|
+
private cardEntries: CardEntry[] = [];
|
|
47
|
+
/** Keyboard focus index into the affordable subset of cardEntries. -1 = none. */
|
|
48
|
+
private focusIndex = -1;
|
|
49
|
+
// Drag-scroll state. The drag is handled on the (unmasked) overlay, not the masked strip — a mask
|
|
50
|
+
// prunes pointer events outside its band, stalling globalpointermove mid-drag so later cards never
|
|
51
|
+
// scroll into reach. The overlay sees the whole screen, so the scroll runs the full range.
|
|
52
|
+
private dragX = 0;
|
|
53
|
+
private dragAxis: 'x' | 'y' = 'y';
|
|
54
|
+
private dragMax = 0;
|
|
55
|
+
private dragBaseX = 0;
|
|
56
|
+
private dragBaseY = 0;
|
|
57
|
+
private bandTop = 0;
|
|
58
|
+
private bandH = 0;
|
|
59
|
+
private dragging = false;
|
|
60
|
+
private dragFrom = 0;
|
|
61
|
+
private dragBase = 0;
|
|
62
|
+
private dragged = false; // a tap that actually moved → suppress the card select
|
|
63
|
+
|
|
64
|
+
constructor(host: PixiComponentContext, bonuses: BonusOption[]) {
|
|
65
|
+
super();
|
|
66
|
+
this.host = host;
|
|
67
|
+
this.bonuses = bonuses;
|
|
68
|
+
this.addChild(this.veil, this.strip, this.header, this.footer);
|
|
69
|
+
this.veil.eventMode = 'static';
|
|
70
|
+
this.strip.mask = this.stripMask;
|
|
71
|
+
this.addChild(this.stripMask);
|
|
72
|
+
this.eventMode = 'static';
|
|
73
|
+
this.on('pointerdown', this.onDragDown);
|
|
74
|
+
this.on('globalpointermove', this.onDragMove);
|
|
75
|
+
this.on('pointerup', this.onDragUp);
|
|
76
|
+
this.on('pointerupoutside', this.onDragUp);
|
|
77
|
+
this.resize(host.screenW, host.screenH);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private onDragDown = (e: FederatedPointerEvent): void => {
|
|
81
|
+
if (this.confirm || this.dragMax <= 0) return;
|
|
82
|
+
const gy = e.global.y;
|
|
83
|
+
if (gy < this.bandTop || gy > this.bandTop + this.bandH) return; // only within the card band
|
|
84
|
+
this.dragging = true;
|
|
85
|
+
this.dragged = false;
|
|
86
|
+
this.dragFrom = this.dragAxis === 'x' ? e.global.x : e.global.y;
|
|
87
|
+
this.dragBase = this.dragX;
|
|
88
|
+
};
|
|
89
|
+
private onDragMove = (e: FederatedPointerEvent): void => {
|
|
90
|
+
if (!this.dragging) return;
|
|
91
|
+
const cur = this.dragAxis === 'x' ? e.global.x : e.global.y;
|
|
92
|
+
if (Math.abs(cur - this.dragFrom) > 4) this.dragged = true;
|
|
93
|
+
this.dragX = Math.max(-this.dragMax, Math.min(0, this.dragBase + (cur - this.dragFrom)));
|
|
94
|
+
if (this.dragAxis === 'x') this.strip.position.x = this.dragBaseX + this.dragX;
|
|
95
|
+
else this.strip.position.y = this.dragBaseY + this.dragX;
|
|
96
|
+
};
|
|
97
|
+
private onDragUp = (): void => {
|
|
98
|
+
this.dragging = false;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/** Configure the scroll for the current layout (axis, the strip's resting position, the card band
|
|
102
|
+
* the drag is allowed to start in, and the max travel). The overlay's listeners read these. */
|
|
103
|
+
private setDragRange(axis: 'x' | 'y', baseX: number, baseY: number, bandTop: number, bandH: number, max: number): void {
|
|
104
|
+
this.dragAxis = axis;
|
|
105
|
+
this.dragBaseX = baseX;
|
|
106
|
+
this.dragBaseY = baseY;
|
|
107
|
+
this.bandTop = bandTop;
|
|
108
|
+
this.bandH = bandH;
|
|
109
|
+
this.dragMax = Math.max(0, max);
|
|
110
|
+
this.dragX = 0;
|
|
111
|
+
this.dragging = false;
|
|
112
|
+
this.strip.eventMode = 'auto'; // visual-only; its children (cards) are still hit-tested in-band
|
|
113
|
+
this.strip.position.set(baseX, baseY);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
resize(w: number, h: number): void {
|
|
117
|
+
this.w = w;
|
|
118
|
+
this.h = h;
|
|
119
|
+
// Chrome (header + bet footer) scales with the frame height: full size by Popout L (≈450px tall),
|
|
120
|
+
// shrinking to 0.72 on a short Popout S so it doesn't dwarf the (already shrunk) cards. Mirrors the
|
|
121
|
+
// DOM shell's vh-clamped buy-bonus chrome.
|
|
122
|
+
this.chromeScale = clamp(0.72, h / 450, 1);
|
|
123
|
+
this.headerH = 44 * this.chromeScale;
|
|
124
|
+
this.footerH = 46 * this.chromeScale;
|
|
125
|
+
this.veil.clear();
|
|
126
|
+
this.veil.rect(0, 0, w, h).fill(this.host.tokens.backdrop);
|
|
127
|
+
this.veil.hitArea = new Rectangle(0, 0, w, h);
|
|
128
|
+
this.buildHeader();
|
|
129
|
+
this.buildCards();
|
|
130
|
+
this.buildFooter();
|
|
131
|
+
if (this.confirm) {
|
|
132
|
+
this.removeConfirm();
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private buildHeader(): void {
|
|
137
|
+
this.header.removeChildren().forEach((c) => c.destroy({ children: true }));
|
|
138
|
+
const sc = this.chromeScale;
|
|
139
|
+
const pad = 10 * sc;
|
|
140
|
+
const navSz = 32 * sc;
|
|
141
|
+
const close = navButton(this.host, 'close', () => this.host.closeLayer(), navSz);
|
|
142
|
+
const titleSize = 16 * sc;
|
|
143
|
+
const title = makeText(this.host.t('Buy bonus'), {
|
|
144
|
+
size: titleSize,
|
|
145
|
+
weight: '800',
|
|
146
|
+
color: '#ffffff',
|
|
147
|
+
letterSpacing: titleSize * 0.04,
|
|
148
|
+
upper: true,
|
|
149
|
+
});
|
|
150
|
+
title.anchor.set(0.5);
|
|
151
|
+
title.position.set(this.w / 2, this.headerH / 2 + pad / 2);
|
|
152
|
+
close.position.set(this.w - navSz - pad, (this.headerH - navSz) / 2 + pad / 2);
|
|
153
|
+
this.header.addChild(title, close);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private buildCards(): void {
|
|
157
|
+
this.strip.removeChildren().forEach((c) => c.destroy({ children: true }));
|
|
158
|
+
const mobile = this.host.layout === 'mobile';
|
|
159
|
+
const top = this.headerH + 6;
|
|
160
|
+
const areaH = this.h - top - this.footerH - 6;
|
|
161
|
+
const gap = 14;
|
|
162
|
+
const n = Math.max(1, this.bonuses.length);
|
|
163
|
+
// The whole card is laid out in `em`; pick the largest em that fits BOTH dimensions of the
|
|
164
|
+
// available area, so the card is always fully visible (no horizontal clip, CTA never under the
|
|
165
|
+
// footer). emH keeps the card height within the band between header and footer; emW makes the
|
|
166
|
+
// N cards (+ gaps + 24px side margins) fit the frame width. min() of the two = the binding fit.
|
|
167
|
+
// Floor 4 is a last-resort so a 400×225 popout still shows the CTA (then X-drag scrolls the slack).
|
|
168
|
+
const emH = 3.4 * (areaH / 100);
|
|
169
|
+
const emW = mobile
|
|
170
|
+
? (this.w - 48) / 18 // vertical stack: a single card spans the width
|
|
171
|
+
: (this.w - 48 - (n - 1) * gap) / (18 * n); // row: N cards + gaps fit the frame width
|
|
172
|
+
const em = mobile ? Math.min(12, emW) : clamp(4, Math.min(emH, emW), 12);
|
|
173
|
+
const cardW = Math.min(18 * em, this.w - 48);
|
|
174
|
+
|
|
175
|
+
const cards = this.bonuses.map((b) => this.buildCard(b, cardW, em, mobile, areaH));
|
|
176
|
+
const cardH = Math.max(...cards.map((c) => c.height));
|
|
177
|
+
for (const c of cards) c.setHeight(cardH);
|
|
178
|
+
|
|
179
|
+
// Rebuild card entries for keyboard navigation
|
|
180
|
+
this.cardEntries = this.bonuses.map((b, i) => ({
|
|
181
|
+
view: cards[i],
|
|
182
|
+
bonus: b,
|
|
183
|
+
affordable: this.isAffordable(b),
|
|
184
|
+
}));
|
|
185
|
+
// Restore or init keyboard focus on the first affordable card
|
|
186
|
+
const affordable = this.cardEntries.filter((e) => e.affordable);
|
|
187
|
+
if (affordable.length > 0) {
|
|
188
|
+
if (this.focusIndex < 0) this.focusIndex = 0;
|
|
189
|
+
else this.focusIndex = Math.min(this.focusIndex, affordable.length - 1);
|
|
190
|
+
this.applyFocusRing();
|
|
191
|
+
} else {
|
|
192
|
+
this.focusIndex = -1;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
this.stripMask.clear();
|
|
196
|
+
if (mobile) {
|
|
197
|
+
// vertical stack — scroll vertically if it overflows (simple drag)
|
|
198
|
+
let y = 0;
|
|
199
|
+
const colX = (this.w - cardW) / 2;
|
|
200
|
+
for (const c of cards) {
|
|
201
|
+
c.node.position.set(colX, y);
|
|
202
|
+
y += cardH + gap;
|
|
203
|
+
this.strip.addChild(c.node);
|
|
204
|
+
}
|
|
205
|
+
this.stripMask.rect(0, top, this.w, areaH).fill(0xffffff);
|
|
206
|
+
this.setDragRange('y', 0, top, top, areaH, y - gap - areaH);
|
|
207
|
+
} else {
|
|
208
|
+
// horizontal strip — centred, drag-scroll if it overflows
|
|
209
|
+
const totalW = cards.length * cardW + (cards.length - 1) * gap;
|
|
210
|
+
let x = Math.max(24, (this.w - totalW) / 2);
|
|
211
|
+
const y = top + (areaH - cardH) / 2;
|
|
212
|
+
for (const c of cards) {
|
|
213
|
+
c.node.position.set(x, y);
|
|
214
|
+
x += cardW + gap;
|
|
215
|
+
this.strip.addChild(c.node);
|
|
216
|
+
}
|
|
217
|
+
this.stripMask.rect(0, top, this.w, areaH).fill(0xffffff);
|
|
218
|
+
this.setDragRange('x', 0, 0, top, areaH, totalW + 48 - this.w);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ── one card ──────────────────────────────────────────────────────────────
|
|
223
|
+
private buildCard(bonus: BonusOption, cardW: number, em: number, mobile: boolean, areaH: number): CardView {
|
|
224
|
+
const accent = effectiveAccent(bonus);
|
|
225
|
+
const ink = contrastText(accent);
|
|
226
|
+
const price = bonus.priceMultiplier * this.host.state.bet;
|
|
227
|
+
const enabled = this.isAffordable(bonus);
|
|
228
|
+
const select = (): void => {
|
|
229
|
+
if (this.dragged) return; // a scroll gesture, not a tap
|
|
230
|
+
if (this.isAffordable(bonus)) this.openConfirm(bonus, accent, ink);
|
|
231
|
+
};
|
|
232
|
+
// Game-supplied card UI (BonusOption.custom): the shell keeps the card slot + the buy/confirm
|
|
233
|
+
// flow via ctx.select(); the game owns the interior. (DOM .ge-bonus-card--custom: no shell bg.)
|
|
234
|
+
if (bonus.custom) {
|
|
235
|
+
const content = bonus.custom({
|
|
236
|
+
bonus,
|
|
237
|
+
bet: this.host.state.bet,
|
|
238
|
+
price,
|
|
239
|
+
priceText: this.host.fmt(price),
|
|
240
|
+
disabled: !enabled,
|
|
241
|
+
accent,
|
|
242
|
+
select,
|
|
243
|
+
});
|
|
244
|
+
return new CustomCard(content as Container, cardW);
|
|
245
|
+
}
|
|
246
|
+
const card = new BonusCard({
|
|
247
|
+
host: this.host,
|
|
248
|
+
bonus,
|
|
249
|
+
cardW,
|
|
250
|
+
em,
|
|
251
|
+
accent,
|
|
252
|
+
ink,
|
|
253
|
+
priceText: this.host.fmt(price),
|
|
254
|
+
enabled,
|
|
255
|
+
ctaLabel: this.host.t(bonus.type === 'feature' ? 'Activate' : 'Buy'),
|
|
256
|
+
onSelect: select,
|
|
257
|
+
});
|
|
258
|
+
void mobile;
|
|
259
|
+
void areaH;
|
|
260
|
+
return card;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
private isAffordable(bonus: BonusOption): boolean {
|
|
264
|
+
const s = this.host.state;
|
|
265
|
+
if (s.busy || !s.buyBonusEnabled) return false;
|
|
266
|
+
return bonus.priceMultiplier * s.bet <= s.balance;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ── bet footer ──────────────────────────────────────────────────────────────
|
|
270
|
+
private buildFooter(): void {
|
|
271
|
+
this.footer.removeChildren().forEach((c) => c.destroy({ children: true }));
|
|
272
|
+
// The bet pill reads too large next to the (shrunk) cards, so size it at ~0.82 of the chrome
|
|
273
|
+
// scale. It still centres in the full-height footer band (footerH stays on chromeScale).
|
|
274
|
+
const sc = this.chromeScale * 0.82;
|
|
275
|
+
const pillH = 38 * sc;
|
|
276
|
+
const stepSz = 32 * sc;
|
|
277
|
+
const glyphSz = 20 * sc;
|
|
278
|
+
const pill = new Container();
|
|
279
|
+
const bg = new Graphics();
|
|
280
|
+
const step = (icon: 'minus' | 'plus', dir: 1 | -1): { node: Container; setDisabled: (d: boolean) => void } => {
|
|
281
|
+
const b = new Container();
|
|
282
|
+
const glyph = makeIcon(icon, glyphSz, '#ffffff');
|
|
283
|
+
glyph.position.set((stepSz - glyphSz) / 2, (stepSz - glyphSz) / 2);
|
|
284
|
+
b.addChild(rectHit(stepSz, stepSz), glyph);
|
|
285
|
+
b.eventMode = 'static';
|
|
286
|
+
b.cursor = 'pointer';
|
|
287
|
+
let disabled = false;
|
|
288
|
+
b.on('pointerover', () => !disabled && glyph.setColor(this.host.tokens.accent));
|
|
289
|
+
b.on('pointerout', () => glyph.setColor('#ffffff'));
|
|
290
|
+
b.on('pointertap', () => {
|
|
291
|
+
if (disabled) return;
|
|
292
|
+
this.host.actions.stepBet(dir);
|
|
293
|
+
this.buildCards();
|
|
294
|
+
this.buildFooter();
|
|
295
|
+
});
|
|
296
|
+
return {
|
|
297
|
+
node: b,
|
|
298
|
+
setDisabled: (d) => {
|
|
299
|
+
disabled = d;
|
|
300
|
+
b.alpha = d ? 0.35 : 1;
|
|
301
|
+
},
|
|
302
|
+
};
|
|
303
|
+
};
|
|
304
|
+
const down = step('minus', -1);
|
|
305
|
+
const up = step('plus', 1);
|
|
306
|
+
const valWrap = new Container();
|
|
307
|
+
const label = makeText(this.host.t('Bet'), { size: 7 * sc, weight: '600', color: this.host.tokens.plaqueLabel, letterSpacing: 1, upper: true });
|
|
308
|
+
const value = makeText(this.host.fmt(this.host.state.bet), { size: 14 * sc, weight: '800', color: '#ffffff' });
|
|
309
|
+
const valW = Math.max(80 * sc, value.width + 16 * sc, label.width + 16 * sc);
|
|
310
|
+
label.position.set((valW - label.width) / 2, 2 * sc);
|
|
311
|
+
value.position.set((valW - value.width) / 2, 11 * sc);
|
|
312
|
+
valWrap.addChild(label, value);
|
|
313
|
+
|
|
314
|
+
const padX = 5 * sc;
|
|
315
|
+
down.node.position.set(padX, (pillH - stepSz) / 2);
|
|
316
|
+
valWrap.position.set(padX + stepSz, (pillH - 28 * sc) / 2);
|
|
317
|
+
up.node.position.set(padX + stepSz + valW, (pillH - stepSz) / 2);
|
|
318
|
+
const pillW = padX * 2 + stepSz + valW + stepSz;
|
|
319
|
+
bg.roundRect(0, 0, pillW, pillH, 999).fill(this.host.tokens.plaqueDark);
|
|
320
|
+
pill.addChild(bg, down.node, valWrap, up.node);
|
|
321
|
+
pill.position.set((this.w - pillW) / 2, this.h - this.footerH + (this.footerH - pillH) / 2);
|
|
322
|
+
this.footer.addChild(pill);
|
|
323
|
+
|
|
324
|
+
const i = this.host.state.availableBets.indexOf(this.host.state.bet);
|
|
325
|
+
down.setDisabled(this.host.state.busy || i <= 0);
|
|
326
|
+
up.setDisabled(this.host.state.busy || i >= this.host.state.availableBets.length - 1);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// ── confirm modal (stacked on top of the overlay) ─────────────────────────────
|
|
330
|
+
private openConfirm(bonus: BonusOption, accent: string, ink: string): void {
|
|
331
|
+
this.removeConfirm();
|
|
332
|
+
this.confirmBonus = bonus;
|
|
333
|
+
const layer = new Container();
|
|
334
|
+
const veil = new Graphics();
|
|
335
|
+
// The confirm sits over the BRIGHT opaque bonus cards, not the frosted game behind the overlay,
|
|
336
|
+
// so the standard ~50%-alpha `backdrop` tint barely dims them. Use a denser fill (same tint) so
|
|
337
|
+
// the confirm reads as a proper modal layer above the cards.
|
|
338
|
+
veil.rect(0, 0, this.w, this.h).fill({ color: 0x0c111c, alpha: 0.82 });
|
|
339
|
+
veil.eventMode = 'static';
|
|
340
|
+
veil.hitArea = new Rectangle(0, 0, this.w, this.h);
|
|
341
|
+
layer.addChild(veil);
|
|
342
|
+
|
|
343
|
+
const em = clamp(11, Math.min(this.w, this.h) * 0.02, 15);
|
|
344
|
+
const cardW = Math.min(28 * em, this.w * 0.86);
|
|
345
|
+
const price = bonus.priceMultiplier * this.host.state.bet;
|
|
346
|
+
|
|
347
|
+
// preview content (thumb, desc, vol, price) measured to size the card
|
|
348
|
+
const body = new Container();
|
|
349
|
+
let y = 1.2 * em;
|
|
350
|
+
const title = makeText(bonus.title, { size: 1.2 * em, weight: '800', color: accent, letterSpacing: 1.2 * em * 0.04, upper: true, align: 'center', wrapWidth: cardW - 2.4 * em });
|
|
351
|
+
title.position.set((cardW - title.width) / 2, y);
|
|
352
|
+
body.addChild(title);
|
|
353
|
+
y += title.height + 1.05 * em;
|
|
354
|
+
const thumb = thumbNode(this.host, bonus, accent, 6.2 * em);
|
|
355
|
+
thumb.position.set((cardW - 6.2 * em) / 2, y);
|
|
356
|
+
body.addChild(thumb);
|
|
357
|
+
y += 6.2 * em + 0.8 * em;
|
|
358
|
+
const desc = makeText(bonus.description, { size: 0.96 * em, weight: '400', color: 'rgba(255,255,255,.82)', align: 'center', wrapWidth: cardW - 2.4 * em, lineHeight: 0.96 * em * 1.45 });
|
|
359
|
+
desc.position.set((cardW - desc.width) / 2, y);
|
|
360
|
+
body.addChild(desc);
|
|
361
|
+
y += desc.height + 0.8 * em;
|
|
362
|
+
if (bonus.volatility) {
|
|
363
|
+
const vol = volatilityRow(this.host, bonus.volatility, accent, 2.2 * em);
|
|
364
|
+
vol.position.set((cardW - vol.width) / 2, y);
|
|
365
|
+
body.addChild(vol);
|
|
366
|
+
y += 2.2 * em + 0.55 * em;
|
|
367
|
+
}
|
|
368
|
+
const priceText = makeText(this.host.fmt(price), { size: 1.6 * em, weight: '800', color: '#ffffff', align: 'center' });
|
|
369
|
+
priceText.position.set((cardW - priceText.width) / 2, y);
|
|
370
|
+
body.addChild(priceText);
|
|
371
|
+
y += priceText.height + 1.2 * em;
|
|
372
|
+
|
|
373
|
+
const ctaH = 3.1 * em;
|
|
374
|
+
const cardH = y + ctaH;
|
|
375
|
+
const card = new Container();
|
|
376
|
+
const cardBg = new Graphics();
|
|
377
|
+
cardBg.roundRect(0, 0, cardW, cardH, 1.3 * em).fill(this.host.tokens.plaqueSolid);
|
|
378
|
+
card.addChild(cardBg, body);
|
|
379
|
+
// footer: Cancel (ghost) + Buy/Activate (accent). No mask — round the outer bottom corners so
|
|
380
|
+
// the buttons keep the card silhouette without a mask blocking their pointer events.
|
|
381
|
+
const half = cardW / 2;
|
|
382
|
+
const r = 1.3 * em;
|
|
383
|
+
card.addChild(
|
|
384
|
+
footerButton(this.host, this.host.t('Cancel'), 'ghost', half, ctaH, 0, y, () => this.removeConfirm(), undefined, r, 0),
|
|
385
|
+
footerButton(this.host, this.host.t(bonus.type === 'feature' ? 'Activate' : 'Buy'), accent, half, ctaH, half, y, () => {
|
|
386
|
+
if (!this.isAffordable(bonus)) return;
|
|
387
|
+
if (bonus.type === 'feature') this.host.actions.activateFeature(bonus);
|
|
388
|
+
else this.host.actions.selectBuyBonus(bonus.id);
|
|
389
|
+
this.host.closeLayer();
|
|
390
|
+
}, ink, 0, r),
|
|
391
|
+
);
|
|
392
|
+
card.position.set((this.w - cardW) / 2, (this.h - cardH) / 2);
|
|
393
|
+
layer.addChild(card);
|
|
394
|
+
|
|
395
|
+
// close X pinned to screen corner
|
|
396
|
+
const close = navButton(this.host, 'close', () => this.removeConfirm(), 36);
|
|
397
|
+
close.position.set(this.w - 36 - 12, 12);
|
|
398
|
+
layer.addChild(close);
|
|
399
|
+
|
|
400
|
+
this.confirm = layer;
|
|
401
|
+
this.addChild(layer);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
private removeConfirm(): void {
|
|
405
|
+
if (this.confirm) {
|
|
406
|
+
this.removeChild(this.confirm);
|
|
407
|
+
this.confirm.destroy({ children: true });
|
|
408
|
+
this.confirm = undefined;
|
|
409
|
+
this.confirmBonus = undefined;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// ── keyboard navigation ────────────────────────────────────────────────────
|
|
414
|
+
|
|
415
|
+
/** Step the bet by `dir` and re-render the bar, cards (affordability + price) and footer when it
|
|
416
|
+
* actually changed. Shared by the keyboard bet keys (the footer ± buttons keep their own copy). */
|
|
417
|
+
private stepBetBy(dir: 1 | -1): void {
|
|
418
|
+
this.host.actions.stepBet(dir);
|
|
419
|
+
this.buildCards();
|
|
420
|
+
this.buildFooter();
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** Apply or clear the focus ring on affordable cards. */
|
|
424
|
+
private applyFocusRing(): void {
|
|
425
|
+
const affordable = this.cardEntries.filter((ce) => ce.affordable);
|
|
426
|
+
for (let i = 0; i < affordable.length; i++) {
|
|
427
|
+
const view = affordable[i].view;
|
|
428
|
+
if (view instanceof BonusCard) {
|
|
429
|
+
view.setFocused(i === this.focusIndex);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** Two-phase keyboard handler.
|
|
435
|
+
* Browse phase: arrows move focus; +/- step bet; Enter/Space opens confirm; Escape closes.
|
|
436
|
+
* Confirm phase: Enter/Space buys/activates; Escape returns to browse. */
|
|
437
|
+
onKey(e: KeyboardEvent): boolean {
|
|
438
|
+
const mobile = this.host.layout === 'mobile';
|
|
439
|
+
|
|
440
|
+
if (this.confirm && this.confirmBonus) {
|
|
441
|
+
// ── Confirm phase ──
|
|
442
|
+
switch (e.code) {
|
|
443
|
+
case 'Enter':
|
|
444
|
+
case 'Space': {
|
|
445
|
+
const bonus = this.confirmBonus;
|
|
446
|
+
if (!this.isAffordable(bonus)) return true;
|
|
447
|
+
if (bonus.type === 'feature') this.host.actions.activateFeature(bonus);
|
|
448
|
+
else this.host.actions.selectBuyBonus(bonus.id);
|
|
449
|
+
this.host.closeLayer();
|
|
450
|
+
return true;
|
|
451
|
+
}
|
|
452
|
+
case 'Escape':
|
|
453
|
+
this.removeConfirm();
|
|
454
|
+
return true;
|
|
455
|
+
default:
|
|
456
|
+
return false;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// ── Browse phase ──
|
|
461
|
+
const affordable = this.cardEntries.filter((ce) => ce.affordable);
|
|
462
|
+
const last = affordable.length - 1;
|
|
463
|
+
|
|
464
|
+
// Bet stepping mirrors the bar's keys (Shift+↑/↓, Shift+=/-, Numpad ±). Checked BEFORE arrow
|
|
465
|
+
// navigation so a bare arrow still moves card focus while a Shift+arrow changes the bet.
|
|
466
|
+
const bet = betDir(e);
|
|
467
|
+
if (bet !== null) { this.stepBetBy(bet); return true; }
|
|
468
|
+
|
|
469
|
+
// Determine navigation direction from key code + layout
|
|
470
|
+
const fwdKey = e.code === 'ArrowRight' || (mobile && e.code === 'ArrowDown');
|
|
471
|
+
const bwdKey = e.code === 'ArrowLeft' || (mobile && e.code === 'ArrowUp');
|
|
472
|
+
|
|
473
|
+
if (fwdKey) {
|
|
474
|
+
if (last < 0) return true;
|
|
475
|
+
if (this.focusIndex < last) { this.focusIndex++; this.applyFocusRing(); }
|
|
476
|
+
return true;
|
|
477
|
+
}
|
|
478
|
+
if (bwdKey) {
|
|
479
|
+
if (last < 0) return true;
|
|
480
|
+
if (this.focusIndex > 0) { this.focusIndex--; this.applyFocusRing(); }
|
|
481
|
+
return true;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
switch (e.code) {
|
|
485
|
+
case 'Enter':
|
|
486
|
+
case 'Space':
|
|
487
|
+
if (last < 0 || this.focusIndex < 0) return true;
|
|
488
|
+
{
|
|
489
|
+
const entry = affordable[this.focusIndex];
|
|
490
|
+
const accent = effectiveAccent(entry.bonus);
|
|
491
|
+
const ink = contrastText(accent);
|
|
492
|
+
this.openConfirm(entry.bonus, accent, ink);
|
|
493
|
+
}
|
|
494
|
+
return true;
|
|
495
|
+
// Bare =/- also step the bet (the Shift+=/- and Numpad variants are handled by betDir above).
|
|
496
|
+
case 'Equal':
|
|
497
|
+
this.stepBetBy(1);
|
|
498
|
+
return true;
|
|
499
|
+
case 'Minus':
|
|
500
|
+
this.stepBetBy(-1);
|
|
501
|
+
return true;
|
|
502
|
+
case 'Escape':
|
|
503
|
+
this.host.closeLayer();
|
|
504
|
+
return true;
|
|
505
|
+
default:
|
|
506
|
+
return false;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
fit(): void {
|
|
511
|
+
/* cards already sized to the area */
|
|
512
|
+
}
|
|
513
|
+
onRemove(): void {
|
|
514
|
+
/* nothing extra */
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// ── card view ──────────────────────────────────────────────────────────────────
|
|
519
|
+
/** A card in the strip — the default `BonusCard` or a game-supplied `CustomCard`. The overlay only
|
|
520
|
+
* needs its display node, its natural height, and a way to stretch it to the row's tallest card. */
|
|
521
|
+
interface CardView {
|
|
522
|
+
node: Container;
|
|
523
|
+
height: number;
|
|
524
|
+
setHeight(total: number): void;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/** Wrapper for a game-supplied custom card (`BonusOption.custom`). The shell keeps the card slot (sizing +
|
|
528
|
+
* the scroll/confirm flow via `ctx.select`); the game owns the visuals — no shell bg/border, like
|
|
529
|
+
* the DOM's `.ge-bonus-card--custom`. */
|
|
530
|
+
class CustomCard implements CardView {
|
|
531
|
+
readonly node = new Container();
|
|
532
|
+
height = 0;
|
|
533
|
+
constructor(content: Container, _cardW: number) {
|
|
534
|
+
this.node.addChild(content);
|
|
535
|
+
this.height = Math.max(1, content.getLocalBounds().height);
|
|
536
|
+
}
|
|
537
|
+
setHeight(total: number): void {
|
|
538
|
+
this.height = total; // the game owns its own layout; we only record the slot height
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
interface BonusCardOpts {
|
|
543
|
+
host: PixiComponentContext;
|
|
544
|
+
bonus: BonusOption;
|
|
545
|
+
cardW: number;
|
|
546
|
+
em: number;
|
|
547
|
+
accent: string;
|
|
548
|
+
ink: string;
|
|
549
|
+
priceText: string;
|
|
550
|
+
enabled: boolean;
|
|
551
|
+
ctaLabel: string;
|
|
552
|
+
onSelect: () => void;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
class BonusCard implements CardView {
|
|
556
|
+
readonly node = new Container();
|
|
557
|
+
height = 0;
|
|
558
|
+
private opts: BonusCardOpts;
|
|
559
|
+
private bg = new Graphics();
|
|
560
|
+
private cta: Container;
|
|
561
|
+
private bottomBlock = new Container();
|
|
562
|
+
private bottomH = 0;
|
|
563
|
+
private ctaH: number;
|
|
564
|
+
|
|
565
|
+
constructor(opts: BonusCardOpts) {
|
|
566
|
+
this.opts = opts;
|
|
567
|
+
const { host, bonus, cardW, em, accent } = opts;
|
|
568
|
+
this.ctaH = 3.1 * em;
|
|
569
|
+
const sidePad = 1.1 * em;
|
|
570
|
+
const innerW = cardW - sidePad * 2;
|
|
571
|
+
|
|
572
|
+
// disabled (.ge-bonus-off): the title and the lit volatility bolts desaturate to grey, on top of
|
|
573
|
+
// the whole-card 0.62 alpha — matches the DOM, where they aren't just dimmed but recoloured.
|
|
574
|
+
const titleColor = opts.enabled ? accent : 'rgba(255,255,255,.6)';
|
|
575
|
+
const volColor = opts.enabled ? accent : 'rgba(255,255,255,.4)';
|
|
576
|
+
|
|
577
|
+
const top = new Container();
|
|
578
|
+
let y = 1.25 * em;
|
|
579
|
+
const title = makeText(bonus.title, { size: 1.3 * em, weight: '800', color: titleColor, letterSpacing: 1.3 * em * 0.04, upper: true, align: 'center', wrapWidth: innerW });
|
|
580
|
+
title.position.set((cardW - title.width) / 2, y);
|
|
581
|
+
top.addChild(title);
|
|
582
|
+
y += title.height + 0.75 * em;
|
|
583
|
+
const thumb = thumbNode(host, bonus, accent, 6.2 * em);
|
|
584
|
+
thumb.position.set((cardW - 6.2 * em) / 2, y);
|
|
585
|
+
top.addChild(thumb);
|
|
586
|
+
y += 6.2 * em + 0.7 * em;
|
|
587
|
+
const desc = makeText(bonus.description, { size: 0.96 * em, weight: '400', color: 'rgba(255,255,255,.82)', align: 'center', wrapWidth: innerW, lineHeight: 0.96 * em * 1.45 });
|
|
588
|
+
desc.position.set((cardW - desc.width) / 2, y);
|
|
589
|
+
top.addChild(desc);
|
|
590
|
+
y += desc.height;
|
|
591
|
+
this.topH = y;
|
|
592
|
+
|
|
593
|
+
// bottom block: volatility + price
|
|
594
|
+
let by = 0;
|
|
595
|
+
if (bonus.volatility) {
|
|
596
|
+
const vol = volatilityRow(host, bonus.volatility, volColor, 2.2 * em);
|
|
597
|
+
vol.position.set((cardW - vol.width) / 2, by);
|
|
598
|
+
this.bottomBlock.addChild(vol);
|
|
599
|
+
by += 2.2 * em + 0.55 * em;
|
|
600
|
+
}
|
|
601
|
+
const priceText = makeText(opts.priceText, { size: 1.6 * em, weight: '800', color: '#ffffff', align: 'center' });
|
|
602
|
+
priceText.position.set((cardW - priceText.width) / 2, by);
|
|
603
|
+
this.bottomBlock.addChild(priceText);
|
|
604
|
+
by += priceText.height;
|
|
605
|
+
this.bottomH = by;
|
|
606
|
+
|
|
607
|
+
this.cta = ctaButton(host, opts.ctaLabel, accent, opts.ink, cardW, this.ctaH, opts.enabled, opts.onSelect, 1.4 * em);
|
|
608
|
+
|
|
609
|
+
this.node.addChild(this.bg, top, this.bottomBlock, this.cta);
|
|
610
|
+
if (opts.enabled) {
|
|
611
|
+
this.node.eventMode = 'static';
|
|
612
|
+
this.node.cursor = 'pointer';
|
|
613
|
+
this.node.on('pointertap', opts.onSelect);
|
|
614
|
+
// hover: accent outline (the DOM's box-shadow 0 0 0 1px card-acc). pointerenter/leave (not
|
|
615
|
+
// over/out) so moving onto the inner CTA doesn't toggle the card hover off and on.
|
|
616
|
+
this.node.on('pointerenter', () => { this.hovered = true; this.drawBg(); });
|
|
617
|
+
this.node.on('pointerleave', () => { this.hovered = false; this.drawBg(); });
|
|
618
|
+
} else {
|
|
619
|
+
this.node.alpha = 0.62;
|
|
620
|
+
}
|
|
621
|
+
// provisional height (refined by setHeight once the row's max is known)
|
|
622
|
+
this.setHeight(this.topH + 0.7 * this.opts.em + this.bottomH + 1.25 * this.opts.em + this.ctaH);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
private topH = 0;
|
|
626
|
+
|
|
627
|
+
private hovered = false;
|
|
628
|
+
private focused = false;
|
|
629
|
+
|
|
630
|
+
/** Set keyboard focus ring on this card (reuses the hover/accent outline visual). */
|
|
631
|
+
setFocused(focused: boolean): void {
|
|
632
|
+
this.focused = focused;
|
|
633
|
+
this.drawBg();
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/** Card background + border; the border turns accent on hover or keyboard focus
|
|
637
|
+
* (DOM box-shadow 0 0 0 1px card-acc). */
|
|
638
|
+
private drawBg(): void {
|
|
639
|
+
const { cardW, em, host, accent, enabled } = this.opts;
|
|
640
|
+
const active = this.hovered || this.focused;
|
|
641
|
+
const w = active ? 2 : 1;
|
|
642
|
+
this.bg.clear();
|
|
643
|
+
this.bg
|
|
644
|
+
.roundRect(w / 2, w / 2, cardW - w, this.height - w, 1.4 * em)
|
|
645
|
+
.fill(host.tokens.plaqueGlass)
|
|
646
|
+
.stroke({ color: active && enabled ? accent : host.tokens.plaqueLine, width: w });
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
setHeight(total: number): void {
|
|
650
|
+
this.height = total;
|
|
651
|
+
const { cardW, em } = this.opts;
|
|
652
|
+
const bodyH = total - this.ctaH;
|
|
653
|
+
// bottom block sits above the CTA, with the body bottom padding (.9em)
|
|
654
|
+
this.bottomBlock.position.set(0, bodyH - 0.9 * em - this.bottomH);
|
|
655
|
+
this.cta.position.set(0, bodyH);
|
|
656
|
+
this.drawBg();
|
|
657
|
+
// No node mask (it would block pointer events to the CTA). The CTA rounds its own bottom
|
|
658
|
+
// corners to the card radius instead — see ctaButton().
|
|
659
|
+
this.node.hitArea = new Rectangle(0, 0, cardW, total);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// ── shared card bits ─────────────────────────────────────────────────────────
|
|
664
|
+
function thumbNode(host: PixiComponentContext, bonus: BonusOption, accent: string, h: number): Container {
|
|
665
|
+
const c = new Container();
|
|
666
|
+
c.addChild(rectHit(h, h, 0)); // size anchor (transparent)
|
|
667
|
+
if (bonus.thumbnail) {
|
|
668
|
+
Assets.load(bonus.thumbnail)
|
|
669
|
+
.then((tex: Texture) => {
|
|
670
|
+
const sp = new Sprite(tex);
|
|
671
|
+
const scale = Math.min((h * 18) / 6.2 / tex.width || 1, h / tex.height);
|
|
672
|
+
const s = Math.min(h / tex.height, h / tex.width);
|
|
673
|
+
sp.scale.set(s);
|
|
674
|
+
sp.position.set((h - tex.width * s) / 2, (h - tex.height * s) / 2);
|
|
675
|
+
void scale;
|
|
676
|
+
c.addChild(sp);
|
|
677
|
+
})
|
|
678
|
+
.catch(() => {});
|
|
679
|
+
} else {
|
|
680
|
+
const gift = makeIcon('gift', h * 0.56, accent);
|
|
681
|
+
gift.position.set((h - h * 0.56) / 2, (h - h * 0.56) / 2);
|
|
682
|
+
c.addChild(gift);
|
|
683
|
+
}
|
|
684
|
+
return c;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
function volatilityRow(_host: PixiComponentContext, level: number, accent: string, size: number): Container {
|
|
688
|
+
const c = new Container();
|
|
689
|
+
const n = Math.max(0, Math.min(5, level));
|
|
690
|
+
let x = 0;
|
|
691
|
+
for (let i = 0; i < 5; i++) {
|
|
692
|
+
// active = solid accent bolt; inactive = solid black bolt, dimmed (fill==ring, 1px hairline ring
|
|
693
|
+
// per the DOM: .ge-bonus-vol svg path { stroke-width:1; fill/stroke = accent (on) / #000 (off) })
|
|
694
|
+
const active = i < n;
|
|
695
|
+
const col = active ? accent : '#000000';
|
|
696
|
+
const bolt = makeRingedIcon('turbo1', size, col, col, 1);
|
|
697
|
+
bolt.alpha = active ? 1 : 0.5;
|
|
698
|
+
bolt.position.set(x, 0);
|
|
699
|
+
c.addChild(bolt);
|
|
700
|
+
x += size;
|
|
701
|
+
}
|
|
702
|
+
return c;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function ctaButton(host: PixiComponentContext, label: string, accent: string, ink: string, w: number, h: number, enabled: boolean, onTap: () => void, radius = 0): Container {
|
|
706
|
+
const c = new Container();
|
|
707
|
+
const bg = new Graphics();
|
|
708
|
+
const fill = enabled ? accent : '#8d939e';
|
|
709
|
+
const draw = (hover: boolean): void => {
|
|
710
|
+
bg.clear();
|
|
711
|
+
roundedPath(bg, 0, 0, w, h, [0, 0, radius, radius]); // rounded bottom corners (card silhouette)
|
|
712
|
+
bg.fill(fill);
|
|
713
|
+
if (hover) {
|
|
714
|
+
roundedPath(bg, 0, 0, w, h, [0, 0, radius, radius]);
|
|
715
|
+
bg.fill({ color: '#ffffff', alpha: 0.1 }); // ≈ filter:brightness(1.06)
|
|
716
|
+
}
|
|
717
|
+
};
|
|
718
|
+
draw(false);
|
|
719
|
+
const t = makeText(label, { size: 1.05 * (h / 3.1), weight: '800', color: enabled ? ink : '#3a3f47', letterSpacing: 0.5, upper: true, align: 'center' });
|
|
720
|
+
t.position.set((w - t.width) / 2, (h - t.height) / 2);
|
|
721
|
+
c.addChild(bg, t);
|
|
722
|
+
if (enabled) {
|
|
723
|
+
c.eventMode = 'static';
|
|
724
|
+
c.cursor = 'pointer';
|
|
725
|
+
c.hitArea = new Rectangle(0, 0, w, h);
|
|
726
|
+
attachHover(c, () => draw(true), () => draw(false));
|
|
727
|
+
c.on('pointertap', onTap);
|
|
728
|
+
}
|
|
729
|
+
return c;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function footerButton(host: PixiComponentContext, label: string, kind: 'ghost' | string, w: number, h: number, x: number, y: number, onTap: () => void, ink?: string, blRadius = 0, brRadius = 0): Container {
|
|
733
|
+
const c = new Container();
|
|
734
|
+
const bg = new Graphics();
|
|
735
|
+
const fill = kind === 'ghost' ? host.tokens.plaqueGlassHover : kind;
|
|
736
|
+
const draw = (hover: boolean): void => {
|
|
737
|
+
bg.clear();
|
|
738
|
+
roundedPath(bg, 0, 0, w, h, [0, 0, brRadius, blRadius]);
|
|
739
|
+
bg.fill(fill);
|
|
740
|
+
if (hover) {
|
|
741
|
+
roundedPath(bg, 0, 0, w, h, [0, 0, brRadius, blRadius]);
|
|
742
|
+
bg.fill({ color: '#ffffff', alpha: 0.1 });
|
|
743
|
+
}
|
|
744
|
+
};
|
|
745
|
+
draw(false);
|
|
746
|
+
const color = kind === 'ghost' ? '#ffffff' : ink ?? contrastText(kind);
|
|
747
|
+
const t = makeText(label, { size: h / 3.1, weight: '800', color, letterSpacing: 0.5, upper: true, align: 'center' });
|
|
748
|
+
t.position.set((w - t.width) / 2, (h - t.height) / 2);
|
|
749
|
+
c.addChild(bg, t);
|
|
750
|
+
c.position.set(x, y);
|
|
751
|
+
c.eventMode = 'static';
|
|
752
|
+
c.cursor = 'pointer';
|
|
753
|
+
c.hitArea = new Rectangle(0, 0, w, h);
|
|
754
|
+
attachHover(c, () => draw(true), () => draw(false));
|
|
755
|
+
c.on('pointertap', onTap);
|
|
756
|
+
return c;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
function rectHit(w: number, h: number, alpha = 0): Graphics {
|
|
760
|
+
const g = new Graphics();
|
|
761
|
+
g.rect(0, 0, w, h).fill({ color: 0xffffff, alpha });
|
|
762
|
+
return g;
|
|
763
|
+
}
|