3d-spinner 0.9.2 → 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/README.md +66 -4
- package/dist/animation-label.d.ts +8 -0
- package/dist/animation-label.js +47 -0
- package/dist/animation.d.ts +2 -0
- package/dist/animations/charged-orb.d.ts +55 -0
- package/dist/animations/charged-orb.js +229 -0
- package/dist/animations/ghost-train.d.ts +72 -0
- package/dist/animations/ghost-train.js +247 -0
- package/dist/animations/grid-assembly.d.ts +70 -0
- package/dist/animations/grid-assembly.js +282 -0
- package/dist/animations/object-motion.d.ts +19 -7
- package/dist/animations/object-motion.js +38 -32
- package/dist/animations/particles.d.ts +110 -0
- package/dist/animations/particles.js +209 -0
- package/dist/animations/rocket-launch.d.ts +58 -0
- package/dist/animations/rocket-launch.js +375 -0
- package/dist/cjs/animations/charged-orb.cjs +1334 -0
- package/dist/cjs/animations/grid-assembly.cjs +1380 -0
- package/dist/cjs/animations/object-motion.cjs +124 -43
- package/dist/cjs/animations/particles.cjs +1940 -0
- package/dist/cjs/animations/spin.cjs +4 -3
- package/dist/cjs/composite-animation.cjs +58 -0
- package/dist/cjs/engines/little-3d-engine/little-3d-engine.cjs +143 -22
- package/dist/cjs/engines/little-3d-engine/loaders/obj.cjs +30 -1
- package/dist/cjs/engines/little-3d-engine/renderers/canvas2d-textured.cjs +345 -0
- package/dist/cjs/engines/little-3d-engine/renderers/webgl-textured.cjs +528 -0
- package/dist/cjs/engines/little-3d-engine/renderers/webgpu-textured.cjs +712 -0
- package/dist/cjs/prefabs/prefabs.cjs +3916 -0
- package/dist/composite-animation.d.ts +18 -0
- package/dist/composite-animation.js +39 -0
- package/dist/engines/little-3d-engine/little-3d-engine.d.ts +17 -12
- package/dist/engines/little-3d-engine/little-3d-engine.js +13 -8
- package/dist/engines/little-3d-engine/loaders/obj.d.ts +12 -4
- package/dist/engines/little-3d-engine/loaders/obj.js +44 -5
- package/dist/engines/little-3d-engine/renderer.d.ts +7 -1
- package/dist/engines/little-3d-engine/renderer.js +2 -0
- package/dist/engines/little-3d-engine/renderers/canvas2d-textured.d.ts +20 -0
- package/dist/engines/little-3d-engine/renderers/canvas2d-textured.js +135 -0
- package/dist/engines/little-3d-engine/renderers/textured-helpers.d.ts +9 -0
- package/dist/engines/little-3d-engine/renderers/textured-helpers.js +35 -0
- package/dist/engines/little-3d-engine/renderers/webgl-textured.d.ts +43 -0
- package/dist/engines/little-3d-engine/renderers/webgl-textured.js +211 -0
- package/dist/engines/little-3d-engine/renderers/webgpu-textured.d.ts +45 -0
- package/dist/engines/little-3d-engine/renderers/webgpu-textured.js +300 -0
- package/dist/engines/little-3d-engine/renderers/webgpu.d.ts +11 -6
- package/dist/engines/little-3d-engine/shapes/complex/plane.d.ts +3 -0
- package/dist/engines/little-3d-engine/shapes/complex/plane.js +33 -0
- package/dist/engines/little-3d-engine/shapes/{cube.d.ts → primitives/cube.d.ts} +1 -1
- package/dist/engines/little-3d-engine/shapes/{octahedron.d.ts → primitives/octahedron.d.ts} +1 -1
- package/dist/engines/little-3d-engine/shapes/{pyramid.d.ts → primitives/pyramid.d.ts} +1 -1
- package/dist/engines/little-3d-engine/shapes/primitives/quad.d.ts +9 -0
- package/dist/engines/little-3d-engine/shapes/primitives/quad.js +18 -0
- package/dist/engines/little-3d-engine/shapes/{cube-sphere.d.ts → primitives/spheres/cube-sphere.d.ts} +1 -1
- package/dist/engines/little-3d-engine/shapes/{icosphere.d.ts → primitives/spheres/icosphere.d.ts} +1 -1
- package/dist/engines/little-3d-engine/shapes/{icosphere.js → primitives/spheres/icosphere.js} +1 -1
- package/dist/engines/little-3d-engine/shapes/{octa-sphere.d.ts → primitives/spheres/octa-sphere.d.ts} +1 -1
- package/dist/engines/little-3d-engine/shapes/{octa-sphere.js → primitives/spheres/octa-sphere.js} +1 -1
- package/dist/engines/little-3d-engine/shapes/{uv-sphere.d.ts → primitives/spheres/uv-sphere.d.ts} +1 -1
- package/dist/engines/little-3d-engine/shapes/{tetrahedron.d.ts → primitives/tetrahedron.d.ts} +1 -1
- package/dist/engines/little-3d-engine/textures/dynamic/canvas-texture.d.ts +1 -0
- package/dist/engines/little-3d-engine/textures/dynamic/canvas-texture.js +8 -0
- package/dist/engines/little-3d-engine/textures/dynamic/shine.d.ts +2 -0
- package/dist/engines/little-3d-engine/textures/dynamic/shine.js +14 -0
- package/dist/engines/little-3d-engine/textures/dynamic/star.d.ts +14 -0
- package/dist/engines/little-3d-engine/textures/dynamic/star.js +31 -0
- package/dist/engines/little-3d-engine/textures/dynamic/streak.d.ts +5 -0
- package/dist/engines/little-3d-engine/textures/dynamic/streak.js +20 -0
- package/dist/index.d.ts +2 -1
- package/dist/prefabs/charged-orb.d.ts +17 -0
- package/dist/prefabs/charged-orb.js +37 -0
- package/dist/prefabs/crystal-comet.d.ts +4 -0
- package/dist/prefabs/crystal-comet.js +41 -0
- package/dist/prefabs/ghost-train.d.ts +10 -0
- package/dist/prefabs/ghost-train.js +37 -0
- package/dist/prefabs/grid-assembly.d.ts +13 -0
- package/dist/prefabs/grid-assembly.js +15 -0
- package/dist/prefabs/monochrome-streak.d.ts +4 -0
- package/dist/prefabs/monochrome-streak.js +25 -0
- package/dist/prefabs/plane-star-trail.d.ts +4 -0
- package/dist/prefabs/plane-star-trail.js +40 -0
- package/dist/prefabs/prefabs.d.ts +10 -0
- package/dist/prefabs/prefabs.js +9 -0
- package/dist/prefabs/pulsing-starfield.d.ts +4 -0
- package/dist/prefabs/pulsing-starfield.js +27 -0
- package/dist/prefabs/rocket-launch.d.ts +10 -0
- package/dist/prefabs/rocket-launch.js +16 -0
- package/dist/prefabs/spinner.d.ts +7 -0
- package/dist/prefabs/spinner.js +17 -0
- package/dist/prefabs/star-swarm.d.ts +4 -0
- package/dist/prefabs/star-swarm.js +27 -0
- package/dist/prefabs/types.d.ts +46 -0
- package/dist/prefabs/types.js +1 -0
- package/dist/umd/spinner.global.js +2407 -85
- package/dist/umd/spinner.global.min.js +60 -5
- package/package.json +43 -2
- /package/dist/engines/little-3d-engine/shapes/{cube.js → primitives/cube.js} +0 -0
- /package/dist/engines/little-3d-engine/shapes/{octahedron.js → primitives/octahedron.js} +0 -0
- /package/dist/engines/little-3d-engine/shapes/{pyramid.js → primitives/pyramid.js} +0 -0
- /package/dist/engines/little-3d-engine/shapes/{cube-sphere.js → primitives/spheres/cube-sphere.js} +0 -0
- /package/dist/engines/little-3d-engine/shapes/{uv-sphere.js → primitives/spheres/uv-sphere.js} +0 -0
- /package/dist/engines/little-3d-engine/shapes/{tetrahedron.js → primitives/tetrahedron.js} +0 -0
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
import { animationLabelOpacity, mountAnimationLabel, } from "../animation-label.js";
|
|
2
|
+
import { Little3dEngine, pyramid, quad, } from "../engines/little-3d-engine/little-3d-engine.js";
|
|
3
|
+
import { canvasTexture } from "../engines/little-3d-engine/textures/dynamic/canvas-texture.js";
|
|
4
|
+
import { easeOutBack } from "../engines/little-tween-engine/core/tweens.js";
|
|
5
|
+
const ROCKETS = 20; // one per 5% 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 SIZE = 0.12;
|
|
10
|
+
const ROW_Y = -0.5; // the row sits just under the centered progress text
|
|
11
|
+
const PARTICLE_Z = 0.3; // in front of the rockets so exhaust and smoke read clearly
|
|
12
|
+
const SLIDE_MS = 460; // cartoon slide-in from the right
|
|
13
|
+
const SLIDE_GATE = 0.45; // a rocket starts sliding once its left neighbor is this far in
|
|
14
|
+
const EXIT_HURRY = 2.5; // at 100% any stragglers rush in before the row can launch
|
|
15
|
+
const LAUNCH_SPREAD_MS = 620; // staggered blast-off window at 100%
|
|
16
|
+
const ASCENT_G = 5.2; // climb acceleration, scene units per second squared
|
|
17
|
+
const FINISH_PAD_MS = 2000; // time after the last blast-off before the scene is done
|
|
18
|
+
const TURNERS = 3; // rockets that veer off mid-climb
|
|
19
|
+
const TURN_MIN_Y = 0.2;
|
|
20
|
+
const TURN_MAX_Y = 0.8; // "about halfway", jittered so no two turn at the same height
|
|
21
|
+
const TURN_MIN_DEG = 30;
|
|
22
|
+
const TURN_MAX_DEG = 50;
|
|
23
|
+
const DEG = Math.PI / 180;
|
|
24
|
+
const SMOKE_LIFE_MS = 1400;
|
|
25
|
+
const SMOKE_GAP_MS = 320; // low rate: a lazy idle wisp per rocket
|
|
26
|
+
const SMOKE_RISE = 0.55;
|
|
27
|
+
const SMOKE_SIZE = 0.17;
|
|
28
|
+
const SMOKE_PEAK = 0.16; // very transparent
|
|
29
|
+
const SMOKE_POOL = 104;
|
|
30
|
+
const FIRE_LIFE_MS = 420;
|
|
31
|
+
const FIRE_GAP_MS = 55;
|
|
32
|
+
const FIRE_ON_MS = 950; // stop the exhaust once the rocket has cleared the view
|
|
33
|
+
const FIRE_TRAIL = 0.25; // how fast a puff drifts back off the nozzle
|
|
34
|
+
const FIRE_SPREAD = 0.09;
|
|
35
|
+
const FIRE_SIZE = 0.15;
|
|
36
|
+
const FIRE_PEAK = 0.9;
|
|
37
|
+
const FIRE_POOL = 140;
|
|
38
|
+
const SMOKE_COLORS = ["#e2e8f0", "#cbd5e1"];
|
|
39
|
+
const FIRE_COLORS = ["#fef3c7", "#fde047", "#fb923c", "#ef4444"];
|
|
40
|
+
const ROCKET_COLORS = ["#e2e8f0", "#f8fafc", "#cbd5e1", "#94a3b8", "#e2e8f0"];
|
|
41
|
+
function clamp01(value) {
|
|
42
|
+
return Math.max(0, Math.min(1, value));
|
|
43
|
+
}
|
|
44
|
+
function smoothstep(edge0, edge1, value) {
|
|
45
|
+
const x = clamp01((value - edge0) / (edge1 - edge0));
|
|
46
|
+
return x * x * (3 - 2 * x);
|
|
47
|
+
}
|
|
48
|
+
function hash01(index, salt) {
|
|
49
|
+
let h = (Math.imul(index + 1, 0x9e3779b9) ^ Math.imul(salt + 1, 0x85ebca6b)) >>> 0;
|
|
50
|
+
h = Math.imul(h ^ (h >>> 16), 0x45d9f3b);
|
|
51
|
+
h ^= h >>> 16;
|
|
52
|
+
return (h >>> 0) / 4294967296;
|
|
53
|
+
}
|
|
54
|
+
/** A soft round puff for tinting: white so the face color sets the hue, alpha falls off radially. */
|
|
55
|
+
function puffTexture(coreAlpha, coreStop) {
|
|
56
|
+
return canvasTexture((ctx) => {
|
|
57
|
+
const g = ctx.createRadialGradient(48, 48, 1, 48, 48, 47);
|
|
58
|
+
g.addColorStop(0, `rgba(255,255,255,${coreAlpha})`);
|
|
59
|
+
g.addColorStop(coreStop, `rgba(255,255,255,${coreAlpha * 0.6})`);
|
|
60
|
+
g.addColorStop(1, "rgba(255,255,255,0)");
|
|
61
|
+
ctx.fillStyle = g;
|
|
62
|
+
ctx.fillRect(0, 0, 96, 96);
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* A progress story told by a launch pad. Every 5% of progress a small rocket
|
|
67
|
+
* slides in cartoon-style from the right and lines up left-to-right under the
|
|
68
|
+
* progress text, idling with a thin wisp of smoke. At 100% the whole row blasts
|
|
69
|
+
* off in a loose stagger on columns of fire; partway up three of them suddenly
|
|
70
|
+
* veer 30-50 degrees and streak away. Rocket count follows the reported
|
|
71
|
+
* progress, so scrubbing in either direction stays smooth.
|
|
72
|
+
*/
|
|
73
|
+
export class RocketLaunchAnimation {
|
|
74
|
+
constructor(options = {}) {
|
|
75
|
+
this.rockets = [];
|
|
76
|
+
this.smoke = [];
|
|
77
|
+
this.fire = [];
|
|
78
|
+
this.smokeFades = [];
|
|
79
|
+
this.fireFades = [];
|
|
80
|
+
this.blends = new Array(ROCKETS).fill(0);
|
|
81
|
+
this.groundedAt = new Array(ROCKETS).fill(Infinity);
|
|
82
|
+
// Per-rocket veer parameters (turnS = Infinity for a rocket that climbs straight).
|
|
83
|
+
this.turnS = new Array(ROCKETS).fill(Infinity);
|
|
84
|
+
this.turnDir = [];
|
|
85
|
+
this.turnRoll = new Array(ROCKETS).fill(0);
|
|
86
|
+
this.stagger = new Array(ROCKETS).fill(0);
|
|
87
|
+
this.aspect = 16 / 9;
|
|
88
|
+
this.enterAt = Infinity;
|
|
89
|
+
this.exitAt = Infinity;
|
|
90
|
+
this.launchedAt = Infinity;
|
|
91
|
+
this.lastNow = 0;
|
|
92
|
+
this.finished = false;
|
|
93
|
+
this.backend = options.backend;
|
|
94
|
+
this.labelContent = options.label;
|
|
95
|
+
this.fadeLabel = options.fadeLabel ?? true;
|
|
96
|
+
for (let i = 0; i < ROCKETS; i++) {
|
|
97
|
+
this.turnDir.push({ x: 0, y: 1 });
|
|
98
|
+
this.stagger[i] = hash01(i, 7) * LAUNCH_SPREAD_MS;
|
|
99
|
+
}
|
|
100
|
+
// Pick the three veering rockets deterministically, then give each its own
|
|
101
|
+
// turn height, angle, and side.
|
|
102
|
+
const order = Array.from({ length: ROCKETS }, (_, i) => i).sort((a, b) => hash01(a, 11) - hash01(b, 11));
|
|
103
|
+
for (const i of order.slice(0, TURNERS)) {
|
|
104
|
+
const height = TURN_MIN_Y + hash01(i, 12) * (TURN_MAX_Y - TURN_MIN_Y);
|
|
105
|
+
const angle = (TURN_MIN_DEG + hash01(i, 13) * (TURN_MAX_DEG - TURN_MIN_DEG)) * DEG;
|
|
106
|
+
const sign = hash01(i, 14) < 0.5 ? -1 : 1;
|
|
107
|
+
this.turnS[i] = height - ROW_Y;
|
|
108
|
+
this.turnDir[i] = { x: sign * Math.sin(angle), y: Math.cos(angle) };
|
|
109
|
+
this.turnRoll[i] = -sign * angle;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
mount(target) {
|
|
113
|
+
if (!target.style.position)
|
|
114
|
+
target.style.position = "relative";
|
|
115
|
+
const smokeMeshes = SMOKE_COLORS.map((color) => quad(1, [color]));
|
|
116
|
+
const fireMeshes = FIRE_COLORS.map((color) => quad(1, [color]));
|
|
117
|
+
const smokeTexture = puffTexture(0.85, 0.5);
|
|
118
|
+
const fireTexture = puffTexture(1, 0.32);
|
|
119
|
+
const backend = async (rendererOptions) => {
|
|
120
|
+
const renderer = this.backend === "webgpu"
|
|
121
|
+
? new (await import("../engines/little-3d-engine/renderers/webgpu-textured.js")).WebGPUTexturedRenderer(rendererOptions)
|
|
122
|
+
: this.backend === "webgl"
|
|
123
|
+
? new (await import("../engines/little-3d-engine/renderers/webgl-textured.js")).WebGLTexturedRenderer(rendererOptions)
|
|
124
|
+
: new (await import("../engines/little-3d-engine/renderers/canvas2d-textured.js")).Canvas2DTexturedRenderer(rendererOptions);
|
|
125
|
+
for (const mesh of smokeMeshes)
|
|
126
|
+
renderer.setTexture(mesh, smokeTexture);
|
|
127
|
+
for (const mesh of fireMeshes)
|
|
128
|
+
renderer.setTexture(mesh, fireTexture);
|
|
129
|
+
return renderer;
|
|
130
|
+
};
|
|
131
|
+
const engine = new Little3dEngine({
|
|
132
|
+
backend,
|
|
133
|
+
camera: { position: { x: 0, y: 0, z: CAMERA_Z }, fov: FOV },
|
|
134
|
+
});
|
|
135
|
+
// Untextured pyramids render lit through the inner renderer; the textured
|
|
136
|
+
// smoke and fire quads draw over them as billboards.
|
|
137
|
+
const rocketMesh = pyramid(1, ROCKET_COLORS);
|
|
138
|
+
for (let i = 0; i < ROCKETS; i++)
|
|
139
|
+
this.rockets.push(engine.add(rocketMesh, { scale: 0 }));
|
|
140
|
+
for (let s = 0; s < SMOKE_POOL; s++) {
|
|
141
|
+
const fade = { mode: "one-sided", opacity: 0 };
|
|
142
|
+
this.smokeFades.push(fade);
|
|
143
|
+
this.smoke.push(engine.add(smokeMeshes[s % smokeMeshes.length], { scale: 0, transparency: fade }));
|
|
144
|
+
}
|
|
145
|
+
for (let f = 0; f < FIRE_POOL; f++) {
|
|
146
|
+
const fade = { mode: "one-sided", opacity: 0 };
|
|
147
|
+
this.fireFades.push(fade);
|
|
148
|
+
this.fire.push(engine.add(fireMeshes[f % fireMeshes.length], { scale: 0, transparency: fade }));
|
|
149
|
+
}
|
|
150
|
+
this.engine = engine;
|
|
151
|
+
engine.mount(target).catch((error) => {
|
|
152
|
+
target.textContent = error instanceof Error ? error.message : String(error);
|
|
153
|
+
});
|
|
154
|
+
const measure = () => {
|
|
155
|
+
if (target.clientWidth > 0 && target.clientHeight > 0) {
|
|
156
|
+
this.aspect = target.clientWidth / target.clientHeight;
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
measure();
|
|
160
|
+
this.observer = new ResizeObserver(measure);
|
|
161
|
+
this.observer.observe(target);
|
|
162
|
+
this.label = mountAnimationLabel(target, this.labelContent);
|
|
163
|
+
if (this.fadeLabel)
|
|
164
|
+
this.label.setOpacity(0);
|
|
165
|
+
}
|
|
166
|
+
enter(now) {
|
|
167
|
+
if (this.enterAt === Infinity)
|
|
168
|
+
this.enterAt = now;
|
|
169
|
+
}
|
|
170
|
+
exit(now) {
|
|
171
|
+
if (this.exitAt === Infinity)
|
|
172
|
+
this.exitAt = now;
|
|
173
|
+
}
|
|
174
|
+
isFinished() {
|
|
175
|
+
return this.finished;
|
|
176
|
+
}
|
|
177
|
+
render(now, frame) {
|
|
178
|
+
if (!this.engine || !this.label)
|
|
179
|
+
return;
|
|
180
|
+
for (const handle of this.rockets)
|
|
181
|
+
handle.transform.scale = 0;
|
|
182
|
+
for (const fade of this.smokeFades)
|
|
183
|
+
fade.opacity = 0;
|
|
184
|
+
for (const fade of this.fireFades)
|
|
185
|
+
fade.opacity = 0;
|
|
186
|
+
for (const handle of this.smoke)
|
|
187
|
+
handle.transform.scale = 0;
|
|
188
|
+
for (const handle of this.fire)
|
|
189
|
+
handle.transform.scale = 0;
|
|
190
|
+
if (this.enterAt === Infinity) {
|
|
191
|
+
this.engine.render();
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
const dt = this.lastNow === 0 ? 16 : Math.min(50, now - this.lastNow);
|
|
195
|
+
this.lastNow = now;
|
|
196
|
+
// At 100% the runner calls exit(): hurry any stragglers onto the pad, then
|
|
197
|
+
// launch only once the whole row is grounded, so a hard scrub straight to
|
|
198
|
+
// 100% still plays every rocket's slide-in instead of snapping them in.
|
|
199
|
+
const exiting = this.exitAt !== Infinity;
|
|
200
|
+
this.updateBlends(dt, frame.progress, now, exiting);
|
|
201
|
+
if (exiting && this.launchedAt === Infinity && this.blends.every((blend) => blend >= 1)) {
|
|
202
|
+
this.launchedAt = now;
|
|
203
|
+
}
|
|
204
|
+
const launched = this.launchedAt !== Infinity;
|
|
205
|
+
const halfWidth = HALF_HEIGHT * this.aspect;
|
|
206
|
+
const rowHalf = Math.min(halfWidth * 0.8, 1.18);
|
|
207
|
+
const spawnX = halfWidth + 0.6;
|
|
208
|
+
let smokeCursor = 0;
|
|
209
|
+
let fireCursor = 0;
|
|
210
|
+
for (let i = 0; i < ROCKETS; i++) {
|
|
211
|
+
const homeX = -rowHalf + (2 * rowHalf * i) / (ROCKETS - 1);
|
|
212
|
+
const launchAt = launched ? this.launchedAt + this.stagger[i] : Infinity;
|
|
213
|
+
const la = now - launchAt;
|
|
214
|
+
if (launched && la >= 0) {
|
|
215
|
+
this.renderAscent(i, homeX, la, halfWidth);
|
|
216
|
+
fireCursor = this.emitFire(i, homeX, la, fireCursor);
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
// Grounded: slide in and idle.
|
|
220
|
+
const blend = this.blends[i];
|
|
221
|
+
if (blend <= 0)
|
|
222
|
+
continue;
|
|
223
|
+
const transform = this.rockets[i].transform;
|
|
224
|
+
transform.position.x = spawnX + (homeX - spawnX) * easeOutBack(blend);
|
|
225
|
+
transform.position.y = ROW_Y;
|
|
226
|
+
transform.position.z = 0;
|
|
227
|
+
transform.rotation.x = 0;
|
|
228
|
+
transform.rotation.y = 0;
|
|
229
|
+
transform.rotation.z = 0;
|
|
230
|
+
transform.scale = SIZE * smoothstep(0, 0.6, blend);
|
|
231
|
+
if (this.groundedAt[i] !== Infinity) {
|
|
232
|
+
smokeCursor = this.emitSmoke(i, homeX, now, launchAt, smokeCursor);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
this.label.setText(frame.indeterminate
|
|
236
|
+
? (typeof this.labelContent === "string" ? this.labelContent : "")
|
|
237
|
+
: `${Math.round(frame.progress * 100)}%`);
|
|
238
|
+
if (this.fadeLabel) {
|
|
239
|
+
this.label.setOpacity(animationLabelOpacity(now, this.enterAt, SLIDE_MS, this.launchedAt, LAUNCH_SPREAD_MS));
|
|
240
|
+
}
|
|
241
|
+
if (launched && now >= this.launchedAt + LAUNCH_SPREAD_MS + FINISH_PAD_MS) {
|
|
242
|
+
this.finished = true;
|
|
243
|
+
}
|
|
244
|
+
this.engine.render();
|
|
245
|
+
}
|
|
246
|
+
destroy() {
|
|
247
|
+
this.observer?.disconnect();
|
|
248
|
+
this.observer = undefined;
|
|
249
|
+
this.label?.container.remove();
|
|
250
|
+
this.label = undefined;
|
|
251
|
+
this.engine?.destroy();
|
|
252
|
+
this.engine = undefined;
|
|
253
|
+
this.rockets.length = 0;
|
|
254
|
+
this.smoke.length = 0;
|
|
255
|
+
this.fire.length = 0;
|
|
256
|
+
this.smokeFades.length = 0;
|
|
257
|
+
this.fireFades.length = 0;
|
|
258
|
+
}
|
|
259
|
+
updateBlends(dt, progress, now, exiting) {
|
|
260
|
+
// One rocket per 5%; round so the twentieth lands ~97.5% and is grounded
|
|
261
|
+
// before the 100% blast-off, never colliding with it.
|
|
262
|
+
const want = exiting ? ROCKETS : Math.min(ROCKETS, Math.round(progress * ROCKETS));
|
|
263
|
+
const rate = (dt / SLIDE_MS) * (exiting ? EXIT_HURRY : 1);
|
|
264
|
+
for (let i = 0; i < ROCKETS; i++) {
|
|
265
|
+
const target = i < want ? 1 : 0;
|
|
266
|
+
const blend = this.blends[i];
|
|
267
|
+
if (target > blend && (i === 0 || this.blends[i - 1] >= SLIDE_GATE)) {
|
|
268
|
+
this.blends[i] = Math.min(1, blend + rate);
|
|
269
|
+
}
|
|
270
|
+
else if (target < blend && (i === ROCKETS - 1 || this.blends[i + 1] <= 1 - SLIDE_GATE)) {
|
|
271
|
+
this.blends[i] = Math.max(0, blend - rate);
|
|
272
|
+
}
|
|
273
|
+
if (this.blends[i] >= 1) {
|
|
274
|
+
if (this.groundedAt[i] === Infinity)
|
|
275
|
+
this.groundedAt[i] = now;
|
|
276
|
+
}
|
|
277
|
+
else {
|
|
278
|
+
this.groundedAt[i] = Infinity;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
/** Along-track distance climbed `la` ms after this rocket's own blast-off. */
|
|
283
|
+
ascentDistance(la) {
|
|
284
|
+
const seconds = la / 1000;
|
|
285
|
+
return 0.5 * ASCENT_G * seconds * seconds;
|
|
286
|
+
}
|
|
287
|
+
/** Rocket center, nose direction, and roll `la` ms into its climb. */
|
|
288
|
+
ascentPose(i, homeX, la) {
|
|
289
|
+
const s = this.ascentDistance(la);
|
|
290
|
+
const turnS = this.turnS[i];
|
|
291
|
+
if (s <= turnS) {
|
|
292
|
+
return { pos: { x: homeX, y: ROW_Y + s }, dir: { x: 0, y: 1 }, roll: 0 };
|
|
293
|
+
}
|
|
294
|
+
const post = s - turnS;
|
|
295
|
+
const dir = this.turnDir[i];
|
|
296
|
+
return {
|
|
297
|
+
pos: { x: homeX + dir.x * post, y: ROW_Y + turnS + dir.y * post },
|
|
298
|
+
dir,
|
|
299
|
+
roll: this.turnRoll[i],
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
renderAscent(i, homeX, la, halfWidth) {
|
|
303
|
+
const { pos, roll } = this.ascentPose(i, homeX, la);
|
|
304
|
+
if (pos.y > HALF_HEIGHT + 0.4 || Math.abs(pos.x) > halfWidth + 0.4)
|
|
305
|
+
return; // gone
|
|
306
|
+
const transform = this.rockets[i].transform;
|
|
307
|
+
transform.position.x = pos.x;
|
|
308
|
+
transform.position.y = pos.y;
|
|
309
|
+
transform.position.z = 0;
|
|
310
|
+
transform.rotation.x = 0;
|
|
311
|
+
transform.rotation.y = 0;
|
|
312
|
+
transform.rotation.z = roll;
|
|
313
|
+
transform.scale = SIZE;
|
|
314
|
+
}
|
|
315
|
+
emitFire(i, homeX, la, cursor) {
|
|
316
|
+
const gap = FIRE_GAP_MS;
|
|
317
|
+
const last = Math.min(Math.floor(la / gap), Math.floor(FIRE_ON_MS / gap));
|
|
318
|
+
const first = Math.max(0, Math.ceil((la - FIRE_LIFE_MS) / gap));
|
|
319
|
+
for (let n = first; n <= last; n++) {
|
|
320
|
+
if (cursor >= this.fire.length)
|
|
321
|
+
return cursor;
|
|
322
|
+
const emitLa = n * gap;
|
|
323
|
+
const age = la - emitLa;
|
|
324
|
+
if (age < 0 || age >= FIRE_LIFE_MS)
|
|
325
|
+
continue;
|
|
326
|
+
const life = age / FIRE_LIFE_MS;
|
|
327
|
+
const seconds = age / 1000;
|
|
328
|
+
const pose = this.ascentPose(i, homeX, emitLa);
|
|
329
|
+
const back = { x: -pose.dir.x, y: -pose.dir.y };
|
|
330
|
+
const perp = { x: -pose.dir.y, y: pose.dir.x };
|
|
331
|
+
const lat = (hash01(i * 97 + n, 1) - 0.5) * FIRE_SPREAD;
|
|
332
|
+
const baseX = pose.pos.x + back.x * SIZE * 0.5;
|
|
333
|
+
const baseY = pose.pos.y + back.y * SIZE * 0.5;
|
|
334
|
+
const transform = this.fire[cursor].transform;
|
|
335
|
+
transform.position.x = baseX + back.x * FIRE_TRAIL * seconds + perp.x * lat;
|
|
336
|
+
transform.position.y = baseY + back.y * FIRE_TRAIL * seconds + perp.y * lat - 0.12 * seconds * seconds;
|
|
337
|
+
transform.position.z = PARTICLE_Z;
|
|
338
|
+
transform.rotation.z = hash01(i * 97 + n, 2) * Math.PI * 2;
|
|
339
|
+
transform.scale = FIRE_SIZE * (0.7 + 0.5 * hash01(i * 97 + n, 3)) * (1 - 0.55 * life);
|
|
340
|
+
this.fireFades[cursor].opacity =
|
|
341
|
+
FIRE_PEAK * smoothstep(0, 0.15, life) * (1 - smoothstep(0.55, 1, life));
|
|
342
|
+
cursor++;
|
|
343
|
+
}
|
|
344
|
+
return cursor;
|
|
345
|
+
}
|
|
346
|
+
emitSmoke(i, homeX, now, launchAt, cursor) {
|
|
347
|
+
const start = this.groundedAt[i];
|
|
348
|
+
const tr = now - start;
|
|
349
|
+
const gap = SMOKE_GAP_MS;
|
|
350
|
+
// Stop spawning once this rocket is about to launch; live wisps still drift off.
|
|
351
|
+
const emitUntil = Number.isFinite(launchAt) ? launchAt - start : tr;
|
|
352
|
+
const last = Math.min(Math.floor(tr / gap), Math.floor(emitUntil / gap));
|
|
353
|
+
const first = Math.max(0, Math.ceil((tr - SMOKE_LIFE_MS) / gap));
|
|
354
|
+
const baseY = ROW_Y - SIZE * 0.4;
|
|
355
|
+
for (let n = first; n <= last; n++) {
|
|
356
|
+
if (cursor >= this.smoke.length)
|
|
357
|
+
return cursor;
|
|
358
|
+
const age = tr - n * gap;
|
|
359
|
+
if (age < 0 || age >= SMOKE_LIFE_MS)
|
|
360
|
+
continue;
|
|
361
|
+
const life = age / SMOKE_LIFE_MS;
|
|
362
|
+
const drift = (hash01(i * 131 + n, 1) - 0.5) * 0.14;
|
|
363
|
+
const transform = this.smoke[cursor].transform;
|
|
364
|
+
transform.position.x = homeX + drift * life;
|
|
365
|
+
transform.position.y = baseY + SMOKE_RISE * life;
|
|
366
|
+
transform.position.z = PARTICLE_Z;
|
|
367
|
+
transform.rotation.z = hash01(i * 131 + n, 2) * Math.PI * 2;
|
|
368
|
+
transform.scale = SMOKE_SIZE * (0.5 + 0.8 * life) * (0.7 + 0.6 * hash01(i * 131 + n, 3));
|
|
369
|
+
this.smokeFades[cursor].opacity =
|
|
370
|
+
SMOKE_PEAK * smoothstep(0, 0.25, life) * (1 - smoothstep(0.5, 1, life));
|
|
371
|
+
cursor++;
|
|
372
|
+
}
|
|
373
|
+
return cursor;
|
|
374
|
+
}
|
|
375
|
+
}
|