3d-spinner 0.9.1 → 0.9.3
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 +96 -11
- 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/grid-assembly.d.ts +67 -0
- package/dist/animations/grid-assembly.js +268 -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/cjs/animations/charged-orb.cjs +1334 -0
- package/dist/cjs/animations/grid-assembly.cjs +1498 -0
- package/dist/cjs/animations/object-motion.cjs +1449 -0
- package/dist/cjs/animations/particles.cjs +1940 -0
- package/dist/cjs/animations/spin.cjs +1300 -0
- package/dist/cjs/composite-animation.cjs +58 -0
- package/dist/cjs/engines/little-3d-engine/little-3d-engine.cjs +1433 -0
- package/dist/cjs/engines/little-3d-engine/loaders/obj.cjs +87 -0
- 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/engines/little-tween-engine/little-tween-engine.cjs +279 -0
- package/dist/cjs/index.cjs +120 -0
- package/dist/cjs/motion/motion.cjs +140 -0
- package/dist/cjs/motion/transitions.cjs +80 -0
- package/dist/cjs/prefabs/prefabs.cjs +3450 -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 +2 -0
- package/dist/engines/little-3d-engine/textures/dynamic/star.js +16 -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 +9 -0
- package/dist/prefabs/ghost-train.js +44 -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 +8 -0
- package/dist/prefabs/rocket-launch.js +59 -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 +4212 -0
- package/dist/umd/spinner.global.min.js +113 -0
- package/package.json +67 -11
- /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,268 @@
|
|
|
1
|
+
import { animationLabelOpacity, mountAnimationLabel, } from "../animation-label.js";
|
|
2
|
+
import { Little3dEngine, cube, tetrahedron, octahedron, pyramid, icosphere, } from "../engines/little-3d-engine/little-3d-engine.js";
|
|
3
|
+
import { easeInCubic, easeInOutCubic, easeOutCubic, } from "../engines/little-tween-engine/core/tweens.js";
|
|
4
|
+
const GRID = 5;
|
|
5
|
+
const COUNT = GRID * GRID;
|
|
6
|
+
const CAMERA_Z = 4;
|
|
7
|
+
const FOV = (55 * Math.PI) / 180;
|
|
8
|
+
const TWO_PI = Math.PI * 2;
|
|
9
|
+
const INTRO_MS = 900;
|
|
10
|
+
const INTRO_STAGGER_MS = 60;
|
|
11
|
+
const GATE_DOCK = 0.35;
|
|
12
|
+
const GATE_UNDOCK = 0.65;
|
|
13
|
+
const EXIT_HURRY = 2.5;
|
|
14
|
+
const SPIN_EVERY_MS = 2000;
|
|
15
|
+
const SPIN_MS = 380;
|
|
16
|
+
const SPIN_STAGGER_MS = 80;
|
|
17
|
+
const HOLD_MS = 1000;
|
|
18
|
+
const COLLAPSE_MS = 700;
|
|
19
|
+
const POP_MS = 170;
|
|
20
|
+
const LABEL_FADE_MS = 600;
|
|
21
|
+
const DEFAULT_MESHES = [
|
|
22
|
+
() => cube(1, ["#60a5fa", "#3b82f6", "#2563eb", "#38bdf8", "#0ea5e9", "#1d4ed8"]),
|
|
23
|
+
() => tetrahedron(1, ["#f472b6", "#ec4899", "#db2777", "#f9a8d4"]),
|
|
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
|
+
];
|
|
28
|
+
function clamp01(value) {
|
|
29
|
+
return Math.max(0, Math.min(1, value));
|
|
30
|
+
}
|
|
31
|
+
function smooth01(value) {
|
|
32
|
+
const x = clamp01(value);
|
|
33
|
+
return x * x * (3 - 2 * x);
|
|
34
|
+
}
|
|
35
|
+
function resolveMesh(mesh) {
|
|
36
|
+
return typeof mesh === "function" ? mesh() : mesh;
|
|
37
|
+
}
|
|
38
|
+
// Shortest signed representation of an angle, so easing a tumble back to zero
|
|
39
|
+
// never unwinds through full revolutions.
|
|
40
|
+
function wrapAngle(angle) {
|
|
41
|
+
const wrapped = angle - TWO_PI * Math.floor(angle / TWO_PI);
|
|
42
|
+
return wrapped > Math.PI ? wrapped - TWO_PI : wrapped;
|
|
43
|
+
}
|
|
44
|
+
function hash01(index, salt) {
|
|
45
|
+
let h = (Math.imul(index + 1, 0x9e3779b9) ^ Math.imul(salt + 1, 0x85ebca6b)) >>> 0;
|
|
46
|
+
h = Math.imul(h ^ (h >>> 16), 0x45d9f3b);
|
|
47
|
+
h ^= h >>> 16;
|
|
48
|
+
return (h >>> 0) / 4294967296;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* A progress story in three acts: 25 shapes fly in and circle just inside the
|
|
52
|
+
* view edge; as progress climbs they leave the orbit one by one and dock into
|
|
53
|
+
* a 5x5 grid at the center (docked shapes idle with a staggered spin every two
|
|
54
|
+
* seconds); at completion the finished grid holds for a second, then every
|
|
55
|
+
* shape dives into the center while shrinking away and vanishes with a small
|
|
56
|
+
* pop. Docking is driven by time-based blends toward per-shape targets, so a
|
|
57
|
+
* progress jump in either direction stays smooth.
|
|
58
|
+
*/
|
|
59
|
+
export class GridAssemblyAnimation {
|
|
60
|
+
constructor(options = {}) {
|
|
61
|
+
this.handles = [];
|
|
62
|
+
this.blends = new Array(COUNT).fill(0);
|
|
63
|
+
this.dockedAt = new Array(COUNT).fill(Infinity);
|
|
64
|
+
this.tumbleX = [];
|
|
65
|
+
this.tumbleY = [];
|
|
66
|
+
this.fades = [];
|
|
67
|
+
this.slots = [];
|
|
68
|
+
this.aspect = 16 / 9;
|
|
69
|
+
this.enterAt = Infinity;
|
|
70
|
+
this.exitAt = Infinity;
|
|
71
|
+
this.allDockedAt = Infinity;
|
|
72
|
+
this.collapseAt = Infinity;
|
|
73
|
+
this.lastNow = 0;
|
|
74
|
+
this.popFading = false;
|
|
75
|
+
this.finished = false;
|
|
76
|
+
const sources = options.meshes && options.meshes.length > 0 ? options.meshes : DEFAULT_MESHES;
|
|
77
|
+
this.meshes = sources.map(resolveMesh);
|
|
78
|
+
this.size = options.size ?? 0.34;
|
|
79
|
+
this.orbitPeriodMs = options.orbitPeriodMs ?? 9000;
|
|
80
|
+
this.dockMs = options.dockMs ?? 800;
|
|
81
|
+
this.backend = options.backend;
|
|
82
|
+
this.labelContent = options.label;
|
|
83
|
+
this.fadeLabel = options.fadeLabel ?? true;
|
|
84
|
+
const spacing = this.size + (options.gap ?? 0.12);
|
|
85
|
+
for (let i = 0; i < COUNT; i++) {
|
|
86
|
+
const row = Math.floor(i / GRID);
|
|
87
|
+
const col = i % GRID;
|
|
88
|
+
this.slots.push({ x: (col - 2) * spacing, y: (2 - row) * spacing, z: 0 });
|
|
89
|
+
this.tumbleX.push(TWO_PI * hash01(i, 2) - Math.PI);
|
|
90
|
+
this.tumbleY.push(TWO_PI * hash01(i, 4) - Math.PI);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
mount(target) {
|
|
94
|
+
if (!target.style.position)
|
|
95
|
+
target.style.position = "relative";
|
|
96
|
+
const engine = new Little3dEngine({
|
|
97
|
+
backend: this.backend,
|
|
98
|
+
camera: { position: { x: 0, y: 0, z: CAMERA_Z }, fov: FOV },
|
|
99
|
+
});
|
|
100
|
+
for (let i = 0; i < COUNT; i++) {
|
|
101
|
+
this.fades.push({ mode: "one-sided", opacity: 1 });
|
|
102
|
+
this.handles.push(engine.add(this.meshes[i % this.meshes.length], { scale: 0 }));
|
|
103
|
+
}
|
|
104
|
+
this.engine = engine;
|
|
105
|
+
engine.mount(target).catch((error) => {
|
|
106
|
+
target.textContent = error instanceof Error ? error.message : String(error);
|
|
107
|
+
});
|
|
108
|
+
const measure = () => {
|
|
109
|
+
if (target.clientWidth > 0 && target.clientHeight > 0) {
|
|
110
|
+
this.aspect = target.clientWidth / target.clientHeight;
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
measure();
|
|
114
|
+
this.observer = new ResizeObserver(measure);
|
|
115
|
+
this.observer.observe(target);
|
|
116
|
+
this.label = mountAnimationLabel(target, this.labelContent);
|
|
117
|
+
if (this.fadeLabel)
|
|
118
|
+
this.label.setOpacity(0);
|
|
119
|
+
}
|
|
120
|
+
enter(now) {
|
|
121
|
+
if (this.enterAt === Infinity)
|
|
122
|
+
this.enterAt = now;
|
|
123
|
+
}
|
|
124
|
+
exit(now) {
|
|
125
|
+
if (this.exitAt === Infinity)
|
|
126
|
+
this.exitAt = now;
|
|
127
|
+
}
|
|
128
|
+
isFinished() {
|
|
129
|
+
return this.finished;
|
|
130
|
+
}
|
|
131
|
+
render(now, frame) {
|
|
132
|
+
if (!this.engine || !this.label)
|
|
133
|
+
return;
|
|
134
|
+
if (this.enterAt === Infinity) {
|
|
135
|
+
for (const handle of this.handles)
|
|
136
|
+
handle.transform.scale = 0;
|
|
137
|
+
this.engine.render();
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const dt = this.lastNow === 0 ? 16 : Math.min(50, now - this.lastNow);
|
|
141
|
+
this.lastNow = now;
|
|
142
|
+
this.updateBlends(dt, frame.progress, now);
|
|
143
|
+
if (this.exitAt !== Infinity && this.allDockedAt !== Infinity && this.collapseAt === Infinity) {
|
|
144
|
+
this.collapseAt = Math.max(this.exitAt, this.allDockedAt) + HOLD_MS;
|
|
145
|
+
}
|
|
146
|
+
if (now >= this.collapseAt)
|
|
147
|
+
this.renderCollapse(now);
|
|
148
|
+
else
|
|
149
|
+
this.renderStory(now, dt);
|
|
150
|
+
this.label.setText(frame.indeterminate
|
|
151
|
+
? (typeof this.labelContent === "string" ? this.labelContent : "")
|
|
152
|
+
: `${Math.round(frame.progress * 100)}%`);
|
|
153
|
+
if (this.fadeLabel) {
|
|
154
|
+
this.label.setOpacity(animationLabelOpacity(now, this.enterAt, LABEL_FADE_MS, this.collapseAt, COLLAPSE_MS));
|
|
155
|
+
}
|
|
156
|
+
if (this.collapseAt !== Infinity && now >= this.collapseAt + COLLAPSE_MS + POP_MS) {
|
|
157
|
+
this.finished = true;
|
|
158
|
+
}
|
|
159
|
+
this.engine.render();
|
|
160
|
+
}
|
|
161
|
+
destroy() {
|
|
162
|
+
this.observer?.disconnect();
|
|
163
|
+
this.observer = undefined;
|
|
164
|
+
this.label?.container.remove();
|
|
165
|
+
this.label = undefined;
|
|
166
|
+
this.engine?.destroy();
|
|
167
|
+
this.engine = undefined;
|
|
168
|
+
this.handles.length = 0;
|
|
169
|
+
this.fades.length = 0;
|
|
170
|
+
}
|
|
171
|
+
updateBlends(dt, progress, now) {
|
|
172
|
+
const exiting = this.exitAt !== Infinity;
|
|
173
|
+
const want = exiting ? COUNT : Math.min(COUNT, Math.floor(progress * COUNT + 1e-9));
|
|
174
|
+
const rate = (dt / this.dockMs) * (exiting ? EXIT_HURRY : 1);
|
|
175
|
+
for (let i = 0; i < COUNT; i++) {
|
|
176
|
+
const target = i < want ? 1 : 0;
|
|
177
|
+
const blend = this.blends[i];
|
|
178
|
+
if (target > blend && (i === 0 || this.blends[i - 1] >= GATE_DOCK)) {
|
|
179
|
+
this.blends[i] = Math.min(1, blend + rate);
|
|
180
|
+
}
|
|
181
|
+
else if (target < blend && (i === COUNT - 1 || this.blends[i + 1] <= GATE_UNDOCK)) {
|
|
182
|
+
this.blends[i] = Math.max(0, blend - rate);
|
|
183
|
+
}
|
|
184
|
+
if (this.blends[i] >= 1) {
|
|
185
|
+
if (this.dockedAt[i] === Infinity)
|
|
186
|
+
this.dockedAt[i] = now;
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
this.dockedAt[i] = Infinity;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
const allDocked = want === COUNT && this.blends.every((blend) => blend >= 1);
|
|
193
|
+
if (allDocked) {
|
|
194
|
+
if (this.allDockedAt === Infinity)
|
|
195
|
+
this.allDockedAt = now;
|
|
196
|
+
}
|
|
197
|
+
else if (!exiting) {
|
|
198
|
+
this.allDockedAt = Infinity;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
renderStory(now, dt) {
|
|
202
|
+
const t = now - this.enterAt;
|
|
203
|
+
const halfHeight = Math.tan(FOV / 2) * CAMERA_Z;
|
|
204
|
+
const halfWidth = halfHeight * this.aspect;
|
|
205
|
+
const radius = Math.max(0.6, Math.min(halfWidth, halfHeight) - this.size * 0.55);
|
|
206
|
+
const spawnFactor = (Math.max(halfWidth, halfHeight) * 1.25 + this.size * 2) / radius;
|
|
207
|
+
for (let i = 0; i < COUNT; i++) {
|
|
208
|
+
const transform = this.handles[i].transform;
|
|
209
|
+
const eased = smooth01(this.blends[i]);
|
|
210
|
+
const introT = clamp01((t - i * INTRO_STAGGER_MS) / INTRO_MS);
|
|
211
|
+
const reach = 1 + (spawnFactor - 1) * (1 - easeOutCubic(introT));
|
|
212
|
+
const angle = Math.PI / 2 - (i / COUNT) * TWO_PI - (t / this.orbitPeriodMs) * TWO_PI;
|
|
213
|
+
const orbitX = Math.cos(angle) * radius * reach;
|
|
214
|
+
const orbitY = Math.sin(angle) * radius * reach;
|
|
215
|
+
const slot = this.slots[i];
|
|
216
|
+
transform.position.x = orbitX + (slot.x - orbitX) * eased;
|
|
217
|
+
transform.position.y = orbitY + (slot.y - orbitY) * eased;
|
|
218
|
+
transform.position.z = 0;
|
|
219
|
+
transform.scale = this.size;
|
|
220
|
+
// The tumble angle only advances while a shape is fully free, so easing it to
|
|
221
|
+
// zero during docking never crosses the +-pi seam and cannot visibly snap.
|
|
222
|
+
if (this.blends[i] === 0) {
|
|
223
|
+
this.tumbleX[i] = wrapAngle(this.tumbleX[i] + (0.0004 + 0.0008 * hash01(i, 1)) * dt);
|
|
224
|
+
this.tumbleY[i] = wrapAngle(this.tumbleY[i] + (0.0006 + 0.001 * hash01(i, 3)) * dt);
|
|
225
|
+
}
|
|
226
|
+
transform.rotation.z = 0;
|
|
227
|
+
if (this.blends[i] >= 1) {
|
|
228
|
+
const phase = (t + i * SPIN_STAGGER_MS) % SPIN_EVERY_MS;
|
|
229
|
+
const spinning = phase < SPIN_MS && now - phase >= this.dockedAt[i];
|
|
230
|
+
transform.rotation.x = 0;
|
|
231
|
+
transform.rotation.y = spinning ? TWO_PI * easeInOutCubic(phase / SPIN_MS) : 0;
|
|
232
|
+
}
|
|
233
|
+
else {
|
|
234
|
+
const free = 1 - eased;
|
|
235
|
+
transform.rotation.x = this.tumbleX[i] * free;
|
|
236
|
+
transform.rotation.y = this.tumbleY[i] * free;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
renderCollapse(now) {
|
|
241
|
+
if (!this.captured) {
|
|
242
|
+
this.captured = this.handles.map((handle) => ({ ...handle.transform.position }));
|
|
243
|
+
}
|
|
244
|
+
const u = clamp01((now - this.collapseAt) / COLLAPSE_MS);
|
|
245
|
+
const pull = easeInCubic(u);
|
|
246
|
+
for (let i = 0; i < COUNT; i++) {
|
|
247
|
+
const transform = this.handles[i].transform;
|
|
248
|
+
const from = this.captured[i];
|
|
249
|
+
transform.position.x = from.x * (1 - pull);
|
|
250
|
+
transform.position.y = from.y * (1 - pull);
|
|
251
|
+
transform.position.z = from.z * (1 - pull);
|
|
252
|
+
transform.scale = this.size * (1 - 0.99 * pull);
|
|
253
|
+
}
|
|
254
|
+
if (u >= 1) {
|
|
255
|
+
if (!this.popFading) {
|
|
256
|
+
this.popFading = true;
|
|
257
|
+
for (let i = 0; i < COUNT; i++)
|
|
258
|
+
this.handles[i].transparency = this.fades[i];
|
|
259
|
+
}
|
|
260
|
+
const v = clamp01((now - this.collapseAt - COLLAPSE_MS) / POP_MS);
|
|
261
|
+
for (let i = 0; i < COUNT; i++) {
|
|
262
|
+
this.fades[i].opacity = 1 - v;
|
|
263
|
+
this.handles[i].transform.scale =
|
|
264
|
+
v >= 1 ? 0 : this.size * 0.01 * (1 + 1.6 * Math.sin(Math.PI * v));
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AnimationFrame, SpinnerAnimation } from "../animation.js";
|
|
1
|
+
import type { AnimationFrame, AnimationLabel, SpinnerAnimation } from "../animation.js";
|
|
2
2
|
import { type Backend, type Mesh, type Transparency } from "../engines/little-3d-engine/little-3d-engine.js";
|
|
3
3
|
import type { MotionController } from "../motion/controller.js";
|
|
4
4
|
import { type ObjectMotionTransitionConfig } from "../motion/transitions.js";
|
|
@@ -27,7 +27,7 @@ export interface ObjectMotionOptions {
|
|
|
27
27
|
mesh: Mesh | (() => Mesh);
|
|
28
28
|
/** How the object moves: a circle, square, figure-8, wander, or any custom controller. */
|
|
29
29
|
motion: MotionController;
|
|
30
|
-
/** Face color applied to every triangle.
|
|
30
|
+
/** Face color applied to every triangle. Omit to retain the mesh's face colors. */
|
|
31
31
|
color?: string;
|
|
32
32
|
/** Rendering backend. Default `"canvas2d"`. */
|
|
33
33
|
backend?: Backend;
|
|
@@ -39,14 +39,16 @@ export interface ObjectMotionOptions {
|
|
|
39
39
|
facing?: Facing;
|
|
40
40
|
/** Additional local-space rotation on top of path orientation. */
|
|
41
41
|
rotation?: ObjectMotionRotation;
|
|
42
|
-
/** Intro transition. Defaults to `
|
|
42
|
+
/** Intro transition. Defaults to `enterFromObjectDirection()` (fly in along the path). */
|
|
43
43
|
intro?: ObjectMotionTransitionConfig;
|
|
44
|
-
/** Outro transition. Defaults to `
|
|
44
|
+
/** Outro transition. Defaults to `leaveInObjectDirection()` (fly out along the path). */
|
|
45
45
|
outro?: ObjectMotionTransitionConfig;
|
|
46
46
|
/** Trailing copies that chase the lead in single file. Omit for a single object. */
|
|
47
47
|
tail?: ObjectMotionTail;
|
|
48
48
|
/** Overlay label shown in indeterminate mode (no value to show). Hidden if omitted. */
|
|
49
|
-
label?:
|
|
49
|
+
label?: AnimationLabel;
|
|
50
|
+
/** Fade the label with the intro and outro transitions. Default `true`. */
|
|
51
|
+
fadeLabel?: boolean;
|
|
50
52
|
}
|
|
51
53
|
/** Centers a mesh at the origin and uniformly scales it to fit within `targetSize`. */
|
|
52
54
|
export declare function centerAndScaleMesh(mesh: Mesh, targetSize: number): Mesh;
|
|
@@ -54,7 +56,7 @@ export declare function centerAndScaleMesh(mesh: Mesh, targetSize: number): Mesh
|
|
|
54
56
|
* An object that moves along a {@link MotionController}'s path (a circle, a
|
|
55
57
|
* square, a figure-8, a smooth wander, or any custom controller) with its nose
|
|
56
58
|
* following the path tangent. The runner triggers the lifecycle: {@link enter}
|
|
57
|
-
*
|
|
59
|
+
* flies it in along the path, {@link exit} flies it out. Any mesh works (OBJ imports or engine
|
|
58
60
|
* primitives); `facing` corrects a model that points the wrong way, and
|
|
59
61
|
* `rotation` adds local spin or tilt on top of path following.
|
|
60
62
|
*/
|
|
@@ -68,7 +70,8 @@ export declare class ObjectMotionAnimation implements SpinnerAnimation {
|
|
|
68
70
|
private readonly motion;
|
|
69
71
|
private readonly backend?;
|
|
70
72
|
private readonly transparency?;
|
|
71
|
-
private readonly
|
|
73
|
+
private readonly labelContent?;
|
|
74
|
+
private readonly fadeLabel;
|
|
72
75
|
private readonly tailCount;
|
|
73
76
|
private readonly tailGap;
|
|
74
77
|
private readonly intro;
|
|
@@ -88,6 +91,15 @@ export declare class ObjectMotionAnimation implements SpinnerAnimation {
|
|
|
88
91
|
enter(now: number): void;
|
|
89
92
|
exit(now: number): void;
|
|
90
93
|
isFinished(): boolean;
|
|
94
|
+
/** Milliseconds the fly-out takes; used to align a following particle trail's outro. */
|
|
95
|
+
get outroDurationMs(): number;
|
|
96
|
+
/**
|
|
97
|
+
* A {@link MotionController} that follows the object's *actual* position, including
|
|
98
|
+
* the intro fly-in and outro fly-out (it falls back to the raw motion path before
|
|
99
|
+
* {@link enter} and once idle). Feed it to a particle layer's `emitter` so the
|
|
100
|
+
* particles trail the object through its transitions instead of the bare path.
|
|
101
|
+
*/
|
|
102
|
+
trailEmitter(): MotionController;
|
|
91
103
|
render(now: number, frame: AnimationFrame): void;
|
|
92
104
|
destroy(): void;
|
|
93
105
|
private aheadAt;
|
|
@@ -1,19 +1,7 @@
|
|
|
1
|
+
import { animationLabelOpacity, mountAnimationLabel, } from "../animation-label.js";
|
|
1
2
|
import { Little3dEngine, cross, normalize, scale, subtract, } from "../engines/little-3d-engine/little-3d-engine.js";
|
|
2
3
|
import { multiply, rotationX, rotationY, rotationZ, } from "../engines/little-3d-engine/core/math.js";
|
|
3
|
-
import {
|
|
4
|
-
const LABEL_STYLE = [
|
|
5
|
-
"position:absolute",
|
|
6
|
-
"inset:0",
|
|
7
|
-
"display:flex",
|
|
8
|
-
"align-items:center",
|
|
9
|
-
"justify-content:center",
|
|
10
|
-
"pointer-events:none",
|
|
11
|
-
"font:700 1.6rem/1 system-ui,sans-serif",
|
|
12
|
-
"letter-spacing:0.02em",
|
|
13
|
-
"color:rgba(255,255,255,0.9)",
|
|
14
|
-
"text-shadow:0 1px 10px rgba(0,0,0,0.6)",
|
|
15
|
-
"z-index:1",
|
|
16
|
-
].join(";");
|
|
4
|
+
import { enterFromObjectDirection, leaveInObjectDirection, } from "../motion/transitions.js";
|
|
17
5
|
const WORLD_UP = { x: 0, y: 1, z: 0 };
|
|
18
6
|
const DEFAULT_INTRO_MS = 2100;
|
|
19
7
|
const DEFAULT_OUTRO_MS = 2100;
|
|
@@ -37,6 +25,8 @@ function resolveMesh(mesh) {
|
|
|
37
25
|
return typeof mesh === "function" ? mesh() : mesh;
|
|
38
26
|
}
|
|
39
27
|
function applyColor(mesh, color) {
|
|
28
|
+
if (color === undefined)
|
|
29
|
+
return mesh;
|
|
40
30
|
return { vertices: mesh.vertices, faces: mesh.faces.map((face) => ({ ...face, color })) };
|
|
41
31
|
}
|
|
42
32
|
function faceForward(mesh, facing) {
|
|
@@ -138,7 +128,7 @@ function resolveTransition(config, fallback, durationMs) {
|
|
|
138
128
|
* An object that moves along a {@link MotionController}'s path (a circle, a
|
|
139
129
|
* square, a figure-8, a smooth wander, or any custom controller) with its nose
|
|
140
130
|
* following the path tangent. The runner triggers the lifecycle: {@link enter}
|
|
141
|
-
*
|
|
131
|
+
* flies it in along the path, {@link exit} flies it out. Any mesh works (OBJ imports or engine
|
|
142
132
|
* primitives); `facing` corrects a model that points the wrong way, and
|
|
143
133
|
* `rotation` adds local spin or tilt on top of path following.
|
|
144
134
|
*/
|
|
@@ -156,15 +146,16 @@ export class ObjectMotionAnimation {
|
|
|
156
146
|
this.outroDirection = { x: 1, y: 0, z: 0 };
|
|
157
147
|
const centered = centerAndScaleMesh(resolveMesh(options.mesh), options.size ?? 1);
|
|
158
148
|
const facing = faceForward(centered, options.facing ?? "+x");
|
|
159
|
-
this.mesh = applyColor(facing, options.color
|
|
149
|
+
this.mesh = applyColor(facing, options.color);
|
|
160
150
|
this.motion = options.motion;
|
|
161
151
|
this.backend = options.backend;
|
|
162
152
|
this.transparency = options.transparency;
|
|
163
|
-
this.
|
|
153
|
+
this.labelContent = options.label;
|
|
154
|
+
this.fadeLabel = options.fadeLabel ?? true;
|
|
164
155
|
this.tailCount = Math.max(0, Math.floor(options.tail?.count ?? 0));
|
|
165
156
|
this.tailGap = Math.max(0, options.tail?.gapMs ?? 0);
|
|
166
|
-
this.intro = resolveTransition(options.intro,
|
|
167
|
-
this.outro = resolveTransition(options.outro,
|
|
157
|
+
this.intro = resolveTransition(options.intro, enterFromObjectDirection(), DEFAULT_INTRO_MS);
|
|
158
|
+
this.outro = resolveTransition(options.outro, leaveInObjectDirection(), DEFAULT_OUTRO_MS);
|
|
168
159
|
const rotation = options.rotation;
|
|
169
160
|
this.rotationOffset = { x: rotation?.x ?? 0, y: rotation?.y ?? 0, z: rotation?.z ?? 0 };
|
|
170
161
|
this.rotationSpin = {
|
|
@@ -181,7 +172,8 @@ export class ObjectMotionAnimation {
|
|
|
181
172
|
this.rotationSpin.z !== 0;
|
|
182
173
|
}
|
|
183
174
|
mount(target) {
|
|
184
|
-
target.style.position
|
|
175
|
+
if (!target.style.position)
|
|
176
|
+
target.style.position = "relative";
|
|
185
177
|
const engine = new Little3dEngine({
|
|
186
178
|
backend: this.backend,
|
|
187
179
|
camera: { position: { x: 0, y: 0, z: 3 } },
|
|
@@ -195,11 +187,9 @@ export class ObjectMotionAnimation {
|
|
|
195
187
|
engine.mount(target).catch((error) => {
|
|
196
188
|
target.textContent = error instanceof Error ? error.message : String(error);
|
|
197
189
|
});
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
target.appendChild(label);
|
|
202
|
-
this.label = label;
|
|
190
|
+
this.label = mountAnimationLabel(target, this.labelContent);
|
|
191
|
+
if (this.fadeLabel)
|
|
192
|
+
this.label.setOpacity(0);
|
|
203
193
|
}
|
|
204
194
|
enter(now) {
|
|
205
195
|
if (this.started)
|
|
@@ -218,6 +208,19 @@ export class ObjectMotionAnimation {
|
|
|
218
208
|
isFinished() {
|
|
219
209
|
return this.finished;
|
|
220
210
|
}
|
|
211
|
+
/** Milliseconds the fly-out takes; used to align a following particle trail's outro. */
|
|
212
|
+
get outroDurationMs() {
|
|
213
|
+
return this.outro.durationMs;
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* A {@link MotionController} that follows the object's *actual* position, including
|
|
217
|
+
* the intro fly-in and outro fly-out (it falls back to the raw motion path before
|
|
218
|
+
* {@link enter} and once idle). Feed it to a particle layer's `emitter` so the
|
|
219
|
+
* particles trail the object through its transitions instead of the bare path.
|
|
220
|
+
*/
|
|
221
|
+
trailEmitter() {
|
|
222
|
+
return { positionAt: (t) => this.sampleAt(t)?.position ?? this.motion.positionAt(t) };
|
|
223
|
+
}
|
|
221
224
|
render(now, frame) {
|
|
222
225
|
if (!this.engine || !this.label)
|
|
223
226
|
return;
|
|
@@ -258,13 +261,16 @@ export class ObjectMotionAnimation {
|
|
|
258
261
|
transform.rotation.y = euler.y;
|
|
259
262
|
transform.rotation.z = euler.z;
|
|
260
263
|
}
|
|
261
|
-
this.label.
|
|
262
|
-
? (this.
|
|
263
|
-
: `${Math.round(frame.progress * 100)}
|
|
264
|
+
this.label.setText(frame.indeterminate
|
|
265
|
+
? (typeof this.labelContent === "string" ? this.labelContent : "")
|
|
266
|
+
: `${Math.round(frame.progress * 100)}%`);
|
|
267
|
+
if (this.fadeLabel) {
|
|
268
|
+
this.label.setOpacity(animationLabelOpacity(now, this.started ? this.introStart : Infinity, this.intro.durationMs, this.outroStart, this.outro.durationMs));
|
|
269
|
+
}
|
|
264
270
|
this.engine.render();
|
|
265
271
|
}
|
|
266
272
|
destroy() {
|
|
267
|
-
this.label?.remove();
|
|
273
|
+
this.label?.container.remove();
|
|
268
274
|
this.label = undefined;
|
|
269
275
|
this.engine?.destroy();
|
|
270
276
|
this.engine = undefined;
|
|
@@ -302,7 +308,7 @@ export class ObjectMotionAnimation {
|
|
|
302
308
|
const delta = transition.durationMs === 0 ? 1 : clamp01(elapsedMs / transition.durationMs);
|
|
303
309
|
const input = this.transitionInput(phase, delta, elapsedMs, transition.durationMs, start);
|
|
304
310
|
const output = transition.transition(input);
|
|
305
|
-
return this.applyTransitionOutput(input, output);
|
|
311
|
+
return this.applyTransitionOutput(input, output, phase, t);
|
|
306
312
|
}
|
|
307
313
|
transitionInput(phase, delta, elapsedMs, durationMs, start) {
|
|
308
314
|
if (phase === "intro") {
|
|
@@ -330,9 +336,9 @@ export class ObjectMotionAnimation {
|
|
|
330
336
|
phase,
|
|
331
337
|
};
|
|
332
338
|
}
|
|
333
|
-
applyTransitionOutput(input, output) {
|
|
339
|
+
applyTransitionOutput(input, output, phase, t) {
|
|
334
340
|
return {
|
|
335
|
-
position: output.position ?? input.position,
|
|
341
|
+
position: output.position ?? (phase === "intro" ? this.motion.positionAt(t) : input.position),
|
|
336
342
|
size: output.size ?? input.size ?? 1,
|
|
337
343
|
orientation: output.orientation,
|
|
338
344
|
};
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type { AnimationFrame, AnimationLabel, SpinnerAnimation } from "../animation.js";
|
|
2
|
+
import type { MotionController } from "../motion/controller.js";
|
|
3
|
+
import { type Backend, type Vec3 } from "../engines/little-3d-engine/little-3d-engine.js";
|
|
4
|
+
export interface ParticlesOptions {
|
|
5
|
+
/** Particles emitted per second. Default `20`. */
|
|
6
|
+
rate?: number;
|
|
7
|
+
/** Lifetime of one particle in milliseconds. Default `1800`. */
|
|
8
|
+
lifeMs?: number;
|
|
9
|
+
/** Particle colors, cycled across particles. Defaults to a built-in palette. */
|
|
10
|
+
colors?: string[];
|
|
11
|
+
/** Base particle size in world units, varied per particle. Default `0.16`. */
|
|
12
|
+
size?: number;
|
|
13
|
+
/** Base emission speed in world units per second, varied per particle. Default `0.6`. */
|
|
14
|
+
speed?: number;
|
|
15
|
+
/** Constant acceleration in world units per second squared. Default none. */
|
|
16
|
+
gravity?: Vec3;
|
|
17
|
+
/** Mean emission direction. Omit to emit uniformly in all directions. */
|
|
18
|
+
direction?: Vec3;
|
|
19
|
+
/** Cone half-angle around `direction` in radians. Default `0.5`. */
|
|
20
|
+
spread?: number;
|
|
21
|
+
/** Peak particle opacity `0..1`. Default `0.9`. */
|
|
22
|
+
opacity?: number;
|
|
23
|
+
/** Maximum spin around the view axis in radians per millisecond. Default `0.002`. */
|
|
24
|
+
spin?: number;
|
|
25
|
+
/** Rotate each billboard around the view axis to follow its current velocity. Default `false`. */
|
|
26
|
+
alignToMotion?: boolean;
|
|
27
|
+
/** Seed for the deterministic particle stream. Default `1`. */
|
|
28
|
+
seed?: number;
|
|
29
|
+
/** Rendering backend. Default `"canvas2d"`. */
|
|
30
|
+
backend?: Backend;
|
|
31
|
+
/** Optional moving emission origin. Each particle keeps the origin where it was emitted. */
|
|
32
|
+
emitter?: MotionController;
|
|
33
|
+
/**
|
|
34
|
+
* Milliseconds to keep emitting after {@link ParticlesAnimation.exit}. Default `0`
|
|
35
|
+
* (emission stops at exit). Give it a moving `emitter`'s outro duration so fresh
|
|
36
|
+
* particles keep trailing the emitter as it flies out, instead of freezing where
|
|
37
|
+
* the loop left off.
|
|
38
|
+
*/
|
|
39
|
+
outroMs?: number;
|
|
40
|
+
/**
|
|
41
|
+
* Image applied to every particle (a URL or a drawable element), tinted by
|
|
42
|
+
* the particle color; the image's alpha shapes the particle. Renders
|
|
43
|
+
* through a textured renderer, fetched on demand: the WebGPU one when
|
|
44
|
+
* `backend` is `"webgpu"`, otherwise the WebGL one.
|
|
45
|
+
*/
|
|
46
|
+
texture?: string | TexImageSource;
|
|
47
|
+
/** Overlay label shown in indeterminate mode (no value to show). Hidden if omitted. */
|
|
48
|
+
label?: AnimationLabel;
|
|
49
|
+
/** Fade the label as particles appear and drain away. Default `true`. */
|
|
50
|
+
fadeLabel?: boolean;
|
|
51
|
+
}
|
|
52
|
+
/** State of one live particle: where it is and how it looks. */
|
|
53
|
+
export interface ParticleSample {
|
|
54
|
+
position: Vec3;
|
|
55
|
+
/** Rotation around the view axis, radians. */
|
|
56
|
+
roll: number;
|
|
57
|
+
size: number;
|
|
58
|
+
opacity: number;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* A deterministic particle stream. Particle `index` is emitted at
|
|
62
|
+
* `index * spawnGapMs`; its whole life is a pure function of time, so the
|
|
63
|
+
* field holds no per-frame state and the same seed replays the same stream.
|
|
64
|
+
*/
|
|
65
|
+
export interface ParticleField {
|
|
66
|
+
/** Upper bound on simultaneously live particles; slots recycle beyond it. */
|
|
67
|
+
maxLive: number;
|
|
68
|
+
/** Milliseconds between consecutive emissions. */
|
|
69
|
+
spawnGapMs: number;
|
|
70
|
+
/** Lifetime of one particle in milliseconds. */
|
|
71
|
+
lifeMs: number;
|
|
72
|
+
/**
|
|
73
|
+
* State of particle `index` at `t` milliseconds after emission started, or
|
|
74
|
+
* `undefined` while unborn or after death.
|
|
75
|
+
*/
|
|
76
|
+
sample(index: number, t: number): ParticleSample | undefined;
|
|
77
|
+
}
|
|
78
|
+
/** Create a {@link ParticleField} from the emission options. */
|
|
79
|
+
export declare function particleField(options?: ParticlesOptions): ParticleField;
|
|
80
|
+
/**
|
|
81
|
+
* A stream of camera-facing billboard particles: a burst, a fountain, drifting
|
|
82
|
+
* embers - shaped by the emission options. Particles fade in, drift under
|
|
83
|
+
* `gravity`, and fade out; the runner triggers the lifecycle: {@link enter}
|
|
84
|
+
* starts emission, {@link exit} stops it and lets the live particles die out
|
|
85
|
+
* as the outro.
|
|
86
|
+
*/
|
|
87
|
+
export declare class ParticlesAnimation implements SpinnerAnimation {
|
|
88
|
+
private engine?;
|
|
89
|
+
private label?;
|
|
90
|
+
private readonly handles;
|
|
91
|
+
private readonly fades;
|
|
92
|
+
private readonly field;
|
|
93
|
+
private readonly colors;
|
|
94
|
+
private readonly backend?;
|
|
95
|
+
private readonly texture?;
|
|
96
|
+
private readonly labelContent?;
|
|
97
|
+
private readonly fadeLabel;
|
|
98
|
+
private readonly emitter?;
|
|
99
|
+
private readonly outroMs;
|
|
100
|
+
private enterAt;
|
|
101
|
+
private exitAt;
|
|
102
|
+
private finished;
|
|
103
|
+
constructor(options?: ParticlesOptions);
|
|
104
|
+
mount(target: HTMLElement): void;
|
|
105
|
+
enter(now: number): void;
|
|
106
|
+
exit(now: number): void;
|
|
107
|
+
isFinished(): boolean;
|
|
108
|
+
render(now: number, frame: AnimationFrame): void;
|
|
109
|
+
destroy(): void;
|
|
110
|
+
}
|