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