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")); }