3d-spinner 0.9.3 → 0.9.4
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/animations/ghost-train.d.ts +72 -0
- package/dist/animations/ghost-train.js +247 -0
- package/dist/animations/grid-assembly.d.ts +11 -8
- package/dist/animations/grid-assembly.js +44 -30
- package/dist/animations/rocket-launch.d.ts +58 -0
- package/dist/animations/rocket-launch.js +375 -0
- package/dist/cjs/animations/grid-assembly.cjs +28 -146
- package/dist/cjs/engines/little-3d-engine/little-3d-engine.cjs +22 -10
- package/dist/cjs/prefabs/prefabs.cjs +635 -169
- package/dist/engines/little-3d-engine/textures/dynamic/star.d.ts +14 -2
- package/dist/engines/little-3d-engine/textures/dynamic/star.js +26 -11
- package/dist/prefabs/ghost-train.d.ts +5 -4
- package/dist/prefabs/ghost-train.js +17 -24
- package/dist/prefabs/rocket-launch.d.ts +7 -5
- package/dist/prefabs/rocket-launch.js +10 -53
- package/dist/umd/spinner.global.js +623 -105
- package/dist/umd/spinner.global.min.js +10 -10
- package/package.json +2 -2
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { AnimationFrame, AnimationLabel, SpinnerAnimation } from "../animation.js";
|
|
2
|
+
import { type Backend } from "../engines/little-3d-engine/little-3d-engine.js";
|
|
3
|
+
import type { MotionController } from "../motion/controller.js";
|
|
4
|
+
export interface GhostTrainOptions {
|
|
5
|
+
/** How the convoy moves. Default a tilted square track. */
|
|
6
|
+
motion?: MotionController;
|
|
7
|
+
/** Uniform car size in scene units. Default `0.3`. */
|
|
8
|
+
size?: number;
|
|
9
|
+
/** Rendering backend. Default `"canvas2d"`. */
|
|
10
|
+
backend?: Backend;
|
|
11
|
+
/** Overlay label; progress mode shows a percentage. */
|
|
12
|
+
label?: AnimationLabel;
|
|
13
|
+
/** Fade the label with the story's beginning and end. Default `true`. */
|
|
14
|
+
fadeLabel?: boolean;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* A progress story: a translucent train of ice cubes runs laps around a tilted
|
|
18
|
+
* square track, cars turning smoothly through the corners as if seen from above.
|
|
19
|
+
* Every 2% of progress attaches one more car, popping it into existence at the
|
|
20
|
+
* tail. At 100% the lead car keeps going in its current direction of travel and
|
|
21
|
+
* every following car funnels through that same exit point along the exact same
|
|
22
|
+
* path, the convoy accelerating clear of the view within four seconds. Car
|
|
23
|
+
* count follows the
|
|
24
|
+
* reported progress, so scrubbing in either direction stays smooth.
|
|
25
|
+
* {@link trailEmitter} exposes the lead car to a particle layer for the star trail.
|
|
26
|
+
*/
|
|
27
|
+
export declare class GhostTrainAnimation implements SpinnerAnimation {
|
|
28
|
+
private engine?;
|
|
29
|
+
private label?;
|
|
30
|
+
private observer?;
|
|
31
|
+
private readonly cars;
|
|
32
|
+
private readonly appear;
|
|
33
|
+
private readonly headings;
|
|
34
|
+
private readonly motion;
|
|
35
|
+
private readonly size;
|
|
36
|
+
private readonly backend?;
|
|
37
|
+
private readonly labelContent?;
|
|
38
|
+
private readonly fadeLabel;
|
|
39
|
+
private aspect;
|
|
40
|
+
private enterAt;
|
|
41
|
+
private outroAt;
|
|
42
|
+
private carsAtOutro;
|
|
43
|
+
private exitPathTime;
|
|
44
|
+
private exitPoint;
|
|
45
|
+
private exitDir;
|
|
46
|
+
private exitSpeed;
|
|
47
|
+
private lastNow;
|
|
48
|
+
private finished;
|
|
49
|
+
constructor(options?: GhostTrainOptions);
|
|
50
|
+
mount(target: HTMLElement): void;
|
|
51
|
+
enter(now: number): void;
|
|
52
|
+
exit(now: number): void;
|
|
53
|
+
isFinished(): boolean;
|
|
54
|
+
/** Milliseconds the lead car keeps moving into the outro; feed a trail layer's `outroMs`. */
|
|
55
|
+
get outroDurationMs(): number;
|
|
56
|
+
/**
|
|
57
|
+
* A {@link MotionController} following the lead car's actual position, through
|
|
58
|
+
* laps and the accelerating escape. Feed it to a particle layer's `emitter`
|
|
59
|
+
* so the star trail stays behind the train.
|
|
60
|
+
*/
|
|
61
|
+
trailEmitter(): MotionController;
|
|
62
|
+
render(now: number, frame: AnimationFrame): void;
|
|
63
|
+
destroy(): void;
|
|
64
|
+
/** Extra path-time every car has accelerated forward by, `now` ms into the outro. */
|
|
65
|
+
private warp;
|
|
66
|
+
/**
|
|
67
|
+
* The single trajectory every car rides, sampled at path-time `p`: the track
|
|
68
|
+
* up to the exit switch point, then a straight escape outward. Because the
|
|
69
|
+
* switch point and direction are shared, all cars follow the exact same path.
|
|
70
|
+
*/
|
|
71
|
+
private pathPosition;
|
|
72
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { animationLabelOpacity, mountAnimationLabel, } from "../animation-label.js";
|
|
2
|
+
import { Little3dEngine, cube, cross, dot, normalize, subtract, } from "../engines/little-3d-engine/little-3d-engine.js";
|
|
3
|
+
import { squareMotion } from "../motion/square.js";
|
|
4
|
+
import { easeOutBack } from "../engines/little-tween-engine/core/tweens.js";
|
|
5
|
+
const MAX_CARS = 50; // one car per 2% of progress
|
|
6
|
+
const CAMERA_Z = 3;
|
|
7
|
+
const FOV = (55 * Math.PI) / 180;
|
|
8
|
+
const HALF_HEIGHT = Math.tan(FOV / 2) * CAMERA_Z;
|
|
9
|
+
const RUN_GAP_MS = 130; // spacing between cars in path-time; 49 gaps stay under one lap
|
|
10
|
+
const POP_MS = 320; // a new car pops into existence over this long
|
|
11
|
+
const SAMPLE_MS = 8; // heading sample step
|
|
12
|
+
const TURN_RATE = (0.4 * Math.PI) / 180; // max heading change per millisecond (smooth cornering)
|
|
13
|
+
// Blast-off: the whole convoy funnels through the lead car's exit point and
|
|
14
|
+
// follows the exact same escape path. A time-warp accelerates every car's
|
|
15
|
+
// path-time after exit so all of them clear the view within MAX_OUTRO_MS.
|
|
16
|
+
const MAX_OUTRO_MS = 4000;
|
|
17
|
+
const WARP_ACCEL = 1000; // extra path-milliseconds accrued, = 0.5 * WARP_ACCEL * seconds^2
|
|
18
|
+
const TRAIL_OUTRO_MS = 1200; // how long the lead keeps shedding stars into the outro
|
|
19
|
+
const TRANSPARENCY = { mode: "two-sided", opacity: 0.275 };
|
|
20
|
+
const CAR_COLORS = ["#bae6fd", "#7dd3fc", "#38bdf8", "#0ea5e9", "#a5f3fc", "#e0f2fe"];
|
|
21
|
+
const WORLD_UP = { x: 0, y: 1, z: 0 };
|
|
22
|
+
function clamp01(value) {
|
|
23
|
+
return Math.max(0, Math.min(1, value));
|
|
24
|
+
}
|
|
25
|
+
/** Orientation (engine Euler) that points the car's local +X along `forward`, kept upright. */
|
|
26
|
+
function orientationFor(forward) {
|
|
27
|
+
const fwd = normalize(forward);
|
|
28
|
+
let right = cross(fwd, WORLD_UP);
|
|
29
|
+
if (Math.hypot(right.x, right.y, right.z) < 1e-4)
|
|
30
|
+
right = { x: 0, y: 0, z: 1 };
|
|
31
|
+
right = normalize(right);
|
|
32
|
+
const up = cross(right, fwd);
|
|
33
|
+
const w = normalize(cross(fwd, up));
|
|
34
|
+
return {
|
|
35
|
+
x: Math.atan2(cross(w, fwd).z, w.z),
|
|
36
|
+
y: Math.asin(Math.max(-1, Math.min(1, -fwd.z))),
|
|
37
|
+
z: Math.atan2(fwd.y, fwd.x),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/** Rotate unit vector `from` toward `to` by at most `maxRad`, for a rate-limited turn. */
|
|
41
|
+
function rotateToward(from, to, maxRad) {
|
|
42
|
+
const a = normalize(from);
|
|
43
|
+
const b = normalize(to);
|
|
44
|
+
const d = Math.max(-1, Math.min(1, dot(a, b)));
|
|
45
|
+
const angle = Math.acos(d);
|
|
46
|
+
if (angle <= maxRad || angle < 1e-4)
|
|
47
|
+
return b;
|
|
48
|
+
const sin = Math.sin(angle);
|
|
49
|
+
if (sin < 1e-4)
|
|
50
|
+
return b; // (near-)opposite: snap rather than spin unpredictably
|
|
51
|
+
const t = maxRad / angle;
|
|
52
|
+
const w1 = Math.sin((1 - t) * angle) / sin;
|
|
53
|
+
const w2 = Math.sin(t * angle) / sin;
|
|
54
|
+
return normalize({ x: a.x * w1 + b.x * w2, y: a.y * w1 + b.y * w2, z: a.z * w1 + b.z * w2 });
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* A progress story: a translucent train of ice cubes runs laps around a tilted
|
|
58
|
+
* square track, cars turning smoothly through the corners as if seen from above.
|
|
59
|
+
* Every 2% of progress attaches one more car, popping it into existence at the
|
|
60
|
+
* tail. At 100% the lead car keeps going in its current direction of travel and
|
|
61
|
+
* every following car funnels through that same exit point along the exact same
|
|
62
|
+
* path, the convoy accelerating clear of the view within four seconds. Car
|
|
63
|
+
* count follows the
|
|
64
|
+
* reported progress, so scrubbing in either direction stays smooth.
|
|
65
|
+
* {@link trailEmitter} exposes the lead car to a particle layer for the star trail.
|
|
66
|
+
*/
|
|
67
|
+
export class GhostTrainAnimation {
|
|
68
|
+
constructor(options = {}) {
|
|
69
|
+
this.cars = [];
|
|
70
|
+
this.appear = new Array(MAX_CARS).fill(0);
|
|
71
|
+
this.headings = new Array(MAX_CARS).fill(undefined);
|
|
72
|
+
this.aspect = 16 / 9;
|
|
73
|
+
this.enterAt = Infinity;
|
|
74
|
+
this.outroAt = Infinity;
|
|
75
|
+
this.carsAtOutro = 0;
|
|
76
|
+
this.exitPathTime = 0; // lead car's path-time at blast-off (the escape switch point)
|
|
77
|
+
this.exitPoint = { x: 0, y: 0, z: 0 };
|
|
78
|
+
this.exitDir = { x: 1, y: 0, z: 0 }; // shared escape direction, outward from the track
|
|
79
|
+
this.exitSpeed = 0.001; // path-units per path-millisecond at the switch (keeps speed continuous)
|
|
80
|
+
this.lastNow = 0;
|
|
81
|
+
this.finished = false;
|
|
82
|
+
this.motion = options.motion ?? squareMotion({ size: 1.7, periodMs: 6800, tilt: 0.5 });
|
|
83
|
+
this.size = options.size ?? 0.15;
|
|
84
|
+
this.backend = options.backend;
|
|
85
|
+
this.labelContent = options.label;
|
|
86
|
+
this.fadeLabel = options.fadeLabel ?? true;
|
|
87
|
+
}
|
|
88
|
+
mount(target) {
|
|
89
|
+
if (!target.style.position)
|
|
90
|
+
target.style.position = "relative";
|
|
91
|
+
const engine = new Little3dEngine({
|
|
92
|
+
backend: this.backend,
|
|
93
|
+
camera: { position: { x: 0, y: 0, z: CAMERA_Z }, fov: FOV },
|
|
94
|
+
});
|
|
95
|
+
const mesh = cube(1, CAR_COLORS);
|
|
96
|
+
for (let i = 0; i < MAX_CARS; i++) {
|
|
97
|
+
this.cars.push(engine.add(mesh, { scale: 0, transparency: { ...TRANSPARENCY } }));
|
|
98
|
+
}
|
|
99
|
+
this.engine = engine;
|
|
100
|
+
engine.mount(target).catch((error) => {
|
|
101
|
+
target.textContent = error instanceof Error ? error.message : String(error);
|
|
102
|
+
});
|
|
103
|
+
const measure = () => {
|
|
104
|
+
if (target.clientWidth > 0 && target.clientHeight > 0) {
|
|
105
|
+
this.aspect = target.clientWidth / target.clientHeight;
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
measure();
|
|
109
|
+
this.observer = new ResizeObserver(measure);
|
|
110
|
+
this.observer.observe(target);
|
|
111
|
+
this.label = mountAnimationLabel(target, this.labelContent);
|
|
112
|
+
if (this.fadeLabel)
|
|
113
|
+
this.label.setOpacity(0);
|
|
114
|
+
}
|
|
115
|
+
enter(now) {
|
|
116
|
+
if (this.enterAt === Infinity)
|
|
117
|
+
this.enterAt = now;
|
|
118
|
+
}
|
|
119
|
+
exit(now) {
|
|
120
|
+
if (this.outroAt !== Infinity || this.enterAt === Infinity)
|
|
121
|
+
return;
|
|
122
|
+
this.outroAt = now;
|
|
123
|
+
this.carsAtOutro = this.appear.filter((a) => a > 0.5).length;
|
|
124
|
+
this.exitPathTime = now - this.enterAt;
|
|
125
|
+
const from = this.motion.positionAt(this.exitPathTime);
|
|
126
|
+
const velocity = subtract(this.motion.positionAt(this.exitPathTime + 1), this.motion.positionAt(this.exitPathTime - 1));
|
|
127
|
+
const speed = Math.hypot(velocity.x, velocity.y, velocity.z);
|
|
128
|
+
this.exitPoint = from;
|
|
129
|
+
if (speed > 1e-6)
|
|
130
|
+
this.exitSpeed = speed / 2;
|
|
131
|
+
// The lead car keeps going in its current direction of travel; every car
|
|
132
|
+
// funnels through the exit point and follows that same straight path out.
|
|
133
|
+
this.exitDir = speed > 1e-6 ? normalize(velocity) : { x: 1, y: 0, z: 0 };
|
|
134
|
+
}
|
|
135
|
+
isFinished() {
|
|
136
|
+
return this.finished;
|
|
137
|
+
}
|
|
138
|
+
/** Milliseconds the lead car keeps moving into the outro; feed a trail layer's `outroMs`. */
|
|
139
|
+
get outroDurationMs() {
|
|
140
|
+
return TRAIL_OUTRO_MS;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* A {@link MotionController} following the lead car's actual position, through
|
|
144
|
+
* laps and the accelerating escape. Feed it to a particle layer's `emitter`
|
|
145
|
+
* so the star trail stays behind the train.
|
|
146
|
+
*/
|
|
147
|
+
trailEmitter() {
|
|
148
|
+
return {
|
|
149
|
+
positionAt: (t) => this.enterAt === Infinity
|
|
150
|
+
? this.motion.positionAt(t)
|
|
151
|
+
: this.pathPosition(t - this.enterAt + this.warp(t)),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
render(now, frame) {
|
|
155
|
+
if (!this.engine || !this.label)
|
|
156
|
+
return;
|
|
157
|
+
for (const car of this.cars)
|
|
158
|
+
car.transform.scale = 0;
|
|
159
|
+
if (this.enterAt === Infinity) {
|
|
160
|
+
this.engine.render();
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
const dt = this.lastNow === 0 ? 16 : Math.min(50, now - this.lastNow);
|
|
164
|
+
this.lastNow = now;
|
|
165
|
+
// One car per 2%; round so the fiftieth attaches ~99% and has a beat to pop
|
|
166
|
+
// before the 100% blast-off (mirrors the rocket-launch count).
|
|
167
|
+
const want = this.outroAt !== Infinity
|
|
168
|
+
? this.carsAtOutro
|
|
169
|
+
: Math.min(MAX_CARS, Math.round(frame.progress * MAX_CARS));
|
|
170
|
+
const halfWidth = HALF_HEIGHT * this.aspect;
|
|
171
|
+
const warp = this.warp(now);
|
|
172
|
+
let anyOnScreen = false;
|
|
173
|
+
for (let k = 0; k < MAX_CARS; k++) {
|
|
174
|
+
const target = k < want ? 1 : 0;
|
|
175
|
+
this.appear[k] = clamp01(this.appear[k] + Math.sign(target - this.appear[k]) * (dt / POP_MS));
|
|
176
|
+
if (this.appear[k] <= 0) {
|
|
177
|
+
this.headings[k] = undefined;
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
const p = now - this.enterAt - k * RUN_GAP_MS + warp;
|
|
181
|
+
const position = this.pathPosition(p);
|
|
182
|
+
if (Math.abs(position.x) > halfWidth + this.size || Math.abs(position.y) > HALF_HEIGHT + this.size) {
|
|
183
|
+
continue; // off-screen: leave it hidden
|
|
184
|
+
}
|
|
185
|
+
const ahead = subtract(this.pathPosition(p + SAMPLE_MS), position);
|
|
186
|
+
const targetDir = Math.hypot(ahead.x, ahead.y, ahead.z) > 1e-5
|
|
187
|
+
? ahead
|
|
188
|
+
: this.headings[k] ?? { x: 1, y: 0, z: 0 };
|
|
189
|
+
this.headings[k] = this.headings[k]
|
|
190
|
+
? rotateToward(this.headings[k], targetDir, TURN_RATE * dt)
|
|
191
|
+
: normalize(targetDir);
|
|
192
|
+
const orientation = orientationFor(this.headings[k]);
|
|
193
|
+
const transform = this.cars[k].transform;
|
|
194
|
+
transform.position.x = position.x;
|
|
195
|
+
transform.position.y = position.y;
|
|
196
|
+
transform.position.z = position.z;
|
|
197
|
+
transform.rotation.x = orientation.x;
|
|
198
|
+
transform.rotation.y = orientation.y;
|
|
199
|
+
transform.rotation.z = orientation.z;
|
|
200
|
+
transform.scale = this.size * easeOutBack(this.appear[k]);
|
|
201
|
+
anyOnScreen = true;
|
|
202
|
+
}
|
|
203
|
+
this.label.setText(frame.indeterminate
|
|
204
|
+
? (typeof this.labelContent === "string" ? this.labelContent : "")
|
|
205
|
+
: `${Math.round(frame.progress * 100)}%`);
|
|
206
|
+
if (this.fadeLabel) {
|
|
207
|
+
this.label.setOpacity(animationLabelOpacity(now, this.enterAt, POP_MS, this.outroAt, TRAIL_OUTRO_MS));
|
|
208
|
+
}
|
|
209
|
+
if (this.outroAt !== Infinity && now > this.outroAt + 300 &&
|
|
210
|
+
(!anyOnScreen || now >= this.outroAt + MAX_OUTRO_MS)) {
|
|
211
|
+
this.finished = true;
|
|
212
|
+
}
|
|
213
|
+
this.engine.render();
|
|
214
|
+
}
|
|
215
|
+
destroy() {
|
|
216
|
+
this.observer?.disconnect();
|
|
217
|
+
this.observer = undefined;
|
|
218
|
+
this.label?.container.remove();
|
|
219
|
+
this.label = undefined;
|
|
220
|
+
this.engine?.destroy();
|
|
221
|
+
this.engine = undefined;
|
|
222
|
+
this.cars.length = 0;
|
|
223
|
+
}
|
|
224
|
+
/** Extra path-time every car has accelerated forward by, `now` ms into the outro. */
|
|
225
|
+
warp(now) {
|
|
226
|
+
if (this.outroAt === Infinity)
|
|
227
|
+
return 0;
|
|
228
|
+
const seconds = (now - this.outroAt) / 1000;
|
|
229
|
+
return 0.5 * WARP_ACCEL * seconds * seconds;
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* The single trajectory every car rides, sampled at path-time `p`: the track
|
|
233
|
+
* up to the exit switch point, then a straight escape outward. Because the
|
|
234
|
+
* switch point and direction are shared, all cars follow the exact same path.
|
|
235
|
+
*/
|
|
236
|
+
pathPosition(p) {
|
|
237
|
+
if (this.outroAt === Infinity || p <= this.exitPathTime) {
|
|
238
|
+
return this.motion.positionAt(p);
|
|
239
|
+
}
|
|
240
|
+
const distance = this.exitSpeed * (p - this.exitPathTime);
|
|
241
|
+
return {
|
|
242
|
+
x: this.exitPoint.x + this.exitDir.x * distance,
|
|
243
|
+
y: this.exitPoint.y + this.exitDir.y * distance,
|
|
244
|
+
z: this.exitPoint.z + this.exitDir.z * distance,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
}
|
|
@@ -19,13 +19,14 @@ export interface GridAssemblyOptions {
|
|
|
19
19
|
fadeLabel?: boolean;
|
|
20
20
|
}
|
|
21
21
|
/**
|
|
22
|
-
* A progress story in three acts: 25
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
22
|
+
* A progress story in three acts: 25 pastel dark-blue cubes fly in and circle
|
|
23
|
+
* just inside the view edge, completing the full ring before any of them move;
|
|
24
|
+
* once the circle is complete they leave the orbit one by one as progress climbs
|
|
25
|
+
* and dock into a 5x5 grid at the center (docked cubes idle with a staggered
|
|
26
|
+
* spin every two seconds); at completion the finished grid holds for a second,
|
|
27
|
+
* then the cubes dive into the center a little staggered, shrinking away and
|
|
28
|
+
* vanishing with a small pop. Docking is driven by time-based blends toward
|
|
29
|
+
* per-shape targets, so a progress jump in either direction stays smooth.
|
|
29
30
|
*/
|
|
30
31
|
export declare class GridAssemblyAnimation implements SpinnerAnimation {
|
|
31
32
|
private engine?;
|
|
@@ -36,6 +37,9 @@ export declare class GridAssemblyAnimation implements SpinnerAnimation {
|
|
|
36
37
|
private readonly dockedAt;
|
|
37
38
|
private readonly tumbleX;
|
|
38
39
|
private readonly tumbleY;
|
|
40
|
+
private readonly collapseDelay;
|
|
41
|
+
private readonly popStarted;
|
|
42
|
+
private maxCollapseDelay;
|
|
39
43
|
private readonly fades;
|
|
40
44
|
private readonly slots;
|
|
41
45
|
private readonly meshes;
|
|
@@ -52,7 +56,6 @@ export declare class GridAssemblyAnimation implements SpinnerAnimation {
|
|
|
52
56
|
private collapseAt;
|
|
53
57
|
private captured?;
|
|
54
58
|
private lastNow;
|
|
55
|
-
private popFading;
|
|
56
59
|
private finished;
|
|
57
60
|
constructor(options?: GridAssemblyOptions);
|
|
58
61
|
mount(target: HTMLElement): void;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { animationLabelOpacity, mountAnimationLabel, } from "../animation-label.js";
|
|
2
|
-
import { Little3dEngine, cube,
|
|
2
|
+
import { Little3dEngine, cube, } from "../engines/little-3d-engine/little-3d-engine.js";
|
|
3
3
|
import { easeInCubic, easeInOutCubic, easeOutCubic, } from "../engines/little-tween-engine/core/tweens.js";
|
|
4
4
|
const GRID = 5;
|
|
5
5
|
const COUNT = GRID * GRID;
|
|
@@ -8,6 +8,8 @@ const FOV = (55 * Math.PI) / 180;
|
|
|
8
8
|
const TWO_PI = Math.PI * 2;
|
|
9
9
|
const INTRO_MS = 900;
|
|
10
10
|
const INTRO_STAGGER_MS = 60;
|
|
11
|
+
// The orbit ring is fully formed once the last-staggered shape finishes flying in.
|
|
12
|
+
const INTRO_DONE_MS = (COUNT - 1) * INTRO_STAGGER_MS + INTRO_MS;
|
|
11
13
|
const GATE_DOCK = 0.35;
|
|
12
14
|
const GATE_UNDOCK = 0.65;
|
|
13
15
|
const EXIT_HURRY = 2.5;
|
|
@@ -16,15 +18,12 @@ const SPIN_MS = 380;
|
|
|
16
18
|
const SPIN_STAGGER_MS = 80;
|
|
17
19
|
const HOLD_MS = 1000;
|
|
18
20
|
const COLLAPSE_MS = 700;
|
|
21
|
+
const COLLAPSE_SPREAD_MS = 500; // shapes leave a little staggered, not all at once
|
|
19
22
|
const POP_MS = 170;
|
|
20
23
|
const LABEL_FADE_MS = 600;
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
() => octahedron(1, ["#34d399", "#10b981", "#059669", "#6ee7b7", "#a7f3d0", "#047857", "#4ade80", "#065f46"]),
|
|
25
|
-
() => pyramid(1, ["#fbbf24", "#f59e0b", "#d97706", "#fde68a", "#fcd34d"]),
|
|
26
|
-
() => icosphere(1, 1, ["#a78bfa", "#8b5cf6", "#7c3aed", "#c4b5fd"]),
|
|
27
|
-
];
|
|
24
|
+
// Every shape is the same pastel dark-blue cube; the six face tints give it depth.
|
|
25
|
+
const CUBE_COLORS = ["#8397c6", "#7186b8", "#6176a8", "#93a6cf", "#556a9c", "#7a8fc0"];
|
|
26
|
+
const DEFAULT_MESHES = [() => cube(1, CUBE_COLORS)];
|
|
28
27
|
function clamp01(value) {
|
|
29
28
|
return Math.max(0, Math.min(1, value));
|
|
30
29
|
}
|
|
@@ -48,13 +47,14 @@ function hash01(index, salt) {
|
|
|
48
47
|
return (h >>> 0) / 4294967296;
|
|
49
48
|
}
|
|
50
49
|
/**
|
|
51
|
-
* A progress story in three acts: 25
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
50
|
+
* A progress story in three acts: 25 pastel dark-blue cubes fly in and circle
|
|
51
|
+
* just inside the view edge, completing the full ring before any of them move;
|
|
52
|
+
* once the circle is complete they leave the orbit one by one as progress climbs
|
|
53
|
+
* and dock into a 5x5 grid at the center (docked cubes idle with a staggered
|
|
54
|
+
* spin every two seconds); at completion the finished grid holds for a second,
|
|
55
|
+
* then the cubes dive into the center a little staggered, shrinking away and
|
|
56
|
+
* vanishing with a small pop. Docking is driven by time-based blends toward
|
|
57
|
+
* per-shape targets, so a progress jump in either direction stays smooth.
|
|
58
58
|
*/
|
|
59
59
|
export class GridAssemblyAnimation {
|
|
60
60
|
constructor(options = {}) {
|
|
@@ -63,6 +63,9 @@ export class GridAssemblyAnimation {
|
|
|
63
63
|
this.dockedAt = new Array(COUNT).fill(Infinity);
|
|
64
64
|
this.tumbleX = [];
|
|
65
65
|
this.tumbleY = [];
|
|
66
|
+
this.collapseDelay = [];
|
|
67
|
+
this.popStarted = new Array(COUNT).fill(false);
|
|
68
|
+
this.maxCollapseDelay = 0;
|
|
66
69
|
this.fades = [];
|
|
67
70
|
this.slots = [];
|
|
68
71
|
this.aspect = 16 / 9;
|
|
@@ -71,7 +74,6 @@ export class GridAssemblyAnimation {
|
|
|
71
74
|
this.allDockedAt = Infinity;
|
|
72
75
|
this.collapseAt = Infinity;
|
|
73
76
|
this.lastNow = 0;
|
|
74
|
-
this.popFading = false;
|
|
75
77
|
this.finished = false;
|
|
76
78
|
const sources = options.meshes && options.meshes.length > 0 ? options.meshes : DEFAULT_MESHES;
|
|
77
79
|
this.meshes = sources.map(resolveMesh);
|
|
@@ -88,7 +90,9 @@ export class GridAssemblyAnimation {
|
|
|
88
90
|
this.slots.push({ x: (col - 2) * spacing, y: (2 - row) * spacing, z: 0 });
|
|
89
91
|
this.tumbleX.push(TWO_PI * hash01(i, 2) - Math.PI);
|
|
90
92
|
this.tumbleY.push(TWO_PI * hash01(i, 4) - Math.PI);
|
|
93
|
+
this.collapseDelay.push(hash01(i, 7) * COLLAPSE_SPREAD_MS);
|
|
91
94
|
}
|
|
95
|
+
this.maxCollapseDelay = Math.max(...this.collapseDelay);
|
|
92
96
|
}
|
|
93
97
|
mount(target) {
|
|
94
98
|
if (!target.style.position)
|
|
@@ -153,7 +157,7 @@ export class GridAssemblyAnimation {
|
|
|
153
157
|
if (this.fadeLabel) {
|
|
154
158
|
this.label.setOpacity(animationLabelOpacity(now, this.enterAt, LABEL_FADE_MS, this.collapseAt, COLLAPSE_MS));
|
|
155
159
|
}
|
|
156
|
-
if (this.collapseAt !== Infinity && now >= this.collapseAt + COLLAPSE_MS + POP_MS) {
|
|
160
|
+
if (this.collapseAt !== Infinity && now >= this.collapseAt + this.maxCollapseDelay + COLLAPSE_MS + POP_MS) {
|
|
157
161
|
this.finished = true;
|
|
158
162
|
}
|
|
159
163
|
this.engine.render();
|
|
@@ -170,7 +174,12 @@ export class GridAssemblyAnimation {
|
|
|
170
174
|
}
|
|
171
175
|
updateBlends(dt, progress, now) {
|
|
172
176
|
const exiting = this.exitAt !== Infinity;
|
|
173
|
-
|
|
177
|
+
// Hold every shape on the orbit until the ring is fully formed: no shape
|
|
178
|
+
// starts diving to its grid cell before the circle is complete.
|
|
179
|
+
const ringComplete = now - this.enterAt >= INTRO_DONE_MS;
|
|
180
|
+
const want = !ringComplete
|
|
181
|
+
? 0
|
|
182
|
+
: exiting ? COUNT : Math.min(COUNT, Math.floor(progress * COUNT + 1e-9));
|
|
174
183
|
const rate = (dt / this.dockMs) * (exiting ? EXIT_HURRY : 1);
|
|
175
184
|
for (let i = 0; i < COUNT; i++) {
|
|
176
185
|
const target = i < want ? 1 : 0;
|
|
@@ -241,27 +250,32 @@ export class GridAssemblyAnimation {
|
|
|
241
250
|
if (!this.captured) {
|
|
242
251
|
this.captured = this.handles.map((handle) => ({ ...handle.transform.position }));
|
|
243
252
|
}
|
|
244
|
-
|
|
245
|
-
|
|
253
|
+
// Each shape dives and pops on its own small delay, so the grid empties a
|
|
254
|
+
// little staggered instead of all at once.
|
|
246
255
|
for (let i = 0; i < COUNT; i++) {
|
|
247
256
|
const transform = this.handles[i].transform;
|
|
248
257
|
const from = this.captured[i];
|
|
258
|
+
const local = now - this.collapseAt - this.collapseDelay[i];
|
|
259
|
+
if (local <= 0) {
|
|
260
|
+
transform.position.x = from.x;
|
|
261
|
+
transform.position.y = from.y;
|
|
262
|
+
transform.position.z = from.z;
|
|
263
|
+
transform.scale = this.size;
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
const pull = easeInCubic(clamp01(local / COLLAPSE_MS));
|
|
249
267
|
transform.position.x = from.x * (1 - pull);
|
|
250
268
|
transform.position.y = from.y * (1 - pull);
|
|
251
269
|
transform.position.z = from.z * (1 - pull);
|
|
252
270
|
transform.scale = this.size * (1 - 0.99 * pull);
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
this.popFading = true;
|
|
257
|
-
for (let i = 0; i < COUNT; i++)
|
|
271
|
+
if (local >= COLLAPSE_MS) {
|
|
272
|
+
if (!this.popStarted[i]) {
|
|
273
|
+
this.popStarted[i] = true;
|
|
258
274
|
this.handles[i].transparency = this.fades[i];
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
for (let i = 0; i < COUNT; i++) {
|
|
275
|
+
}
|
|
276
|
+
const v = clamp01((local - COLLAPSE_MS) / POP_MS);
|
|
262
277
|
this.fades[i].opacity = 1 - v;
|
|
263
|
-
this.
|
|
264
|
-
v >= 1 ? 0 : this.size * 0.01 * (1 + 1.6 * Math.sin(Math.PI * v));
|
|
278
|
+
transform.scale = v >= 1 ? 0 : this.size * 0.01 * (1 + 1.6 * Math.sin(Math.PI * v));
|
|
265
279
|
}
|
|
266
280
|
}
|
|
267
281
|
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { AnimationFrame, AnimationLabel, SpinnerAnimation } from "../animation.js";
|
|
2
|
+
import { type Backend } from "../engines/little-3d-engine/little-3d-engine.js";
|
|
3
|
+
export interface RocketLaunchOptions {
|
|
4
|
+
/** Rendering backend. Default `"canvas2d"`. */
|
|
5
|
+
backend?: Backend;
|
|
6
|
+
/** Overlay label; progress mode shows a percentage. */
|
|
7
|
+
label?: AnimationLabel;
|
|
8
|
+
/** Fade the label with the story's beginning and launch. Default `true`. */
|
|
9
|
+
fadeLabel?: boolean;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* A progress story told by a launch pad. Every 5% of progress a small rocket
|
|
13
|
+
* slides in cartoon-style from the right and lines up left-to-right under the
|
|
14
|
+
* progress text, idling with a thin wisp of smoke. At 100% the whole row blasts
|
|
15
|
+
* off in a loose stagger on columns of fire; partway up three of them suddenly
|
|
16
|
+
* veer 30-50 degrees and streak away. Rocket count follows the reported
|
|
17
|
+
* progress, so scrubbing in either direction stays smooth.
|
|
18
|
+
*/
|
|
19
|
+
export declare class RocketLaunchAnimation implements SpinnerAnimation {
|
|
20
|
+
private engine?;
|
|
21
|
+
private label?;
|
|
22
|
+
private observer?;
|
|
23
|
+
private readonly backend?;
|
|
24
|
+
private readonly labelContent?;
|
|
25
|
+
private readonly fadeLabel;
|
|
26
|
+
private readonly rockets;
|
|
27
|
+
private readonly smoke;
|
|
28
|
+
private readonly fire;
|
|
29
|
+
private readonly smokeFades;
|
|
30
|
+
private readonly fireFades;
|
|
31
|
+
private readonly blends;
|
|
32
|
+
private readonly groundedAt;
|
|
33
|
+
private readonly turnS;
|
|
34
|
+
private readonly turnDir;
|
|
35
|
+
private readonly turnRoll;
|
|
36
|
+
private readonly stagger;
|
|
37
|
+
private aspect;
|
|
38
|
+
private enterAt;
|
|
39
|
+
private exitAt;
|
|
40
|
+
private launchedAt;
|
|
41
|
+
private lastNow;
|
|
42
|
+
private finished;
|
|
43
|
+
constructor(options?: RocketLaunchOptions);
|
|
44
|
+
mount(target: HTMLElement): void;
|
|
45
|
+
enter(now: number): void;
|
|
46
|
+
exit(now: number): void;
|
|
47
|
+
isFinished(): boolean;
|
|
48
|
+
render(now: number, frame: AnimationFrame): void;
|
|
49
|
+
destroy(): void;
|
|
50
|
+
private updateBlends;
|
|
51
|
+
/** Along-track distance climbed `la` ms after this rocket's own blast-off. */
|
|
52
|
+
private ascentDistance;
|
|
53
|
+
/** Rocket center, nose direction, and roll `la` ms into its climb. */
|
|
54
|
+
private ascentPose;
|
|
55
|
+
private renderAscent;
|
|
56
|
+
private emitFire;
|
|
57
|
+
private emitSmoke;
|
|
58
|
+
}
|