diff --git a/src/choreography.js b/src/choreography.js index d562888..964cf6f 100644 --- a/src/choreography.js +++ b/src/choreography.js @@ -14,7 +14,7 @@ export function initSmoothScroll() { return lenis; } -/* 打字机:scrub 时间轴上的离散字符 reveal */ +/* 打字机:时间轴上的离散字符 reveal(时间驱动,进入视口后完整播放) */ function addTypewriter(tl, textEl, caretEl, fullText, start, duration) { const proxy = { p: 0 }; tl.to( @@ -33,6 +33,9 @@ function addTypewriter(tl, textEl, caretEl, fullText, start, duration) { ); } +/* 根据文案长度推导打字时长,保持各轮节奏差异 */ +const typingDuration = (text) => Math.min(1.5, 0.34 + text.length * 0.07); + /* 开场(时间驱动,非滚动)*/ export function playBoot(mind) { const boot = document.querySelector("#r-boot"); @@ -66,13 +69,42 @@ export function playBoot(mind) { return tl; } -/* 轮次编排:短轮 pin + scrub;长轮(flow)流式 reveal */ +/* 单轮问答时间轴:提问打字 → 思考 → 回答爆发生成(一次性播放) */ +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 inner = section.querySelector(".round-inner"); const qText = section.querySelector(".q-text"); const qCaret = section.querySelector(".q-caret"); const thinking = section.querySelector(".thinking"); @@ -86,82 +118,46 @@ export function initChoreography(mind, setActive = () => {}) { return; } - const { qEnd, thinkEnd, staggerEach } = round; - const burstEnd = 0.8; + const tl = buildRoundTimeline({ round, qText, qCaret, thinking, answer, items, mind }); - const tl = gsap.timeline({ - defaults: { ease: "none" }, - scrollTrigger: { - trigger: section, - start: "top top", - end: round.pinLength, - scrub: 0.65, - pin: true, - anticipatePin: 1, - onToggle(self) { - if (self.isActive) setActive(round.id); - }, - onEnter: () => { - mind.setSide(round.side); - mind.setPalette(round.palette); - }, - onEnterBack: () => { - mind.setSide(round.side); - mind.setPalette(round.palette); - }, - onLeave: () => mind.setState("idle"), - onLeaveBack: () => mind.setState("idle"), - onUpdate(self) { - const p = self.progress; - if (p < qEnd) mind.setState("idle"); - else if (p < thinkEnd) mind.setState("thinking"); - else if (p < burstEnd) mind.setState("answering"); - else mind.setState("idle"); - }, + 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"), }); - - // 1. 提问打字 - addTypewriter(tl, qText, qCaret, round.question, 0, qEnd); - // 2. 思考 shimmer(刻意停顿 = 非线性蓄力) - tl.to(thinking, { opacity: 1, duration: 0.03 }, qEnd) - .to(thinking, { opacity: 0, duration: 0.04 }, thinkEnd - 0.04); - // 3. 爆发生成:不等间隔 stagger 模拟 token 到达 - tl.to(answer, { opacity: 1, duration: 0.02 }, thinkEnd); - tl.to( - items, - { - y: 0, - autoAlpha: 1, - duration: 0.1, - ease: "expo.out", - stagger: { each: staggerEach, from: "start" }, - }, - thinkEnd + 0.02 - ); - // 4. 余韵:内容微视差上浮 - tl.to(inner, { y: -26, duration: 0.2, ease: "power1.in" }, burstEnd); - // 时间轴总长归一 - tl.to({}, { duration: 0.001 }, 1); }); } -/* 长内容轮:提问/思考用入场 scrub,内容块各自进入视口时「生成」 */ +/* 长内容轮:提问/思考时间驱动一次播放,内容块各自进入视口时「生成」 */ function buildFlowRound({ round, section, qText, qCaret, thinking, answer, items, mind, setActive }) { gsap.set(answer, { opacity: 1 }); - const qTl = gsap.timeline({ - defaults: { ease: "none" }, - scrollTrigger: { - trigger: section, - start: "top 78%", - end: "top 18%", - scrub: 0.5, - }, + 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(), }); - addTypewriter(qTl, qText, qCaret, round.question, 0, 0.55); - qTl.to(thinking, { opacity: 1, duration: 0.08 }, 0.6) - .to(thinking, { opacity: 0, duration: 0.1 }, 0.9); items.forEach((item, i) => { gsap.to(item, { @@ -220,41 +216,25 @@ export function initRail(lenis) { return { jump, setActive }; } -/* 滚动守卫:避免停在两轮之间只剩背景的中间态 */ -export function initRoundAutoFocus(lenis) { - const targets = rounds - .filter((round) => round.question && !round.flow) - .map((round) => ({ round, section: document.getElementById(`r-${round.id}`) })) - .filter((target) => target.section); - - if (!targets.length) return () => {}; +/* 模块吸附:滚动停止后,若停在两轮之间的中间态,自动滚动到目标轮顶部, + 再由 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; - let currentTarget = ""; const readY = () => (typeof lenis?.scroll === "number" ? lenis.scroll : window.scrollY); - const pinDistance = (round) => { - const match = String(round.pinLength || "").match(/^\+=([\d.]+)%$/); - const ratio = match ? Number(match[1]) / 100 : 1.6; - return ratio * (window.innerHeight || document.documentElement.clientHeight); - }; - - const typingOffset = (round) => { - const endProgress = Math.min(round.qEnd + 0.025, round.thinkEnd - 0.03); - return Math.max(112, Math.round(pinDistance(round) * endProgress)); - }; - - const focusRound = ({ round, section }, sectionStartY = null) => { - const now = performance.now(); - lockUntil = now + 1150; - currentTarget = section.id; - const baseY = sectionStartY ?? section.getBoundingClientRect().top + readY(); - const targetY = baseY + typingOffset(round); - + const snapTo = (section) => { + lockUntil = performance.now() + 700; + const targetY = section.getBoundingClientRect().top + readY(); if (lenis) { lenis.scrollTo(targetY, { - duration: 0.85, + duration: 0.55, easing: (t) => 1 - Math.pow(1 - t, 3), }); } else { @@ -262,52 +242,46 @@ export function initRoundAutoFocus(lenis) { } }; - const canFocus = ({ section }) => { - const now = performance.now(); - if (now < lockUntil) return false; - const rect = section.getBoundingClientRect(); - if (section.id === currentTarget && rect.top > -12 && rect.top < 12) return false; - return rect.top > 24; - }; - - const findHalfEnteredRound = () => { + const settle = () => { + if (performance.now() < lockUntil) return; const vh = window.innerHeight || document.documentElement.clientHeight; - const minTop = Math.max(36, vh * 0.08); - const maxTop = vh * 0.62; - return targets.find((target) => { - const { section } = target; - const rect = section.getBoundingClientRect(); - if (!canFocus(target)) return false; - return rect.top > minTop && rect.top < maxTop && rect.bottom > vh * 0.55; - }); + 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 checkCurrent = () => { - const target = findHalfEnteredRound(); - if (target) focusRound(target); + 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); }; - const triggers = targets.map((section) => - ScrollTrigger.create({ - trigger: section.section, - start: "top 62%", - end: "top 8%", - onEnter: (self) => { - if (canFocus(section)) { - const vh = window.innerHeight || document.documentElement.clientHeight; - focusRound(section, self.start + vh * 0.62); - } - }, - }), - ); - - window.addEventListener("resize", checkCurrent); - window.setTimeout(checkCurrent, 250); + if (lenis) lenis.on("scroll", onScroll); + window.addEventListener("scroll", onScroll, { passive: true }); + window.addEventListener("resize", onScroll); return () => { - triggers.forEach((trigger) => trigger.kill()); - window.removeEventListener("resize", checkCurrent); + window.clearTimeout(timer); + window.removeEventListener("scroll", onScroll); + window.removeEventListener("resize", onScroll); }; } diff --git a/src/dialogue.js b/src/dialogue.js index f2f6b72..2c4d4d0 100644 --- a/src/dialogue.js +++ b/src/dialogue.js @@ -22,9 +22,6 @@ export const rounds = [ question: "先介绍一下你自己?", side: -1, palette: ["#2dd4bf", "#a78bfa"], - pinLength: "+=160%", - qEnd: 0.14, - thinkEnd: 0.3, staggerEach: 0.055, }, { @@ -34,9 +31,6 @@ export const rounds = [ question: "最近一年具体在做什么?", side: 1, palette: ["#22d3ee", "#c084fc"], - pinLength: "+=200%", - qEnd: 0.12, - thinkEnd: 0.34, staggerEach: 0.085, }, { @@ -46,9 +40,7 @@ export const rounds = [ question: "有实际项目证明吗?", side: -1, palette: ["#38bdf8", "#a78bfa"], - flow: true, // 内容超过一屏:不 pin,逐块流式生成 - qEnd: 0.07, - thinkEnd: 0.16, + flow: true, // 内容超过一屏:逐块流式生成 staggerEach: 0.045, }, { @@ -58,9 +50,6 @@ export const rounds = [ question: "技能栈展开讲讲?", side: 1, palette: ["#67e8f9", "#8b5cf6"], - pinLength: "+=240%", - qEnd: 0.1, - thinkEnd: 0.26, staggerEach: 0.02, }, { @@ -70,9 +59,6 @@ export const rounds = [ question: "之前的团队经历?", side: -1, palette: ["#5eead4", "#a78bfa"], - pinLength: "+=220%", - qEnd: 0.13, - thinkEnd: 0.32, staggerEach: 0.1, }, { @@ -82,9 +68,6 @@ export const rounds = [ question: "怎么联系你?", side: 0, palette: ["#22d3ee", "#f0abfc"], - pinLength: "+=150%", - qEnd: 0.16, - thinkEnd: 0.34, staggerEach: 0.09, }, ]; diff --git a/src/main.js b/src/main.js index cde284c..861214a 100644 --- a/src/main.js +++ b/src/main.js @@ -5,7 +5,7 @@ import { applyReducedMotion, initChoreography, initRail, - initRoundAutoFocus, + initRoundSnap, initSmoothScroll, playBoot, } from "./choreography"; @@ -29,7 +29,7 @@ renderRail(document.getElementById("session-rail"), rail.jump); if (!reduced) { initChoreography(mind, rail.setActive); - initRoundAutoFocus(lenis); + initRoundSnap(lenis); playBoot(mind); } else { applyReducedMotion(); diff --git a/src/styles.css b/src/styles.css index 752c6fb..77cdd58 100644 --- a/src/styles.css +++ b/src/styles.css @@ -193,7 +193,7 @@ main { min-height: 100vh; display: flex; align-items: center; - padding: 96px 7vw; + padding: 72px 7vw; } .round-inner { @@ -210,7 +210,7 @@ main { display: inline-flex; align-items: baseline; margin-left: auto; - margin-bottom: 34px; + margin-bottom: 24px; padding: 12px 20px; border: 1px solid rgba(34, 211, 238, 0.45); border-radius: 18px 18px 4px 18px; @@ -246,7 +246,7 @@ main { display: flex; align-items: center; gap: 10px; - margin-bottom: 30px; + margin-bottom: 22px; font-family: var(--mono); font-size: 12px; letter-spacing: 0.2em;