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,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
|
+
}
|
|
@@ -165,31 +165,6 @@ function expandToTriangles(mesh) {
|
|
|
165
165
|
}
|
|
166
166
|
return { positions, normals, colors, count: positions.length / 3 };
|
|
167
167
|
}
|
|
168
|
-
function midpoint(a, b) {
|
|
169
|
-
return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2, z: (a.z + b.z) / 2 };
|
|
170
|
-
}
|
|
171
|
-
function sphereFromTriangles(seedVertices, seedFaces, size, detail, colors) {
|
|
172
|
-
const radius = size / 2;
|
|
173
|
-
let triangles = seedFaces.map((f) => f.map((i) => normalize(seedVertices[i])));
|
|
174
|
-
const levels = Math.max(0, Math.floor(detail) - 1);
|
|
175
|
-
for (let level = 0; level < levels; level++) {
|
|
176
|
-
const next = [];
|
|
177
|
-
for (const [a, b, c] of triangles) {
|
|
178
|
-
const ab = normalize(midpoint(a, b));
|
|
179
|
-
const bc = normalize(midpoint(b, c));
|
|
180
|
-
const ca = normalize(midpoint(c, a));
|
|
181
|
-
next.push([a, ab, ca], [b, bc, ab], [c, ca, bc], [ab, bc, ca]);
|
|
182
|
-
}
|
|
183
|
-
triangles = next;
|
|
184
|
-
}
|
|
185
|
-
const vertices = [];
|
|
186
|
-
const faces = triangles.map((tri, i) => {
|
|
187
|
-
const base = vertices.length;
|
|
188
|
-
vertices.push(scale(tri[0], radius), scale(tri[1], radius), scale(tri[2], radius));
|
|
189
|
-
return { indices: [base, base + 1, base + 2], color: colors[i % colors.length] };
|
|
190
|
-
});
|
|
191
|
-
return { vertices, faces };
|
|
192
|
-
}
|
|
193
168
|
var init_geometry = __esm({
|
|
194
169
|
"src/engines/little-3d-engine/core/geometry.ts"() {
|
|
195
170
|
"use strict";
|
|
@@ -967,83 +942,8 @@ function cube(size = 1, colors = DEFAULT_COLORS) {
|
|
|
967
942
|
return { vertices, faces };
|
|
968
943
|
}
|
|
969
944
|
|
|
970
|
-
// src/engines/little-3d-engine/shapes/primitives/tetrahedron.ts
|
|
971
|
-
var DEFAULT_COLORS2 = ["#3b82f6", "#8b5cf6", "#ec4899", "#f59e0b"];
|
|
972
|
-
function tetrahedron(size = 1, colors = DEFAULT_COLORS2) {
|
|
973
|
-
const s = size / 2;
|
|
974
|
-
const vertices = [
|
|
975
|
-
{ x: s, y: s, z: s },
|
|
976
|
-
{ x: s, y: -s, z: -s },
|
|
977
|
-
{ x: -s, y: s, z: -s },
|
|
978
|
-
{ x: -s, y: -s, z: s }
|
|
979
|
-
];
|
|
980
|
-
const faces = [
|
|
981
|
-
{ indices: [0, 1, 2], color: colors[0 % colors.length] },
|
|
982
|
-
{ indices: [0, 3, 1], color: colors[1 % colors.length] },
|
|
983
|
-
{ indices: [0, 2, 3], color: colors[2 % colors.length] },
|
|
984
|
-
{ indices: [1, 3, 2], color: colors[3 % colors.length] }
|
|
985
|
-
];
|
|
986
|
-
return { vertices, faces };
|
|
987
|
-
}
|
|
988
|
-
|
|
989
|
-
// src/engines/little-3d-engine/shapes/primitives/octahedron.ts
|
|
990
|
-
var DEFAULT_COLORS3 = [
|
|
991
|
-
"#3b82f6",
|
|
992
|
-
"#8b5cf6",
|
|
993
|
-
"#ec4899",
|
|
994
|
-
"#f59e0b",
|
|
995
|
-
"#10b981",
|
|
996
|
-
"#ef4444",
|
|
997
|
-
"#06b6d4",
|
|
998
|
-
"#eab308"
|
|
999
|
-
];
|
|
1000
|
-
function octahedron(size = 1, colors = DEFAULT_COLORS3) {
|
|
1001
|
-
const r = size / 2;
|
|
1002
|
-
const vertices = [
|
|
1003
|
-
{ x: r, y: 0, z: 0 },
|
|
1004
|
-
{ x: -r, y: 0, z: 0 },
|
|
1005
|
-
{ x: 0, y: r, z: 0 },
|
|
1006
|
-
{ x: 0, y: -r, z: 0 },
|
|
1007
|
-
{ x: 0, y: 0, z: r },
|
|
1008
|
-
{ x: 0, y: 0, z: -r }
|
|
1009
|
-
];
|
|
1010
|
-
const faces = [
|
|
1011
|
-
{ indices: [4, 0, 2], color: colors[0 % colors.length] },
|
|
1012
|
-
{ indices: [4, 2, 1], color: colors[1 % colors.length] },
|
|
1013
|
-
{ indices: [4, 1, 3], color: colors[2 % colors.length] },
|
|
1014
|
-
{ indices: [4, 3, 0], color: colors[3 % colors.length] },
|
|
1015
|
-
{ indices: [5, 2, 0], color: colors[4 % colors.length] },
|
|
1016
|
-
{ indices: [5, 1, 2], color: colors[5 % colors.length] },
|
|
1017
|
-
{ indices: [5, 3, 1], color: colors[6 % colors.length] },
|
|
1018
|
-
{ indices: [5, 0, 3], color: colors[7 % colors.length] }
|
|
1019
|
-
];
|
|
1020
|
-
return { vertices, faces };
|
|
1021
|
-
}
|
|
1022
|
-
|
|
1023
|
-
// src/engines/little-3d-engine/shapes/primitives/pyramid.ts
|
|
1024
|
-
var DEFAULT_COLORS4 = ["#3b82f6", "#8b5cf6", "#ec4899", "#f59e0b", "#10b981"];
|
|
1025
|
-
function pyramid(size = 1, colors = DEFAULT_COLORS4) {
|
|
1026
|
-
const h = size / 2;
|
|
1027
|
-
const vertices = [
|
|
1028
|
-
{ x: -h, y: -h, z: h },
|
|
1029
|
-
{ x: h, y: -h, z: h },
|
|
1030
|
-
{ x: h, y: -h, z: -h },
|
|
1031
|
-
{ x: -h, y: -h, z: -h },
|
|
1032
|
-
{ x: 0, y: h, z: 0 }
|
|
1033
|
-
];
|
|
1034
|
-
const faces = [
|
|
1035
|
-
{ indices: [0, 3, 2, 1], color: colors[0 % colors.length] },
|
|
1036
|
-
{ indices: [4, 0, 1], color: colors[1 % colors.length] },
|
|
1037
|
-
{ indices: [4, 1, 2], color: colors[2 % colors.length] },
|
|
1038
|
-
{ indices: [4, 2, 3], color: colors[3 % colors.length] },
|
|
1039
|
-
{ indices: [4, 3, 0], color: colors[4 % colors.length] }
|
|
1040
|
-
];
|
|
1041
|
-
return { vertices, faces };
|
|
1042
|
-
}
|
|
1043
|
-
|
|
1044
945
|
// src/engines/little-3d-engine/shapes/primitives/spheres/icosphere.ts
|
|
1045
946
|
init_geometry();
|
|
1046
|
-
var DEFAULT_COLORS5 = ["#3b82f6", "#8b5cf6", "#ec4899", "#f59e0b", "#10b981", "#ef4444"];
|
|
1047
947
|
var T = (1 + Math.sqrt(5)) / 2;
|
|
1048
948
|
var SEED_VERTICES = [
|
|
1049
949
|
{ x: -1, y: T, z: 0 },
|
|
@@ -1059,31 +959,6 @@ var SEED_VERTICES = [
|
|
|
1059
959
|
{ x: -T, y: 0, z: -1 },
|
|
1060
960
|
{ x: -T, y: 0, z: 1 }
|
|
1061
961
|
];
|
|
1062
|
-
var SEED_FACES = [
|
|
1063
|
-
[0, 11, 5],
|
|
1064
|
-
[0, 5, 1],
|
|
1065
|
-
[0, 1, 7],
|
|
1066
|
-
[0, 7, 10],
|
|
1067
|
-
[0, 10, 11],
|
|
1068
|
-
[1, 5, 9],
|
|
1069
|
-
[5, 11, 4],
|
|
1070
|
-
[11, 10, 2],
|
|
1071
|
-
[10, 7, 6],
|
|
1072
|
-
[7, 1, 8],
|
|
1073
|
-
[3, 9, 4],
|
|
1074
|
-
[3, 4, 2],
|
|
1075
|
-
[3, 2, 6],
|
|
1076
|
-
[3, 6, 8],
|
|
1077
|
-
[3, 8, 9],
|
|
1078
|
-
[4, 9, 5],
|
|
1079
|
-
[2, 4, 11],
|
|
1080
|
-
[6, 2, 10],
|
|
1081
|
-
[8, 6, 7],
|
|
1082
|
-
[9, 8, 1]
|
|
1083
|
-
];
|
|
1084
|
-
function icosphere(size = 1, detail = 1, colors = DEFAULT_COLORS5) {
|
|
1085
|
-
return sphereFromTriangles(SEED_VERTICES, SEED_FACES, size, detail, colors);
|
|
1086
|
-
}
|
|
1087
962
|
|
|
1088
963
|
// src/engines/little-3d-engine/shapes/primitives/spheres/octa-sphere.ts
|
|
1089
964
|
init_geometry();
|
|
@@ -1263,6 +1138,7 @@ var FOV = 55 * Math.PI / 180;
|
|
|
1263
1138
|
var TWO_PI = Math.PI * 2;
|
|
1264
1139
|
var INTRO_MS = 900;
|
|
1265
1140
|
var INTRO_STAGGER_MS = 60;
|
|
1141
|
+
var INTRO_DONE_MS = (COUNT - 1) * INTRO_STAGGER_MS + INTRO_MS;
|
|
1266
1142
|
var GATE_DOCK = 0.35;
|
|
1267
1143
|
var GATE_UNDOCK = 0.65;
|
|
1268
1144
|
var EXIT_HURRY = 2.5;
|
|
@@ -1271,15 +1147,11 @@ var SPIN_MS = 380;
|
|
|
1271
1147
|
var SPIN_STAGGER_MS = 80;
|
|
1272
1148
|
var HOLD_MS = 1e3;
|
|
1273
1149
|
var COLLAPSE_MS = 700;
|
|
1150
|
+
var COLLAPSE_SPREAD_MS = 500;
|
|
1274
1151
|
var POP_MS = 170;
|
|
1275
1152
|
var LABEL_FADE_MS = 600;
|
|
1276
|
-
var
|
|
1277
|
-
|
|
1278
|
-
() => tetrahedron(1, ["#f472b6", "#ec4899", "#db2777", "#f9a8d4"]),
|
|
1279
|
-
() => octahedron(1, ["#34d399", "#10b981", "#059669", "#6ee7b7", "#a7f3d0", "#047857", "#4ade80", "#065f46"]),
|
|
1280
|
-
() => pyramid(1, ["#fbbf24", "#f59e0b", "#d97706", "#fde68a", "#fcd34d"]),
|
|
1281
|
-
() => icosphere(1, 1, ["#a78bfa", "#8b5cf6", "#7c3aed", "#c4b5fd"])
|
|
1282
|
-
];
|
|
1153
|
+
var CUBE_COLORS = ["#8397c6", "#7186b8", "#6176a8", "#93a6cf", "#556a9c", "#7a8fc0"];
|
|
1154
|
+
var DEFAULT_MESHES = [() => cube(1, CUBE_COLORS)];
|
|
1283
1155
|
function clamp012(value) {
|
|
1284
1156
|
return Math.max(0, Math.min(1, value));
|
|
1285
1157
|
}
|
|
@@ -1307,6 +1179,9 @@ var GridAssemblyAnimation = class {
|
|
|
1307
1179
|
this.dockedAt = new Array(COUNT).fill(Infinity);
|
|
1308
1180
|
this.tumbleX = [];
|
|
1309
1181
|
this.tumbleY = [];
|
|
1182
|
+
this.collapseDelay = [];
|
|
1183
|
+
this.popStarted = new Array(COUNT).fill(false);
|
|
1184
|
+
this.maxCollapseDelay = 0;
|
|
1310
1185
|
this.fades = [];
|
|
1311
1186
|
this.slots = [];
|
|
1312
1187
|
this.aspect = 16 / 9;
|
|
@@ -1315,7 +1190,6 @@ var GridAssemblyAnimation = class {
|
|
|
1315
1190
|
this.allDockedAt = Infinity;
|
|
1316
1191
|
this.collapseAt = Infinity;
|
|
1317
1192
|
this.lastNow = 0;
|
|
1318
|
-
this.popFading = false;
|
|
1319
1193
|
this.finished = false;
|
|
1320
1194
|
const sources = options.meshes && options.meshes.length > 0 ? options.meshes : DEFAULT_MESHES;
|
|
1321
1195
|
this.meshes = sources.map(resolveMesh);
|
|
@@ -1332,7 +1206,9 @@ var GridAssemblyAnimation = class {
|
|
|
1332
1206
|
this.slots.push({ x: (col - 2) * spacing, y: (2 - row) * spacing, z: 0 });
|
|
1333
1207
|
this.tumbleX.push(TWO_PI * hash01(i, 2) - Math.PI);
|
|
1334
1208
|
this.tumbleY.push(TWO_PI * hash01(i, 4) - Math.PI);
|
|
1209
|
+
this.collapseDelay.push(hash01(i, 7) * COLLAPSE_SPREAD_MS);
|
|
1335
1210
|
}
|
|
1211
|
+
this.maxCollapseDelay = Math.max(...this.collapseDelay);
|
|
1336
1212
|
}
|
|
1337
1213
|
mount(target) {
|
|
1338
1214
|
if (!target.style.position) target.style.position = "relative";
|
|
@@ -1393,7 +1269,7 @@ var GridAssemblyAnimation = class {
|
|
|
1393
1269
|
COLLAPSE_MS
|
|
1394
1270
|
));
|
|
1395
1271
|
}
|
|
1396
|
-
if (this.collapseAt !== Infinity && now >= this.collapseAt + COLLAPSE_MS + POP_MS) {
|
|
1272
|
+
if (this.collapseAt !== Infinity && now >= this.collapseAt + this.maxCollapseDelay + COLLAPSE_MS + POP_MS) {
|
|
1397
1273
|
this.finished = true;
|
|
1398
1274
|
}
|
|
1399
1275
|
this.engine.render();
|
|
@@ -1410,7 +1286,8 @@ var GridAssemblyAnimation = class {
|
|
|
1410
1286
|
}
|
|
1411
1287
|
updateBlends(dt, progress, now) {
|
|
1412
1288
|
const exiting = this.exitAt !== Infinity;
|
|
1413
|
-
const
|
|
1289
|
+
const ringComplete = now - this.enterAt >= INTRO_DONE_MS;
|
|
1290
|
+
const want = !ringComplete ? 0 : exiting ? COUNT : Math.min(COUNT, Math.floor(progress * COUNT + 1e-9));
|
|
1414
1291
|
const rate = dt / this.dockMs * (exiting ? EXIT_HURRY : 1);
|
|
1415
1292
|
for (let i = 0; i < COUNT; i++) {
|
|
1416
1293
|
const target = i < want ? 1 : 0;
|
|
@@ -1473,25 +1350,30 @@ var GridAssemblyAnimation = class {
|
|
|
1473
1350
|
if (!this.captured) {
|
|
1474
1351
|
this.captured = this.handles.map((handle) => ({ ...handle.transform.position }));
|
|
1475
1352
|
}
|
|
1476
|
-
const u = clamp012((now - this.collapseAt) / COLLAPSE_MS);
|
|
1477
|
-
const pull = easeInCubic(u);
|
|
1478
1353
|
for (let i = 0; i < COUNT; i++) {
|
|
1479
1354
|
const transform2 = this.handles[i].transform;
|
|
1480
1355
|
const from = this.captured[i];
|
|
1356
|
+
const local = now - this.collapseAt - this.collapseDelay[i];
|
|
1357
|
+
if (local <= 0) {
|
|
1358
|
+
transform2.position.x = from.x;
|
|
1359
|
+
transform2.position.y = from.y;
|
|
1360
|
+
transform2.position.z = from.z;
|
|
1361
|
+
transform2.scale = this.size;
|
|
1362
|
+
continue;
|
|
1363
|
+
}
|
|
1364
|
+
const pull = easeInCubic(clamp012(local / COLLAPSE_MS));
|
|
1481
1365
|
transform2.position.x = from.x * (1 - pull);
|
|
1482
1366
|
transform2.position.y = from.y * (1 - pull);
|
|
1483
1367
|
transform2.position.z = from.z * (1 - pull);
|
|
1484
1368
|
transform2.scale = this.size * (1 - 0.99 * pull);
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
const v = clamp012((now - this.collapseAt - COLLAPSE_MS) / POP_MS);
|
|
1492
|
-
for (let i = 0; i < COUNT; i++) {
|
|
1369
|
+
if (local >= COLLAPSE_MS) {
|
|
1370
|
+
if (!this.popStarted[i]) {
|
|
1371
|
+
this.popStarted[i] = true;
|
|
1372
|
+
this.handles[i].transparency = this.fades[i];
|
|
1373
|
+
}
|
|
1374
|
+
const v = clamp012((local - COLLAPSE_MS) / POP_MS);
|
|
1493
1375
|
this.fades[i].opacity = 1 - v;
|
|
1494
|
-
|
|
1376
|
+
transform2.scale = v >= 1 ? 0 : this.size * 0.01 * (1 + 1.6 * Math.sin(Math.PI * v));
|
|
1495
1377
|
}
|
|
1496
1378
|
}
|
|
1497
1379
|
}
|
|
@@ -1238,18 +1238,30 @@ function canvasTexture(draw, size = 96) {
|
|
|
1238
1238
|
}
|
|
1239
1239
|
|
|
1240
1240
|
// src/engines/little-3d-engine/textures/dynamic/star.ts
|
|
1241
|
-
function
|
|
1241
|
+
function drawStar(ctx) {
|
|
1242
|
+
ctx.save();
|
|
1243
|
+
ctx.translate(48, 48);
|
|
1244
|
+
ctx.fillStyle = "#fff";
|
|
1245
|
+
ctx.beginPath();
|
|
1246
|
+
for (let index = 0; index < 10; index++) {
|
|
1247
|
+
const radius = index % 2 === 0 ? 43 : 16;
|
|
1248
|
+
const angle = index * Math.PI / 5 - Math.PI / 2;
|
|
1249
|
+
ctx.lineTo(radius * Math.cos(angle), radius * Math.sin(angle));
|
|
1250
|
+
}
|
|
1251
|
+
ctx.closePath();
|
|
1252
|
+
ctx.fill();
|
|
1253
|
+
ctx.restore();
|
|
1254
|
+
}
|
|
1255
|
+
function starTexture(options = {}) {
|
|
1256
|
+
const glow = Math.max(0, options.glow ?? 0);
|
|
1242
1257
|
return canvasTexture((ctx) => {
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
const angle = index * Math.PI / 5 - Math.PI / 2;
|
|
1249
|
-
ctx.lineTo(radius * Math.cos(angle), radius * Math.sin(angle));
|
|
1258
|
+
if (glow > 0) {
|
|
1259
|
+
ctx.save();
|
|
1260
|
+
ctx.filter = `blur(${glow}px)`;
|
|
1261
|
+
drawStar(ctx);
|
|
1262
|
+
ctx.restore();
|
|
1250
1263
|
}
|
|
1251
|
-
ctx
|
|
1252
|
-
ctx.fill();
|
|
1264
|
+
drawStar(ctx);
|
|
1253
1265
|
});
|
|
1254
1266
|
}
|
|
1255
1267
|
|