Release v0.0.2 #1

Merged
zhanxi merged 5 commits from wang-yuanyou-fluid-portfolio into master 2026-06-11 08:44:37 +00:00
4 changed files with 115 additions and 158 deletions
Showing only changes of commit 92cedd235b - Show all commits
+109 -135
View File
@@ -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);
};
}
+1 -18
View File
@@ -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,
},
];
+2 -2
View File
@@ -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();
+3 -3
View File
@@ -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;