Files
my-resume/src/choreography.js
T
2026-06-11 16:09:55 +08:00

302 lines
9.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import Lenis from "lenis";
import { rounds } from "./dialogue";
gsap.registerPlugin(ScrollTrigger);
/* Lenis 平滑滚动 + ScrollTrigger 集成 */
export function initSmoothScroll() {
const lenis = new Lenis({ lerp: 0.1 });
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => lenis.raf(time * 1000));
gsap.ticker.lagSmoothing(0);
return lenis;
}
/* 打字机:时间轴上的离散字符 reveal(时间驱动,进入视口后完整播放) */
function addTypewriter(tl, textEl, caretEl, fullText, start, duration) {
const proxy = { p: 0 };
tl.to(
proxy,
{
p: 1,
duration,
ease: `steps(${fullText.length})`,
onUpdate() {
const n = Math.round(proxy.p * fullText.length);
textEl.textContent = fullText.slice(0, n);
if (caretEl) caretEl.style.opacity = proxy.p >= 1 ? "0" : "1";
},
},
start
);
}
/* 根据文案长度推导打字时长,保持各轮节奏差异 */
const typingDuration = (text) => Math.min(1.5, 0.34 + text.length * 0.07);
/* 开场(时间驱动,非滚动)*/
export function playBoot(mind) {
const boot = document.querySelector("#r-boot");
const typedLine = boot.querySelector(".typed-line");
const items = boot.querySelectorAll(".gen");
gsap.set(boot.querySelector(".answer"), { opacity: 1 });
gsap.set(items, { y: 26, autoAlpha: 0 });
const text = "这是我的数字思维体,向它提问即可。";
const proxy = { p: 0 };
const tl = gsap.timeline({ delay: 0.3 });
tl.call(() => mind.setState("thinking"))
.to(proxy, {
p: 1,
duration: 1.4,
ease: `steps(${text.length})`,
onUpdate: () => {
typedLine.textContent = text.slice(0, Math.round(proxy.p * text.length));
},
}, 0.5)
.call(() => mind.setState("answering"), null, 1.9)
.to(items, {
y: 0,
autoAlpha: 1,
duration: 0.7,
ease: "expo.out",
stagger: { each: 0.09, from: "start" },
}, 2.0)
.call(() => mind.setState("idle"), null, 3.4);
return tl;
}
/* 单轮问答时间轴:提问打字 → 思考 → 回答爆发生成(一次性播放) */
function buildRoundTimeline({ round, qText, qCaret, thinking, answer, items, mind }) {
const typeDur = typingDuration(round.question);
const tl = gsap.timeline({ paused: true });
// 1. 提问打字
addTypewriter(tl, qText, qCaret, round.question, 0, typeDur);
// 2. 思考 shimmer(刻意停顿 = 非线性蓄力)
tl.call(() => mind.setState("thinking"), null, typeDur)
.to(thinking, { opacity: 1, duration: 0.22 }, typeDur)
.to({}, { duration: 0.5 })
.to(thinking, { opacity: 0, duration: 0.18 })
// 3. 爆发生成:不等间隔 stagger 模拟 token 到达
.call(() => mind.setState("answering"))
.to(answer, { opacity: 1, duration: 0.2 }, "<")
.to(
items,
{
y: 0,
autoAlpha: 1,
duration: 0.7,
ease: "expo.out",
stagger: { each: Math.max(0.05, round.staggerEach), from: "start" },
},
"<0.05"
)
.call(() => mind.setState("idle"), null, "+=0.4");
return tl;
}
/* 轮次编排:进入视口即播放(不再依赖滚动 scrub,修复"不滚动则不加载" */
export function initChoreography(mind, setActive = () => {}) {
rounds
.filter((r) => r.question)
.forEach((round) => {
const section = document.getElementById(`r-${round.id}`);
const qText = section.querySelector(".q-text");
const qCaret = section.querySelector(".q-caret");
const thinking = section.querySelector(".thinking");
const answer = section.querySelector(".answer");
const items = section.querySelectorAll(".gen");
gsap.set(items, { y: 34, autoAlpha: 0 });
if (round.flow) {
buildFlowRound({ round, section, qText, qCaret, thinking, answer, items, mind, setActive });
return;
}
const tl = buildRoundTimeline({ round, qText, qCaret, thinking, answer, items, mind });
ScrollTrigger.create({
trigger: section,
start: "top 60%",
end: "bottom 40%",
onToggle(self) {
if (self.isActive) setActive(round.id);
},
onEnter: () => {
mind.setSide(round.side);
mind.setPalette(round.palette);
tl.play();
},
onEnterBack: () => {
mind.setSide(round.side);
mind.setPalette(round.palette);
},
onLeave: () => mind.setState("idle"),
onLeaveBack: () => mind.setState("idle"),
});
});
}
/* 长内容轮:提问/思考时间驱动一次播放,内容块各自进入视口时「生成」 */
function buildFlowRound({ round, section, qText, qCaret, thinking, answer, items, mind, setActive }) {
gsap.set(answer, { opacity: 1 });
const typeDur = typingDuration(round.question);
const qTl = gsap.timeline({ paused: true });
addTypewriter(qTl, qText, qCaret, round.question, 0, typeDur);
qTl.to(thinking, { opacity: 1, duration: 0.2 }, typeDur)
.to(thinking, { opacity: 0, duration: 0.2 }, "+=0.5");
ScrollTrigger.create({
trigger: section,
start: "top 70%",
once: true,
onEnter: () => qTl.play(),
});
items.forEach((item, i) => {
gsap.to(item, {
y: 0,
autoAlpha: 1,
duration: 0.8,
delay: (i % 3) * 0.09, // 不等间隔,模拟 token 到达
ease: "expo.out",
scrollTrigger: { trigger: item, start: "top 88%" },
});
});
ScrollTrigger.create({
trigger: section,
start: "top 60%",
end: "bottom 55%",
onToggle(self) {
if (self.isActive) {
setActive(round.id);
mind.setSide(round.side);
mind.setPalette(round.palette);
} else {
mind.setState("idle");
}
},
onUpdate(self) {
mind.setState(self.progress < 0.1 ? "thinking" : "answering");
},
});
}
/* 进度轨:高亮由编排回调驱动,这里只负责节点与跳转 */
export function initRail(lenis) {
const setActive = (id) => {
document.querySelectorAll(".rail-node").forEach((n) => {
n.classList.toggle("active", n.dataset.round === id);
});
};
ScrollTrigger.create({
trigger: "#r-boot",
start: "top top",
end: "bottom 40%",
onToggle(self) {
if (self.isActive) setActive("boot");
},
});
const jump = (id) => {
const target = document.getElementById(`r-${id}`);
if (!target) return;
if (lenis) lenis.scrollTo(target, { offset: 2, duration: 1.2 });
else target.scrollIntoView({ behavior: "smooth" });
};
return { jump, setActive };
}
/* 模块吸附:滚动停止后,若停在两轮之间的中间态,自动滚动到目标轮顶部,
再由 onEnter 正常触发打字机动画(修复"滚不到位则动画错位" */
export function initRoundSnap(lenis) {
const sections = rounds
.map((round) => document.getElementById(`r-${round.id}`))
.filter(Boolean);
if (!sections.length) return () => {};
let timer = 0;
let lockUntil = 0;
const readY = () => (typeof lenis?.scroll === "number" ? lenis.scroll : window.scrollY);
const snapTo = (section) => {
lockUntil = performance.now() + 700;
const targetY = section.getBoundingClientRect().top + readY();
if (lenis) {
lenis.scrollTo(targetY, {
duration: 0.55,
easing: (t) => 1 - Math.pow(1 - t, 3),
});
} else {
window.scrollTo({ top: targetY, behavior: "smooth" });
}
};
const settle = () => {
if (performance.now() < lockUntil) return;
const vh = window.innerHeight || document.documentElement.clientHeight;
for (const section of sections) {
const top = section.getBoundingClientRect().top;
// 已有模块对齐视口顶部 → 状态正常,无需吸附
if (Math.abs(top) < 8) return;
// 模块露出一半(未到打字机区域)→ 吸附进入该模块
const halfEntered = top > vh * 0.12 && top < vh * 0.72;
// 模块占据主屏但未对齐 → 吸附回正
const nearlyAligned = top > -vh * 0.45 && top <= vh * 0.12;
if (halfEntered || nearlyAligned) {
snapTo(section);
return;
}
}
// 长内容轮(flow)内部深处不匹配任何区间 → 自由滚动,不干预
};
const onScroll = () => {
window.clearTimeout(timer);
// lenis 惯性收尾阶段速度趋零:立即判定吸附,不等事件完全静止
const velocity =
lenis && typeof lenis.velocity === "number" ? Math.abs(lenis.velocity) : null;
if (velocity !== null && velocity < 0.3) {
settle();
return;
}
timer = window.setTimeout(settle, 90);
};
if (lenis) lenis.on("scroll", onScroll);
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", onScroll);
return () => {
window.clearTimeout(timer);
window.removeEventListener("scroll", onScroll);
window.removeEventListener("resize", onScroll);
};
}
/* 降级:reduced motion 直接展示全部内容 */
export function applyReducedMotion() {
rounds
.filter((r) => r.question)
.forEach((round) => {
const section = document.getElementById(`r-${round.id}`);
section.querySelector(".q-text").textContent = round.question;
const answer = section.querySelector(".answer");
answer.style.opacity = "1";
});
const boot = document.querySelector("#r-boot");
boot.querySelector(".typed-line").textContent = "这是我的数字思维体。";
document.querySelectorAll(".gen").forEach((n) => (n.style.opacity = "1"));
}