@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,335 @@
|
|
|
1
|
+
import { Container, Graphics, Rectangle } from 'pixi.js';
|
|
2
|
+
|
|
3
|
+
// A focused flexbox for Pixi — just the slice of CSS flex the shell's CSS actually uses:
|
|
4
|
+
// row/column, gap, padding, justify (start/center/end/space-between), align
|
|
5
|
+
// (start/center/end/stretch), per-child grow + alignSelf, an optional rounded "plaque"
|
|
6
|
+
// background, and fixed-or-content sizing. Enough to reproduce the DOM bar & overlays 1:1
|
|
7
|
+
// without pulling in a Yoga dependency.
|
|
8
|
+
|
|
9
|
+
export type FlexDir = 'row' | 'column';
|
|
10
|
+
export type Justify = 'start' | 'center' | 'end' | 'space-between';
|
|
11
|
+
export type Align = 'start' | 'center' | 'end' | 'stretch';
|
|
12
|
+
|
|
13
|
+
export interface Padding {
|
|
14
|
+
top?: number;
|
|
15
|
+
right?: number;
|
|
16
|
+
bottom?: number;
|
|
17
|
+
left?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface BgStyle {
|
|
21
|
+
fill?: string;
|
|
22
|
+
radius?: number;
|
|
23
|
+
/** per-corner radii [tl, tr, br, bl] override `radius`. */
|
|
24
|
+
corners?: [number, number, number, number];
|
|
25
|
+
stroke?: { color: string; width: number };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface FlexOpts {
|
|
29
|
+
direction?: FlexDir;
|
|
30
|
+
gap?: number;
|
|
31
|
+
padding?: number | Padding;
|
|
32
|
+
justify?: Justify;
|
|
33
|
+
align?: Align;
|
|
34
|
+
background?: BgStyle;
|
|
35
|
+
/** Fixed outer size; omit a dimension for content sizing. */
|
|
36
|
+
width?: number;
|
|
37
|
+
height?: number;
|
|
38
|
+
/** Minimum outer height — content shorter than this is centred in the taller box (the DOM rows'
|
|
39
|
+
* effective min height from the tallest child's line box). */
|
|
40
|
+
minHeight?: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ChildOpts {
|
|
44
|
+
grow?: number;
|
|
45
|
+
alignSelf?: Align;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface Entry {
|
|
49
|
+
node: Container;
|
|
50
|
+
grow: number;
|
|
51
|
+
alignSelf?: Align;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Anything that can be measured + sized by the layout (FlexBox and stretchable widgets). */
|
|
55
|
+
export interface Sizable {
|
|
56
|
+
setLayoutSize(w: number | undefined, h: number | undefined): void;
|
|
57
|
+
measureSize(): { w: number; h: number };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function isSizable(n: unknown): n is Sizable {
|
|
61
|
+
return !!n && typeof (n as Sizable).measureSize === 'function' && typeof (n as Sizable).setLayoutSize === 'function';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Zero offset for Sizable widgets, which own a 0,0,w,h box (no getLocalBounds adjustment). */
|
|
65
|
+
const ORIGIN = { x: 0, y: 0 };
|
|
66
|
+
|
|
67
|
+
/** Local bounds of a node, used when it isn't a Sizable. */
|
|
68
|
+
function rawSize(node: Container): { w: number; h: number } {
|
|
69
|
+
const b = node.getLocalBounds();
|
|
70
|
+
return { w: b.width, h: b.height };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function measureOuter(node: Container): { w: number; h: number } {
|
|
74
|
+
return isSizable(node) ? node.measureSize() : rawSize(node);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function pad(p: number | Padding | undefined): Required<Padding> {
|
|
78
|
+
if (p == null) return { top: 0, right: 0, bottom: 0, left: 0 };
|
|
79
|
+
if (typeof p === 'number') return { top: p, right: p, bottom: p, left: p };
|
|
80
|
+
return { top: p.top ?? 0, right: p.right ?? 0, bottom: p.bottom ?? 0, left: p.left ?? 0 };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export class FlexBox extends Container implements Sizable {
|
|
84
|
+
private dir: FlexDir;
|
|
85
|
+
private gap: number;
|
|
86
|
+
private padding: Required<Padding>;
|
|
87
|
+
private justify: Justify;
|
|
88
|
+
private align: Align;
|
|
89
|
+
private bgStyle?: BgStyle;
|
|
90
|
+
private bg?: Graphics;
|
|
91
|
+
private entries: Entry[] = [];
|
|
92
|
+
private fixedW?: number;
|
|
93
|
+
private fixedH?: number;
|
|
94
|
+
private minH?: number;
|
|
95
|
+
/** last computed outer size. */
|
|
96
|
+
private outW = 0;
|
|
97
|
+
private outH = 0;
|
|
98
|
+
|
|
99
|
+
constructor(opts: FlexOpts = {}) {
|
|
100
|
+
super();
|
|
101
|
+
this.dir = opts.direction ?? 'row';
|
|
102
|
+
this.gap = opts.gap ?? 0;
|
|
103
|
+
this.padding = pad(opts.padding);
|
|
104
|
+
this.justify = opts.justify ?? 'start';
|
|
105
|
+
this.align = opts.align ?? 'start';
|
|
106
|
+
this.bgStyle = opts.background;
|
|
107
|
+
this.fixedW = opts.width;
|
|
108
|
+
this.fixedH = opts.height;
|
|
109
|
+
this.minH = opts.minHeight;
|
|
110
|
+
if (this.bgStyle) {
|
|
111
|
+
this.bg = new Graphics();
|
|
112
|
+
this.addChild(this.bg);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
add(node: Container, opts: ChildOpts = {}): this {
|
|
117
|
+
this.entries.push({ node, grow: opts.grow ?? 0, alignSelf: opts.alignSelf });
|
|
118
|
+
this.addChild(node);
|
|
119
|
+
return this;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
addAll(nodes: Container[]): this {
|
|
123
|
+
for (const n of nodes) this.add(n);
|
|
124
|
+
return this;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
clearChildren(): void {
|
|
128
|
+
for (const e of this.entries) {
|
|
129
|
+
this.removeChild(e.node);
|
|
130
|
+
e.node.destroy({ children: true });
|
|
131
|
+
}
|
|
132
|
+
this.entries = [];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
setBackground(bg: BgStyle | undefined): void {
|
|
136
|
+
this.bgStyle = bg;
|
|
137
|
+
if (bg && !this.bg) {
|
|
138
|
+
this.bg = new Graphics();
|
|
139
|
+
this.addChildAt(this.bg, 0);
|
|
140
|
+
}
|
|
141
|
+
if (!bg && this.bg) {
|
|
142
|
+
this.bg.clear();
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Recolour the background fill in place (e.g. a row's hover state) without a relayout. */
|
|
147
|
+
setBgFill(fill: string): void {
|
|
148
|
+
if (this.bgStyle) {
|
|
149
|
+
this.bgStyle.fill = fill;
|
|
150
|
+
this.drawBackground();
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Make the whole box a button target. A FlexBox otherwise only hit-tests its children, so a row
|
|
155
|
+
* used as a button (settings "Game info") was clickable/hoverable only over its icon/label, not
|
|
156
|
+
* the padding or gaps. This maintains a hit area covering the full box across relayouts. */
|
|
157
|
+
private fullHit = false;
|
|
158
|
+
setInteractive(on = true): this {
|
|
159
|
+
this.fullHit = on;
|
|
160
|
+
this.eventMode = on ? 'static' : 'auto';
|
|
161
|
+
this.cursor = on ? 'pointer' : 'default';
|
|
162
|
+
this.updateHit();
|
|
163
|
+
return this;
|
|
164
|
+
}
|
|
165
|
+
private updateHit(): void {
|
|
166
|
+
if (this.fullHit) this.hitArea = new Rectangle(0, 0, this.outW, this.outH);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ── Sizable ──────────────────────────────────────────────────────────────
|
|
170
|
+
setLayoutSize(w: number | undefined, h: number | undefined): void {
|
|
171
|
+
this.fixedW = w;
|
|
172
|
+
this.fixedH = h;
|
|
173
|
+
this.layout();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
measureSize(): { w: number; h: number } {
|
|
177
|
+
this.layout();
|
|
178
|
+
return { w: this.outW, h: this.outH };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
get outerWidth(): number {
|
|
182
|
+
return this.outW;
|
|
183
|
+
}
|
|
184
|
+
get outerHeight(): number {
|
|
185
|
+
return this.outH;
|
|
186
|
+
}
|
|
187
|
+
/** The width this box would take from its content alone (ignoring a fixed width) — used to
|
|
188
|
+
* detect overflow inside a fixed-width plaque (the mobile bar's fit-scale). */
|
|
189
|
+
get naturalWidth(): number {
|
|
190
|
+
return this.natW;
|
|
191
|
+
}
|
|
192
|
+
private natW = 0;
|
|
193
|
+
|
|
194
|
+
/** Measure + position children. Idempotent. */
|
|
195
|
+
layout(): void {
|
|
196
|
+
const horizontal = this.dir === 'row';
|
|
197
|
+
const px = this.padding;
|
|
198
|
+
const padMainStart = horizontal ? px.left : px.top;
|
|
199
|
+
const padMainEnd = horizontal ? px.right : px.bottom;
|
|
200
|
+
const padCrossStart = horizontal ? px.top : px.left;
|
|
201
|
+
const padCrossEnd = horizontal ? px.bottom : px.right;
|
|
202
|
+
|
|
203
|
+
// Measure each child's outer size.
|
|
204
|
+
const sizes = this.entries.map((e) => measureOuter(e.node));
|
|
205
|
+
|
|
206
|
+
const contentMain = sizes.reduce((s, m) => s + (horizontal ? m.w : m.h), 0)
|
|
207
|
+
+ this.gap * Math.max(0, this.entries.length - 1);
|
|
208
|
+
const contentCross = sizes.reduce((mx, m) => Math.max(mx, horizontal ? m.h : m.w), 0);
|
|
209
|
+
// natural width = content + horizontal padding, regardless of any imposed fixed width
|
|
210
|
+
this.natW = horizontal
|
|
211
|
+
? contentMain + padMainStart + padMainEnd
|
|
212
|
+
: contentCross + padCrossStart + padCrossEnd;
|
|
213
|
+
|
|
214
|
+
const fixedMain = horizontal ? this.fixedW : this.fixedH;
|
|
215
|
+
const fixedCross = horizontal ? this.fixedH : this.fixedW;
|
|
216
|
+
|
|
217
|
+
let outerMain = fixedMain ?? contentMain + padMainStart + padMainEnd;
|
|
218
|
+
let outerCross = fixedCross ?? contentCross + padCrossStart + padCrossEnd;
|
|
219
|
+
// minHeight raises the box on whichever axis is vertical (cross for a row, main for a column).
|
|
220
|
+
if (this.minH != null) {
|
|
221
|
+
if (horizontal) outerCross = Math.max(outerCross, this.minH);
|
|
222
|
+
else outerMain = Math.max(outerMain, this.minH);
|
|
223
|
+
}
|
|
224
|
+
const innerMain = outerMain - padMainStart - padMainEnd;
|
|
225
|
+
const innerCross = outerCross - padCrossStart - padCrossEnd;
|
|
226
|
+
|
|
227
|
+
// Distribute free main-space to growers (or, when none grow, to justify spacing).
|
|
228
|
+
const totalGrow = this.entries.reduce((s, e) => s + e.grow, 0);
|
|
229
|
+
let free = innerMain - contentMain;
|
|
230
|
+
if (free < 0) free = 0;
|
|
231
|
+
const growUnit = totalGrow > 0 ? free / totalGrow : 0;
|
|
232
|
+
|
|
233
|
+
// Apply grow to child main sizes (stretch growers) and cross stretch.
|
|
234
|
+
this.entries.forEach((e, i) => {
|
|
235
|
+
const m = sizes[i];
|
|
236
|
+
if (e.grow > 0 && growUnit > 0 && isSizable(e.node)) {
|
|
237
|
+
const newMain = (horizontal ? m.w : m.h) + e.grow * growUnit;
|
|
238
|
+
if (horizontal) {
|
|
239
|
+
e.node.setLayoutSize(newMain, undefined);
|
|
240
|
+
sizes[i] = e.node.measureSize();
|
|
241
|
+
} else {
|
|
242
|
+
e.node.setLayoutSize(undefined, newMain);
|
|
243
|
+
sizes[i] = e.node.measureSize();
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
const a = e.alignSelf ?? this.align;
|
|
247
|
+
if (a === 'stretch' && isSizable(e.node)) {
|
|
248
|
+
if (horizontal) {
|
|
249
|
+
e.node.setLayoutSize(sizes[i].w, innerCross);
|
|
250
|
+
sizes[i] = e.node.measureSize();
|
|
251
|
+
} else {
|
|
252
|
+
e.node.setLayoutSize(innerCross, sizes[i].h);
|
|
253
|
+
sizes[i] = e.node.measureSize();
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
const usedMain = sizes.reduce((s, m) => s + (horizontal ? m.w : m.h), 0)
|
|
259
|
+
+ this.gap * Math.max(0, this.entries.length - 1);
|
|
260
|
+
const leftover = innerMain - usedMain;
|
|
261
|
+
|
|
262
|
+
// Justify: leading offset + per-item spacing.
|
|
263
|
+
let cursor = (horizontal ? px.left : px.top);
|
|
264
|
+
let spacing = this.gap;
|
|
265
|
+
if (totalGrow === 0) {
|
|
266
|
+
if (this.justify === 'center') cursor += leftover / 2;
|
|
267
|
+
else if (this.justify === 'end') cursor += leftover;
|
|
268
|
+
else if (this.justify === 'space-between' && this.entries.length > 1) {
|
|
269
|
+
spacing = this.gap + leftover / (this.entries.length - 1);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
this.entries.forEach((e, i) => {
|
|
274
|
+
const m = sizes[i];
|
|
275
|
+
const childMain = horizontal ? m.w : m.h;
|
|
276
|
+
const childCross = horizontal ? m.h : m.w;
|
|
277
|
+
const a = e.alignSelf ?? this.align;
|
|
278
|
+
let crossPos = padCrossStart;
|
|
279
|
+
if (a === 'center') crossPos += (innerCross - childCross) / 2;
|
|
280
|
+
else if (a === 'end') crossPos += innerCross - childCross;
|
|
281
|
+
// For raw content (Text/Graphics/icon) whose visible bounds don't start at (0,0), offset so
|
|
282
|
+
// the content lands on the slot. Sizable widgets define their own 0,0,w,h box (their glyph is
|
|
283
|
+
// already centred inside it), so offsetting by getLocalBounds would double-count and shift
|
|
284
|
+
// them (icons drifted up). Position those by the box origin instead.
|
|
285
|
+
const b = isSizable(e.node) ? ORIGIN : e.node.getLocalBounds();
|
|
286
|
+
if (horizontal) {
|
|
287
|
+
e.node.position.set(cursor - b.x, crossPos - b.y);
|
|
288
|
+
} else {
|
|
289
|
+
e.node.position.set(crossPos - b.x, cursor - b.y);
|
|
290
|
+
}
|
|
291
|
+
cursor += childMain + spacing;
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
this.outW = horizontal ? outerMain : outerCross;
|
|
295
|
+
this.outH = horizontal ? outerCross : outerMain;
|
|
296
|
+
|
|
297
|
+
this.drawBackground();
|
|
298
|
+
this.updateHit();
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
private drawBackground(): void {
|
|
302
|
+
if (!this.bgStyle || !this.bg) return;
|
|
303
|
+
const g = this.bg;
|
|
304
|
+
g.clear();
|
|
305
|
+
const r = this.bgStyle.corners;
|
|
306
|
+
if (r) {
|
|
307
|
+
roundedPath(g, 0, 0, this.outW, this.outH, r);
|
|
308
|
+
} else {
|
|
309
|
+
g.roundRect(0, 0, this.outW, this.outH, this.bgStyle.radius ?? 0);
|
|
310
|
+
}
|
|
311
|
+
if (this.bgStyle.fill) g.fill(this.bgStyle.fill);
|
|
312
|
+
if (this.bgStyle.stroke) g.stroke({ color: this.bgStyle.stroke.color, width: this.bgStyle.stroke.width });
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Rounded rect with independent corner radii [tl, tr, br, bl] — Pixi's roundRect is uniform. */
|
|
317
|
+
export function roundedPath(
|
|
318
|
+
g: Graphics,
|
|
319
|
+
x: number,
|
|
320
|
+
y: number,
|
|
321
|
+
w: number,
|
|
322
|
+
h: number,
|
|
323
|
+
[tl, tr, br, bl]: [number, number, number, number],
|
|
324
|
+
): void {
|
|
325
|
+
g.moveTo(x + tl, y);
|
|
326
|
+
g.lineTo(x + w - tr, y);
|
|
327
|
+
g.arcTo(x + w, y, x + w, y + tr, tr);
|
|
328
|
+
g.lineTo(x + w, y + h - br);
|
|
329
|
+
g.arcTo(x + w, y + h, x + w - br, y + h, br);
|
|
330
|
+
g.lineTo(x + bl, y + h);
|
|
331
|
+
g.arcTo(x, y + h, x, y + h - bl, bl);
|
|
332
|
+
g.lineTo(x, y + tl);
|
|
333
|
+
g.arcTo(x, y, x + tl, y, tl);
|
|
334
|
+
g.closePath();
|
|
335
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { Container, Graphics, Rectangle } from 'pixi.js';
|
|
2
|
+
import type { PixiComponentContext, ShellLayer } from '../context';
|
|
3
|
+
import { makeIcon } from '../pixi-icon';
|
|
4
|
+
import { makeText } from '../text';
|
|
5
|
+
import { attachHover, attachPress } from './widgets';
|
|
6
|
+
import { ScrollBox } from './scroll';
|
|
7
|
+
import { FlexBox } from './flex';
|
|
8
|
+
|
|
9
|
+
export function clamp(min: number, pref: number, max: number): number {
|
|
10
|
+
return Math.max(min, Math.min(max, pref));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Small rounded icon nav button — the overlay header back/close and row chevrons.
|
|
14
|
+
* `.ge-ov-nav`: 32×32, radius 9, plaque-dark bg, white icon, hover → glass bg + accent icon. */
|
|
15
|
+
export function navButton(host: PixiComponentContext, iconName: 'close' | 'back', onTap: () => void, size = 32): Container {
|
|
16
|
+
const root = new Container();
|
|
17
|
+
const bg = new Graphics();
|
|
18
|
+
const draw = (hover: boolean): void => {
|
|
19
|
+
bg.clear();
|
|
20
|
+
bg.roundRect(0, 0, size, size, 9);
|
|
21
|
+
bg.fill(hover ? host.tokens.plaqueGlass : host.tokens.plaqueDark);
|
|
22
|
+
};
|
|
23
|
+
draw(false);
|
|
24
|
+
const gs = Math.round(size * 0.5625); // glyph tracks the box (default 32 → 18, unchanged)
|
|
25
|
+
const glyph = makeIcon(iconName, gs, '#ffffff');
|
|
26
|
+
glyph.position.set((size - gs) / 2, (size - gs) / 2);
|
|
27
|
+
root.addChild(bg, glyph);
|
|
28
|
+
root.eventMode = 'static';
|
|
29
|
+
root.cursor = 'pointer';
|
|
30
|
+
root.hitArea = new Rectangle(0, 0, size, size);
|
|
31
|
+
attachHover(root, () => {
|
|
32
|
+
draw(true);
|
|
33
|
+
glyph.setColor(host.tokens.accent);
|
|
34
|
+
}, () => {
|
|
35
|
+
draw(false);
|
|
36
|
+
glyph.setColor('#ffffff');
|
|
37
|
+
});
|
|
38
|
+
attachPress(root, 0.92, onTap);
|
|
39
|
+
return root;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface OverlayOpts {
|
|
43
|
+
title: string;
|
|
44
|
+
onClose: () => void;
|
|
45
|
+
onBack?: () => void;
|
|
46
|
+
/** Build the scrolling body content for a given inner width (a Sizable column). */
|
|
47
|
+
build: (bodyWidth: number) => Container;
|
|
48
|
+
/** dataset-style tag for debugging/tests. */
|
|
49
|
+
tag?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Full-screen overlay: frosted dark veil, fixed header (title + back/close), scrolling body.
|
|
53
|
+
* The body is rebuilt on resize so it reflows like the CSS overlay. */
|
|
54
|
+
export class Overlay extends Container implements ShellLayer {
|
|
55
|
+
readonly tag?: string;
|
|
56
|
+
private host: PixiComponentContext;
|
|
57
|
+
private opts: OverlayOpts;
|
|
58
|
+
private veil = new Graphics();
|
|
59
|
+
private header = new Container();
|
|
60
|
+
private scroll: ScrollBox;
|
|
61
|
+
private titleNode = new Container();
|
|
62
|
+
private nav = new Container();
|
|
63
|
+
private w = 0;
|
|
64
|
+
private h = 0;
|
|
65
|
+
private headerH = 44;
|
|
66
|
+
|
|
67
|
+
/** Expose the scroll box for keyboard/test access. */
|
|
68
|
+
get scrollContent(): ScrollBox { return this.scroll; }
|
|
69
|
+
|
|
70
|
+
constructor(host: PixiComponentContext, opts: OverlayOpts) {
|
|
71
|
+
super();
|
|
72
|
+
this.host = host;
|
|
73
|
+
this.opts = opts;
|
|
74
|
+
this.tag = opts.tag;
|
|
75
|
+
this.scroll = new ScrollBox(host.canvas);
|
|
76
|
+
this.addChild(this.veil, this.scroll, this.header);
|
|
77
|
+
this.veil.eventMode = 'static'; // swallow clicks to the game behind
|
|
78
|
+
this.resize(host.screenW, host.screenH);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
resize(w: number, h: number): void {
|
|
82
|
+
this.w = w;
|
|
83
|
+
this.h = h;
|
|
84
|
+
const vh = h / 100;
|
|
85
|
+
this.headerH = clamp(40, 6.4 * vh, 52);
|
|
86
|
+
|
|
87
|
+
// veil
|
|
88
|
+
this.veil.clear();
|
|
89
|
+
this.veil.rect(0, 0, w, h);
|
|
90
|
+
this.veil.fill(this.host.tokens.backdrop);
|
|
91
|
+
this.veil.hitArea = new Rectangle(0, 0, w, h);
|
|
92
|
+
|
|
93
|
+
this.buildHeader();
|
|
94
|
+
this.layoutBody();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private buildHeader(): void {
|
|
98
|
+
this.header.removeChildren().forEach((c) => c.destroy({ children: true }));
|
|
99
|
+
const pad = 10;
|
|
100
|
+
// back or spacer (left), centred title, close (right)
|
|
101
|
+
const left = this.opts.onBack
|
|
102
|
+
? navButton(this.host, 'back', () => this.opts.onBack!())
|
|
103
|
+
: new Container();
|
|
104
|
+
const close = navButton(this.host, 'close', () => this.opts.onClose());
|
|
105
|
+
const titleSize = clamp(13, 2.6 * (this.h / 100), 16);
|
|
106
|
+
const title = makeText(this.opts.title, {
|
|
107
|
+
size: titleSize,
|
|
108
|
+
weight: '800',
|
|
109
|
+
color: '#ffffff',
|
|
110
|
+
letterSpacing: titleSize * 0.04,
|
|
111
|
+
upper: true,
|
|
112
|
+
align: 'center',
|
|
113
|
+
});
|
|
114
|
+
title.anchor.set(0.5);
|
|
115
|
+
title.position.set(this.w / 2, this.headerH / 2 + pad / 2);
|
|
116
|
+
left.position.set(pad, (this.headerH - 32) / 2 + pad / 2);
|
|
117
|
+
close.position.set(this.w - 32 - pad, (this.headerH - 32) / 2 + pad / 2);
|
|
118
|
+
this.header.addChild(title, left, close);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private layoutBody(): void {
|
|
122
|
+
const top = this.headerH + 6;
|
|
123
|
+
const sidePad = clamp(16, 4 * (this.w / 100), 24);
|
|
124
|
+
const vPad = clamp(6, 2 * (this.h / 100), 16);
|
|
125
|
+
const bodyW = Math.min(800, this.w - sidePad * 2);
|
|
126
|
+
|
|
127
|
+
this.scroll.position.set(0, top);
|
|
128
|
+
this.scroll.setViewport(this.w, this.h - top);
|
|
129
|
+
this.scroll.content.removeChildren().forEach((c) => c.destroy({ children: true }));
|
|
130
|
+
|
|
131
|
+
const content = this.opts.build(bodyW);
|
|
132
|
+
if (content instanceof FlexBox) content.setLayoutSize(bodyW, undefined);
|
|
133
|
+
content.position.set((this.w - bodyW) / 2, vPad);
|
|
134
|
+
this.scroll.content.addChild(content);
|
|
135
|
+
this.scroll.refresh();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Handle keyboard navigation while this overlay is the top layer. */
|
|
139
|
+
onKey(e: KeyboardEvent): boolean {
|
|
140
|
+
const viewH = this.h - (this.headerH + 6); // body viewport height
|
|
141
|
+
const lineStep = 60;
|
|
142
|
+
const pageStep = Math.floor(viewH * 0.9);
|
|
143
|
+
switch (e.code) {
|
|
144
|
+
case 'ArrowDown':
|
|
145
|
+
this.scroll.scrollBy(lineStep);
|
|
146
|
+
return true;
|
|
147
|
+
case 'ArrowUp':
|
|
148
|
+
this.scroll.scrollBy(-lineStep);
|
|
149
|
+
return true;
|
|
150
|
+
case 'PageDown':
|
|
151
|
+
this.scroll.scrollBy(pageStep);
|
|
152
|
+
return true;
|
|
153
|
+
case 'PageUp':
|
|
154
|
+
this.scroll.scrollBy(-pageStep);
|
|
155
|
+
return true;
|
|
156
|
+
case 'Space':
|
|
157
|
+
if (e.shiftKey) {
|
|
158
|
+
this.scroll.scrollBy(-pageStep);
|
|
159
|
+
} else {
|
|
160
|
+
this.scroll.scrollBy(pageStep);
|
|
161
|
+
}
|
|
162
|
+
return true;
|
|
163
|
+
case 'Home':
|
|
164
|
+
this.scroll.scrollBy(-999999);
|
|
165
|
+
return true;
|
|
166
|
+
case 'End':
|
|
167
|
+
this.scroll.scrollBy(999999);
|
|
168
|
+
return true;
|
|
169
|
+
default:
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
fit(): void {
|
|
175
|
+
/* overlays scroll; no card fit needed */
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
onRemove(): void {
|
|
179
|
+
this.scroll.destroy({ children: true });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { Container, Graphics, Rectangle, type FederatedPointerEvent } from 'pixi.js';
|
|
2
|
+
|
|
3
|
+
/** A vertically-scrolling viewport — mask + drag + mouse-wheel, the Pixi analogue of the
|
|
4
|
+
* overlay's `overflow-y:auto` scroll region. Add content to `.content`; call `refresh()`
|
|
5
|
+
* after content changes or a resize. */
|
|
6
|
+
export class ScrollBox extends Container {
|
|
7
|
+
readonly content = new Container();
|
|
8
|
+
private maskG = new Graphics();
|
|
9
|
+
private viewW = 0;
|
|
10
|
+
private viewH = 0;
|
|
11
|
+
private scrollY = 0;
|
|
12
|
+
private maxScroll = 0;
|
|
13
|
+
private dragging = false;
|
|
14
|
+
private lastY = 0;
|
|
15
|
+
private moved = false;
|
|
16
|
+
private canvas?: HTMLCanvasElement;
|
|
17
|
+
private wheelHandler?: (e: WheelEvent) => void;
|
|
18
|
+
|
|
19
|
+
constructor(canvas?: HTMLCanvasElement) {
|
|
20
|
+
super();
|
|
21
|
+
this.canvas = canvas;
|
|
22
|
+
this.addChild(this.content);
|
|
23
|
+
// maskG is added to the scene only while scrolling (see refresh) — a leftover unused mask
|
|
24
|
+
// graphic renders as a white rect, and a masked container blocks pointer events to its children.
|
|
25
|
+
this.eventMode = 'static';
|
|
26
|
+
this.on('pointerdown', this.onDown);
|
|
27
|
+
this.on('globalpointermove', this.onMove);
|
|
28
|
+
this.on('pointerup', this.onUp);
|
|
29
|
+
this.on('pointerupoutside', this.onUp);
|
|
30
|
+
if (this.canvas) {
|
|
31
|
+
this.wheelHandler = (e: WheelEvent) => {
|
|
32
|
+
// Always swallow the wheel while this scroll region is mounted (a modal is open) so the
|
|
33
|
+
// gesture never chains to the parent page — on Stake the game is in an iframe and an
|
|
34
|
+
// un-prevented wheel scrolls the host page. Still scroll our content when it overflows.
|
|
35
|
+
e.preventDefault();
|
|
36
|
+
if (this.maxScroll > 0) this.setScroll(this.scrollY + e.deltaY);
|
|
37
|
+
};
|
|
38
|
+
this.canvas.addEventListener('wheel', this.wheelHandler, { passive: false });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
setViewport(w: number, h: number): void {
|
|
43
|
+
this.viewW = w;
|
|
44
|
+
this.viewH = h;
|
|
45
|
+
this.maskG.clear();
|
|
46
|
+
this.maskG.rect(0, 0, w, h);
|
|
47
|
+
this.maskG.fill(0xffffff);
|
|
48
|
+
this.refresh();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Re-measure content height, clamp the scroll, and clip only when it overflows. */
|
|
52
|
+
refresh(): void {
|
|
53
|
+
this.content.mask = null; // measure unmasked (a mask clips getLocalBounds to the viewport)
|
|
54
|
+
if (this.maskG.parent) this.removeChild(this.maskG);
|
|
55
|
+
const b = this.content.getLocalBounds();
|
|
56
|
+
const contentH = b.height + b.y; // content laid out from y≈0 downward
|
|
57
|
+
this.maxScroll = Math.max(0, contentH - this.viewH);
|
|
58
|
+
// Only clip + grab pointer/drag when the content actually overflows: a masked container blocks
|
|
59
|
+
// pointer events to its children in Pixi v8, so when it fits we leave it unmasked and passive →
|
|
60
|
+
// interactive controls (settings sliders/buttons) work. Tall scrolling content (game info) that
|
|
61
|
+
// does get masked has no interactive children, so nothing is lost there.
|
|
62
|
+
const scrollable = this.maxScroll > 0;
|
|
63
|
+
if (scrollable) {
|
|
64
|
+
this.addChild(this.maskG);
|
|
65
|
+
this.content.mask = this.maskG;
|
|
66
|
+
this.hitArea = new Rectangle(0, 0, this.viewW, this.viewH);
|
|
67
|
+
} else {
|
|
68
|
+
this.hitArea = null;
|
|
69
|
+
}
|
|
70
|
+
this.eventMode = scrollable ? 'static' : 'passive';
|
|
71
|
+
this.setScroll(this.scrollY);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Max scrollable distance (0 when content fits) — exposed for tests. */
|
|
75
|
+
get maxScrollY(): number {
|
|
76
|
+
return this.maxScroll;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Scroll by `dy` pixels (positive = down, negative = up), clamped to [0, maxScroll]. */
|
|
80
|
+
scrollBy(dy: number): void {
|
|
81
|
+
this.setScroll(this.scrollY + dy);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private setScroll(y: number): void {
|
|
85
|
+
this.scrollY = Math.max(0, Math.min(this.maxScroll, y)) || 0; // || 0 converts -0 to 0
|
|
86
|
+
this.content.y = this.scrollY === 0 ? 0 : -this.scrollY;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private onDown = (e: FederatedPointerEvent): void => {
|
|
90
|
+
this.dragging = true;
|
|
91
|
+
this.moved = false;
|
|
92
|
+
this.lastY = e.global.y;
|
|
93
|
+
};
|
|
94
|
+
private onMove = (e: FederatedPointerEvent): void => {
|
|
95
|
+
if (!this.dragging) return;
|
|
96
|
+
const dy = e.global.y - this.lastY;
|
|
97
|
+
if (Math.abs(dy) > 2) this.moved = true;
|
|
98
|
+
this.lastY = e.global.y;
|
|
99
|
+
if (this.maxScroll > 0) this.setScroll(this.scrollY - dy);
|
|
100
|
+
};
|
|
101
|
+
private onUp = (): void => {
|
|
102
|
+
this.dragging = false;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/** True if the last pointer sequence was a drag (so a tap handler can ignore it). */
|
|
106
|
+
get didDrag(): boolean {
|
|
107
|
+
return this.moved;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
destroy(options?: Parameters<Container['destroy']>[0]): void {
|
|
111
|
+
if (this.canvas && this.wheelHandler) {
|
|
112
|
+
this.canvas.removeEventListener('wheel', this.wheelHandler);
|
|
113
|
+
this.wheelHandler = undefined;
|
|
114
|
+
}
|
|
115
|
+
super.destroy(options);
|
|
116
|
+
}
|
|
117
|
+
}
|