@energy8platform/shell 0.10.0 → 0.11.1
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 +280 -10
- package/dist/html.cjs.js.map +1 -1
- package/dist/html.d.ts +56 -4
- package/dist/html.esm.js +278 -11
- package/dist/html.esm.js.map +1 -1
- package/dist/index.cjs.js +81 -2
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +56 -4
- package/dist/index.esm.js +79 -3
- package/dist/index.esm.js.map +1 -1
- package/dist/pixi.cjs.js +314 -10
- package/dist/pixi.cjs.js.map +1 -1
- package/dist/pixi.d.ts +56 -4
- package/dist/pixi.esm.js +312 -11
- package/dist/pixi.esm.js.map +1 -1
- package/package.json +1 -1
- package/src/core/index.ts +3 -0
- package/src/core/keyboard.ts +7 -1
- package/src/core/scrollHint.ts +91 -0
- package/src/core/state.ts +15 -0
- package/src/core/version.ts +1 -1
- package/src/ui/html/components/BottomBar.ts +2 -1
- package/src/ui/html/components/BuyBonus.ts +11 -1
- package/src/ui/html/components/GameInfo.ts +3 -1
- package/src/ui/html/components/pickers.ts +4 -0
- package/src/ui/html/primitives.ts +16 -5
- package/src/ui/html/scroll-affordance.ts +144 -0
- package/src/ui/html/shell.css.ts +65 -1
- package/src/ui/pixi/components/BottomBar.ts +9 -3
- package/src/ui/pixi/components/BuyBonus.ts +20 -1
- package/src/ui/pixi/primitives/overlay.ts +1 -1
- package/src/ui/pixi/primitives/scroll-affordance.ts +244 -0
- package/src/ui/pixi/primitives/scroll.ts +21 -3
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { Container, Graphics, type Ticker } from 'pixi.js';
|
|
2
|
+
import type { ScrollHint } from '@/core/scrollHint';
|
|
3
|
+
import { prefersReducedMotion } from '@/core/motion';
|
|
4
|
+
import { makeIcon } from '../pixi-icon';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The Pixi half of the scroll affordance — the drawn counterpart to the DOM's `data-scroll`.
|
|
8
|
+
*
|
|
9
|
+
* A ScrollBox masks, drags and swallows the wheel; a buy-bonus strip drags its cards. Neither drew
|
|
10
|
+
* anything to say so, and in a 400×225 Stake popout that is most of the overlay content out of
|
|
11
|
+
* sight with no way to know it. Both now own one of these and feed it a `ScrollHint`.
|
|
12
|
+
*
|
|
13
|
+
* Three marks, matching the DOM shell exactly:
|
|
14
|
+
* • a thumb — the standing "this scrolls";
|
|
15
|
+
* • a scrim at whichever edge hides content, so the content reads as continuing rather than as
|
|
16
|
+
* ending in a hard cut;
|
|
17
|
+
* • a chevron at rest, retired for good the first time the region moves.
|
|
18
|
+
*
|
|
19
|
+
* Everything is drawn in the PARENT's coordinate space from an explicit viewport rect, so an owner
|
|
20
|
+
* whose content is offset (a ScrollBox translates its content, a strip translates itself) does not
|
|
21
|
+
* have to unwind that transform to place the marks.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export type ScrollAxis = 'y' | 'x';
|
|
25
|
+
|
|
26
|
+
export interface ScrollViewport {
|
|
27
|
+
x: number;
|
|
28
|
+
y: number;
|
|
29
|
+
w: number;
|
|
30
|
+
h: number;
|
|
31
|
+
axis: ScrollAxis;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ThumbBounds {
|
|
35
|
+
x: number;
|
|
36
|
+
y: number;
|
|
37
|
+
w: number;
|
|
38
|
+
h: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Thickness of the thumb, and how far it is inset from the edge it rides. */
|
|
42
|
+
const THUMB_THICK = 4;
|
|
43
|
+
const THUMB_INSET = 3;
|
|
44
|
+
/** Shortest thumb we will draw in pixels — the fractional floor still degenerates on a tiny frame. */
|
|
45
|
+
const THUMB_MIN_PX = 22;
|
|
46
|
+
/** Depth of the edge scrim. */
|
|
47
|
+
const SCRIM = 34;
|
|
48
|
+
const SCRIM_ALPHA = 0.72;
|
|
49
|
+
|
|
50
|
+
/** The overlay chrome — plaques, bar, backdrop — is scheme-independent in this shell (see
|
|
51
|
+
* resolveTheme: those tokens are fixed, only the palette behind them flips). The affordance is
|
|
52
|
+
* chrome, so it is a fixed neutral too: white marks over a tint of the same hue as the veil, which
|
|
53
|
+
* makes the fade read as content dissolving INTO the overlay rather than as a grey band on top. */
|
|
54
|
+
const SCRIM_TINT = 0x0c111c;
|
|
55
|
+
|
|
56
|
+
const CUE_R = 11;
|
|
57
|
+
const CUE_BOB = 4;
|
|
58
|
+
const CUE_PERIOD = 1600; // ms
|
|
59
|
+
|
|
60
|
+
export interface ScrimSlice {
|
|
61
|
+
/** Distance inward from the faded edge. */
|
|
62
|
+
offset: number;
|
|
63
|
+
thickness: number;
|
|
64
|
+
alpha: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The scrim's alpha ramp, densest AT the edge and clear by `depth` inward.
|
|
69
|
+
*
|
|
70
|
+
* Exported because the direction is the whole point and is easy to get backwards: an inverted ramp
|
|
71
|
+
* draws a hard-edged dark band floating over the content instead of a fade into the frame, and it
|
|
72
|
+
* looks deliberate enough that nobody questions it. The squared falloff keeps the outer half of the
|
|
73
|
+
* ramp faint, so the scrim reads as content thinning out rather than as a panel laid on top.
|
|
74
|
+
*/
|
|
75
|
+
export function scrimRamp(depth: number): ScrimSlice[] {
|
|
76
|
+
const slices = Math.max(1, Math.round(depth));
|
|
77
|
+
const thickness = depth / slices;
|
|
78
|
+
const out: ScrimSlice[] = [];
|
|
79
|
+
for (let i = 0; i < slices; i++) {
|
|
80
|
+
const offset = i * thickness;
|
|
81
|
+
const t = 1 - (offset + thickness / 2) / depth; // 1 at the edge → 0 at `depth`
|
|
82
|
+
out.push({ offset, thickness, alpha: SCRIM_ALPHA * t * t });
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export class ScrollAffordanceView extends Container {
|
|
88
|
+
private thumb = new Graphics();
|
|
89
|
+
private scrim = new Graphics();
|
|
90
|
+
private cue = new Container();
|
|
91
|
+
private ticker?: Ticker;
|
|
92
|
+
private bounds: ThumbBounds | null = null;
|
|
93
|
+
private cueOn = false;
|
|
94
|
+
// Once the region has moved the player knows the gesture; re-offering it on every return to the
|
|
95
|
+
// top would nag rather than inform.
|
|
96
|
+
private cueRetired = false;
|
|
97
|
+
private cueBaseY = 0;
|
|
98
|
+
private elapsed = 0;
|
|
99
|
+
private tick?: (t: Ticker) => void;
|
|
100
|
+
|
|
101
|
+
constructor(ticker?: Ticker) {
|
|
102
|
+
super();
|
|
103
|
+
this.ticker = ticker;
|
|
104
|
+
this.eventMode = 'none'; // decoration: never intercept the drag it is advertising
|
|
105
|
+
this.addChild(this.scrim, this.thumb, this.cue);
|
|
106
|
+
this.buildCue();
|
|
107
|
+
this.visible = false;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Where the thumb is drawn, in the parent's space — `null` when nothing is drawn. */
|
|
111
|
+
get thumbBounds(): ThumbBounds | null {
|
|
112
|
+
return this.visible ? this.bounds : null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
get cueVisible(): boolean {
|
|
116
|
+
return this.visible && this.cueOn;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Re-draw for a viewport and a hint. Cheap enough to call on every scroll frame. */
|
|
120
|
+
update(vp: ScrollViewport, hint: ScrollHint): void {
|
|
121
|
+
if (this.destroyed) return;
|
|
122
|
+
if (!hint.overflowing) {
|
|
123
|
+
this.visible = false;
|
|
124
|
+
this.bounds = null;
|
|
125
|
+
this.showCue(false);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
this.visible = true;
|
|
129
|
+
// Any movement off the start retires the cue permanently.
|
|
130
|
+
if (!hint.atStart) this.cueRetired = true;
|
|
131
|
+
|
|
132
|
+
this.drawThumb(vp, hint);
|
|
133
|
+
this.drawScrim(vp, hint);
|
|
134
|
+
this.placeCue(vp);
|
|
135
|
+
this.showCue(hint.atStart && !this.cueRetired);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private drawThumb(vp: ScrollViewport, hint: ScrollHint): void {
|
|
139
|
+
const along = vp.axis === 'y' ? vp.h : vp.w;
|
|
140
|
+
const track = Math.max(0, along - THUMB_INSET * 2);
|
|
141
|
+
const len = Math.max(Math.min(track, THUMB_MIN_PX), track * hint.thumbSize);
|
|
142
|
+
// Recover the travel from the (possibly floored) length so the thumb still reaches both ends.
|
|
143
|
+
const travel = Math.max(0, track - len);
|
|
144
|
+
const progress = hint.thumbSize < 1 ? hint.thumbOffset / (1 - hint.thumbSize) : 0;
|
|
145
|
+
const at = THUMB_INSET + travel * progress;
|
|
146
|
+
|
|
147
|
+
this.bounds =
|
|
148
|
+
vp.axis === 'y'
|
|
149
|
+
? { x: vp.x + vp.w - THUMB_INSET - THUMB_THICK, y: vp.y + at, w: THUMB_THICK, h: len }
|
|
150
|
+
: { x: vp.x + at, y: vp.y + vp.h - THUMB_INSET - THUMB_THICK, w: len, h: THUMB_THICK };
|
|
151
|
+
|
|
152
|
+
const b = this.bounds;
|
|
153
|
+
this.thumb.clear();
|
|
154
|
+
// A faint track behind the thumb: on a dark overlay a lone thumb can read as a stray highlight;
|
|
155
|
+
// with the groove behind it, it reads as a scrollbar.
|
|
156
|
+
if (vp.axis === 'y') {
|
|
157
|
+
this.thumb
|
|
158
|
+
.roundRect(b.x, vp.y + THUMB_INSET, THUMB_THICK, track, THUMB_THICK / 2)
|
|
159
|
+
.fill({ color: 0xffffff, alpha: 0.1 });
|
|
160
|
+
} else {
|
|
161
|
+
this.thumb
|
|
162
|
+
.roundRect(vp.x + THUMB_INSET, b.y, track, THUMB_THICK, THUMB_THICK / 2)
|
|
163
|
+
.fill({ color: 0xffffff, alpha: 0.1 });
|
|
164
|
+
}
|
|
165
|
+
this.thumb.roundRect(b.x, b.y, b.w, b.h, THUMB_THICK / 2).fill({ color: 0xffffff, alpha: 0.55 });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Stacked 1px slices rather than a gradient fill: no texture, no backend-specific paths, and at
|
|
169
|
+
* one device pixel per slice there is nothing left to band. */
|
|
170
|
+
private drawScrim(vp: ScrollViewport, hint: ScrollHint): void {
|
|
171
|
+
this.scrim.clear();
|
|
172
|
+
const depth = Math.min(SCRIM, (vp.axis === 'y' ? vp.h : vp.w) / 3);
|
|
173
|
+
if (depth <= 0) return;
|
|
174
|
+
for (const { offset, thickness, alpha } of scrimRamp(depth)) {
|
|
175
|
+
// `offset` is measured INWARD from whichever edge is being faded.
|
|
176
|
+
if (!hint.atEnd) {
|
|
177
|
+
if (vp.axis === 'y') this.scrim.rect(vp.x, vp.y + vp.h - offset - thickness, vp.w, thickness);
|
|
178
|
+
else this.scrim.rect(vp.x + vp.w - offset - thickness, vp.y, thickness, vp.h);
|
|
179
|
+
this.scrim.fill({ color: SCRIM_TINT, alpha });
|
|
180
|
+
}
|
|
181
|
+
if (!hint.atStart) {
|
|
182
|
+
if (vp.axis === 'y') this.scrim.rect(vp.x, vp.y + offset, vp.w, thickness);
|
|
183
|
+
else this.scrim.rect(vp.x + offset, vp.y, thickness, vp.h);
|
|
184
|
+
this.scrim.fill({ color: SCRIM_TINT, alpha });
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private buildCue(): void {
|
|
190
|
+
const disc = new Graphics();
|
|
191
|
+
disc.circle(0, 0, CUE_R).fill({ color: 0x0b0f18, alpha: 0.9 });
|
|
192
|
+
disc.circle(0, 0, CUE_R).stroke({ color: 0xffffff, alpha: 0.22, width: 1 });
|
|
193
|
+
const size = Math.round(CUE_R * 1.3);
|
|
194
|
+
const glyph = makeIcon('chevronDown', size, '#ffffff');
|
|
195
|
+
glyph.position.set(-size / 2, -size / 2);
|
|
196
|
+
this.cue.addChild(disc, glyph);
|
|
197
|
+
this.cue.visible = false;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private placeCue(vp: ScrollViewport): void {
|
|
201
|
+
// Bottom-centre for a vertical region; hugging the trailing edge for a horizontal one.
|
|
202
|
+
if (vp.axis === 'y') {
|
|
203
|
+
this.cue.x = vp.x + vp.w / 2;
|
|
204
|
+
this.cueBaseY = vp.y + vp.h - CUE_R - 6;
|
|
205
|
+
} else {
|
|
206
|
+
this.cue.x = vp.x + vp.w - CUE_R - 6;
|
|
207
|
+
this.cueBaseY = vp.y + vp.h / 2;
|
|
208
|
+
this.cue.rotation = -Math.PI / 2; // chevron points the way the strip moves
|
|
209
|
+
}
|
|
210
|
+
this.cue.y = this.cueBaseY;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
private showCue(on: boolean): void {
|
|
214
|
+
if (on === this.cueOn) return;
|
|
215
|
+
this.cueOn = on;
|
|
216
|
+
this.cue.visible = on;
|
|
217
|
+
if (on) this.startBob();
|
|
218
|
+
else this.stopBob();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
private startBob(): void {
|
|
222
|
+
// A player who asked for stillness still needs the hint; they just do not need it moving.
|
|
223
|
+
if (this.tick || !this.ticker || prefersReducedMotion()) return;
|
|
224
|
+
this.elapsed = 0;
|
|
225
|
+
this.tick = (t: Ticker) => {
|
|
226
|
+
this.elapsed += t.deltaMS;
|
|
227
|
+
const phase = (this.elapsed % CUE_PERIOD) / CUE_PERIOD;
|
|
228
|
+
this.cue.y = this.cueBaseY + Math.sin(phase * Math.PI * 2) * (CUE_BOB / 2) + CUE_BOB / 2;
|
|
229
|
+
};
|
|
230
|
+
this.ticker.add(this.tick);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
private stopBob(): void {
|
|
234
|
+
if (!this.tick) return;
|
|
235
|
+
this.ticker?.remove(this.tick);
|
|
236
|
+
this.tick = undefined;
|
|
237
|
+
this.cue.y = this.cueBaseY;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
destroy(options?: Parameters<Container['destroy']>[0]): void {
|
|
241
|
+
this.stopBob();
|
|
242
|
+
super.destroy(options);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
@@ -1,10 +1,15 @@
|
|
|
1
|
-
import { Container, Graphics, Rectangle, type FederatedPointerEvent } from 'pixi.js';
|
|
1
|
+
import { Container, Graphics, Rectangle, type FederatedPointerEvent, type Ticker } from 'pixi.js';
|
|
2
|
+
import { scrollHint } from '@/core/scrollHint';
|
|
3
|
+
import { ScrollAffordanceView } from './scroll-affordance';
|
|
2
4
|
|
|
3
5
|
/** A vertically-scrolling viewport — mask + drag + mouse-wheel, the Pixi analogue of the
|
|
4
6
|
* overlay's `overflow-y:auto` scroll region. Add content to `.content`; call `refresh()`
|
|
5
7
|
* after content changes or a resize. */
|
|
6
8
|
export class ScrollBox extends Container {
|
|
7
9
|
readonly content = new Container();
|
|
10
|
+
/** The drawn "this scrolls" marks — thumb, edge scrim, chevron. Public so the overlay that owns
|
|
11
|
+
* the box (and the tests) can read what a player would see. */
|
|
12
|
+
readonly affordance: ScrollAffordanceView;
|
|
8
13
|
private maskG = new Graphics();
|
|
9
14
|
private viewW = 0;
|
|
10
15
|
private viewH = 0;
|
|
@@ -16,10 +21,13 @@ export class ScrollBox extends Container {
|
|
|
16
21
|
private canvas?: HTMLCanvasElement;
|
|
17
22
|
private wheelHandler?: (e: WheelEvent) => void;
|
|
18
23
|
|
|
19
|
-
constructor(canvas?: HTMLCanvasElement) {
|
|
24
|
+
constructor(canvas?: HTMLCanvasElement, ticker?: Ticker) {
|
|
20
25
|
super();
|
|
21
26
|
this.canvas = canvas;
|
|
22
|
-
this.
|
|
27
|
+
this.affordance = new ScrollAffordanceView(ticker);
|
|
28
|
+
// Added AFTER content, and never masked: the marks describe the viewport, so clipping them to
|
|
29
|
+
// the content they describe would hide them exactly when the content overflows.
|
|
30
|
+
this.addChild(this.content, this.affordance);
|
|
23
31
|
// maskG is added to the scene only while scrolling (see refresh) — a leftover unused mask
|
|
24
32
|
// graphic renders as a white rect. (Masking does NOT gate pointer events to the content, despite
|
|
25
33
|
// what an earlier version of this comment claimed — see the correction in refresh() below.)
|
|
@@ -73,6 +81,7 @@ export class ScrollBox extends Container {
|
|
|
73
81
|
}
|
|
74
82
|
this.eventMode = scrollable ? 'static' : 'passive';
|
|
75
83
|
this.setScroll(this.scrollY);
|
|
84
|
+
this.syncAffordance();
|
|
76
85
|
}
|
|
77
86
|
|
|
78
87
|
/** Max scrollable distance (0 when content fits) — exposed for tests. */
|
|
@@ -91,6 +100,15 @@ export class ScrollBox extends Container {
|
|
|
91
100
|
if (this.destroyed || !this.content || this.content.destroyed) return;
|
|
92
101
|
this.scrollY = Math.max(0, Math.min(this.maxScroll, y)) || 0; // || 0 converts -0 to 0
|
|
93
102
|
this.content.y = this.scrollY === 0 ? 0 : -this.scrollY;
|
|
103
|
+
this.syncAffordance();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private syncAffordance(): void {
|
|
107
|
+
if (this.destroyed || this.affordance.destroyed) return;
|
|
108
|
+
this.affordance.update(
|
|
109
|
+
{ x: 0, y: 0, w: this.viewW, h: this.viewH, axis: 'y' },
|
|
110
|
+
scrollHint({ scrollTop: this.scrollY, scrollHeight: this.viewH + this.maxScroll, clientHeight: this.viewH }),
|
|
111
|
+
);
|
|
94
112
|
}
|
|
95
113
|
|
|
96
114
|
private onDown = (e: FederatedPointerEvent): void => {
|