302 lines
9.5 KiB
JavaScript
302 lines
9.5 KiB
JavaScript
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"));
|
||
}
|