Release v0.0.2 #1
+97
-123
@@ -14,7 +14,7 @@ export function initSmoothScroll() {
|
|||||||
return lenis;
|
return lenis;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 打字机:scrub 时间轴上的离散字符 reveal */
|
/* 打字机:时间轴上的离散字符 reveal(时间驱动,进入视口后完整播放) */
|
||||||
function addTypewriter(tl, textEl, caretEl, fullText, start, duration) {
|
function addTypewriter(tl, textEl, caretEl, fullText, start, duration) {
|
||||||
const proxy = { p: 0 };
|
const proxy = { p: 0 };
|
||||||
tl.to(
|
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) {
|
export function playBoot(mind) {
|
||||||
const boot = document.querySelector("#r-boot");
|
const boot = document.querySelector("#r-boot");
|
||||||
@@ -66,13 +69,42 @@ export function playBoot(mind) {
|
|||||||
return tl;
|
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 = () => {}) {
|
export function initChoreography(mind, setActive = () => {}) {
|
||||||
rounds
|
rounds
|
||||||
.filter((r) => r.question)
|
.filter((r) => r.question)
|
||||||
.forEach((round) => {
|
.forEach((round) => {
|
||||||
const section = document.getElementById(`r-${round.id}`);
|
const section = document.getElementById(`r-${round.id}`);
|
||||||
const inner = section.querySelector(".round-inner");
|
|
||||||
const qText = section.querySelector(".q-text");
|
const qText = section.querySelector(".q-text");
|
||||||
const qCaret = section.querySelector(".q-caret");
|
const qCaret = section.querySelector(".q-caret");
|
||||||
const thinking = section.querySelector(".thinking");
|
const thinking = section.querySelector(".thinking");
|
||||||
@@ -86,24 +118,19 @@ export function initChoreography(mind, setActive = () => {}) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { qEnd, thinkEnd, staggerEach } = round;
|
const tl = buildRoundTimeline({ round, qText, qCaret, thinking, answer, items, mind });
|
||||||
const burstEnd = 0.8;
|
|
||||||
|
|
||||||
const tl = gsap.timeline({
|
ScrollTrigger.create({
|
||||||
defaults: { ease: "none" },
|
|
||||||
scrollTrigger: {
|
|
||||||
trigger: section,
|
trigger: section,
|
||||||
start: "top top",
|
start: "top 60%",
|
||||||
end: round.pinLength,
|
end: "bottom 40%",
|
||||||
scrub: 0.65,
|
|
||||||
pin: true,
|
|
||||||
anticipatePin: 1,
|
|
||||||
onToggle(self) {
|
onToggle(self) {
|
||||||
if (self.isActive) setActive(round.id);
|
if (self.isActive) setActive(round.id);
|
||||||
},
|
},
|
||||||
onEnter: () => {
|
onEnter: () => {
|
||||||
mind.setSide(round.side);
|
mind.setSide(round.side);
|
||||||
mind.setPalette(round.palette);
|
mind.setPalette(round.palette);
|
||||||
|
tl.play();
|
||||||
},
|
},
|
||||||
onEnterBack: () => {
|
onEnterBack: () => {
|
||||||
mind.setSide(round.side);
|
mind.setSide(round.side);
|
||||||
@@ -111,57 +138,26 @@ export function initChoreography(mind, setActive = () => {}) {
|
|||||||
},
|
},
|
||||||
onLeave: () => mind.setState("idle"),
|
onLeave: () => mind.setState("idle"),
|
||||||
onLeaveBack: () => 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");
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 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 }) {
|
function buildFlowRound({ round, section, qText, qCaret, thinking, answer, items, mind, setActive }) {
|
||||||
gsap.set(answer, { opacity: 1 });
|
gsap.set(answer, { opacity: 1 });
|
||||||
|
|
||||||
const qTl = gsap.timeline({
|
const typeDur = typingDuration(round.question);
|
||||||
defaults: { ease: "none" },
|
const qTl = gsap.timeline({ paused: true });
|
||||||
scrollTrigger: {
|
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,
|
trigger: section,
|
||||||
start: "top 78%",
|
start: "top 70%",
|
||||||
end: "top 18%",
|
once: true,
|
||||||
scrub: 0.5,
|
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) => {
|
items.forEach((item, i) => {
|
||||||
gsap.to(item, {
|
gsap.to(item, {
|
||||||
@@ -220,41 +216,25 @@ export function initRail(lenis) {
|
|||||||
return { jump, setActive };
|
return { jump, setActive };
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 滚动守卫:避免停在两轮之间只剩背景的中间态 */
|
/* 模块吸附:滚动停止后,若停在两轮之间的中间态,自动滚动到目标轮顶部,
|
||||||
export function initRoundAutoFocus(lenis) {
|
再由 onEnter 正常触发打字机动画(修复"滚不到位则动画错位") */
|
||||||
const targets = rounds
|
export function initRoundSnap(lenis) {
|
||||||
.filter((round) => round.question && !round.flow)
|
const sections = rounds
|
||||||
.map((round) => ({ round, section: document.getElementById(`r-${round.id}`) }))
|
.map((round) => document.getElementById(`r-${round.id}`))
|
||||||
.filter((target) => target.section);
|
.filter(Boolean);
|
||||||
|
if (!sections.length) return () => {};
|
||||||
if (!targets.length) return () => {};
|
|
||||||
|
|
||||||
|
let timer = 0;
|
||||||
let lockUntil = 0;
|
let lockUntil = 0;
|
||||||
let currentTarget = "";
|
|
||||||
|
|
||||||
const readY = () => (typeof lenis?.scroll === "number" ? lenis.scroll : window.scrollY);
|
const readY = () => (typeof lenis?.scroll === "number" ? lenis.scroll : window.scrollY);
|
||||||
|
|
||||||
const pinDistance = (round) => {
|
const snapTo = (section) => {
|
||||||
const match = String(round.pinLength || "").match(/^\+=([\d.]+)%$/);
|
lockUntil = performance.now() + 700;
|
||||||
const ratio = match ? Number(match[1]) / 100 : 1.6;
|
const targetY = section.getBoundingClientRect().top + readY();
|
||||||
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);
|
|
||||||
|
|
||||||
if (lenis) {
|
if (lenis) {
|
||||||
lenis.scrollTo(targetY, {
|
lenis.scrollTo(targetY, {
|
||||||
duration: 0.85,
|
duration: 0.55,
|
||||||
easing: (t) => 1 - Math.pow(1 - t, 3),
|
easing: (t) => 1 - Math.pow(1 - t, 3),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -262,52 +242,46 @@ export function initRoundAutoFocus(lenis) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const canFocus = ({ section }) => {
|
const settle = () => {
|
||||||
const now = performance.now();
|
if (performance.now() < lockUntil) return;
|
||||||
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 vh = window.innerHeight || document.documentElement.clientHeight;
|
const vh = window.innerHeight || document.documentElement.clientHeight;
|
||||||
const minTop = Math.max(36, vh * 0.08);
|
|
||||||
const maxTop = vh * 0.62;
|
|
||||||
|
|
||||||
return targets.find((target) => {
|
for (const section of sections) {
|
||||||
const { section } = target;
|
const top = section.getBoundingClientRect().top;
|
||||||
const rect = section.getBoundingClientRect();
|
// 已有模块对齐视口顶部 → 状态正常,无需吸附
|
||||||
if (!canFocus(target)) return false;
|
if (Math.abs(top) < 8) return;
|
||||||
return rect.top > minTop && rect.top < maxTop && rect.bottom > vh * 0.55;
|
// 模块露出一半(未到打字机区域)→ 吸附进入该模块
|
||||||
});
|
const halfEntered = top > vh * 0.12 && top < vh * 0.72;
|
||||||
};
|
// 模块占据主屏但未对齐 → 吸附回正
|
||||||
|
const nearlyAligned = top > -vh * 0.45 && top <= vh * 0.12;
|
||||||
const checkCurrent = () => {
|
if (halfEntered || nearlyAligned) {
|
||||||
const target = findHalfEnteredRound();
|
snapTo(section);
|
||||||
if (target) focusRound(target);
|
return;
|
||||||
};
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
}),
|
// 长内容轮(flow)内部深处不匹配任何区间 → 自由滚动,不干预
|
||||||
);
|
};
|
||||||
|
|
||||||
window.addEventListener("resize", checkCurrent);
|
const onScroll = () => {
|
||||||
window.setTimeout(checkCurrent, 250);
|
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 () => {
|
return () => {
|
||||||
triggers.forEach((trigger) => trigger.kill());
|
window.clearTimeout(timer);
|
||||||
window.removeEventListener("resize", checkCurrent);
|
window.removeEventListener("scroll", onScroll);
|
||||||
|
window.removeEventListener("resize", onScroll);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-18
@@ -22,9 +22,6 @@ export const rounds = [
|
|||||||
question: "先介绍一下你自己?",
|
question: "先介绍一下你自己?",
|
||||||
side: -1,
|
side: -1,
|
||||||
palette: ["#2dd4bf", "#a78bfa"],
|
palette: ["#2dd4bf", "#a78bfa"],
|
||||||
pinLength: "+=160%",
|
|
||||||
qEnd: 0.14,
|
|
||||||
thinkEnd: 0.3,
|
|
||||||
staggerEach: 0.055,
|
staggerEach: 0.055,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -34,9 +31,6 @@ export const rounds = [
|
|||||||
question: "最近一年具体在做什么?",
|
question: "最近一年具体在做什么?",
|
||||||
side: 1,
|
side: 1,
|
||||||
palette: ["#22d3ee", "#c084fc"],
|
palette: ["#22d3ee", "#c084fc"],
|
||||||
pinLength: "+=200%",
|
|
||||||
qEnd: 0.12,
|
|
||||||
thinkEnd: 0.34,
|
|
||||||
staggerEach: 0.085,
|
staggerEach: 0.085,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -46,9 +40,7 @@ export const rounds = [
|
|||||||
question: "有实际项目证明吗?",
|
question: "有实际项目证明吗?",
|
||||||
side: -1,
|
side: -1,
|
||||||
palette: ["#38bdf8", "#a78bfa"],
|
palette: ["#38bdf8", "#a78bfa"],
|
||||||
flow: true, // 内容超过一屏:不 pin,逐块流式生成
|
flow: true, // 内容超过一屏:逐块流式生成
|
||||||
qEnd: 0.07,
|
|
||||||
thinkEnd: 0.16,
|
|
||||||
staggerEach: 0.045,
|
staggerEach: 0.045,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -58,9 +50,6 @@ export const rounds = [
|
|||||||
question: "技能栈展开讲讲?",
|
question: "技能栈展开讲讲?",
|
||||||
side: 1,
|
side: 1,
|
||||||
palette: ["#67e8f9", "#8b5cf6"],
|
palette: ["#67e8f9", "#8b5cf6"],
|
||||||
pinLength: "+=240%",
|
|
||||||
qEnd: 0.1,
|
|
||||||
thinkEnd: 0.26,
|
|
||||||
staggerEach: 0.02,
|
staggerEach: 0.02,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -70,9 +59,6 @@ export const rounds = [
|
|||||||
question: "之前的团队经历?",
|
question: "之前的团队经历?",
|
||||||
side: -1,
|
side: -1,
|
||||||
palette: ["#5eead4", "#a78bfa"],
|
palette: ["#5eead4", "#a78bfa"],
|
||||||
pinLength: "+=220%",
|
|
||||||
qEnd: 0.13,
|
|
||||||
thinkEnd: 0.32,
|
|
||||||
staggerEach: 0.1,
|
staggerEach: 0.1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -82,9 +68,6 @@ export const rounds = [
|
|||||||
question: "怎么联系你?",
|
question: "怎么联系你?",
|
||||||
side: 0,
|
side: 0,
|
||||||
palette: ["#22d3ee", "#f0abfc"],
|
palette: ["#22d3ee", "#f0abfc"],
|
||||||
pinLength: "+=150%",
|
|
||||||
qEnd: 0.16,
|
|
||||||
thinkEnd: 0.34,
|
|
||||||
staggerEach: 0.09,
|
staggerEach: 0.09,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
+2
-2
@@ -5,7 +5,7 @@ import {
|
|||||||
applyReducedMotion,
|
applyReducedMotion,
|
||||||
initChoreography,
|
initChoreography,
|
||||||
initRail,
|
initRail,
|
||||||
initRoundAutoFocus,
|
initRoundSnap,
|
||||||
initSmoothScroll,
|
initSmoothScroll,
|
||||||
playBoot,
|
playBoot,
|
||||||
} from "./choreography";
|
} from "./choreography";
|
||||||
@@ -29,7 +29,7 @@ renderRail(document.getElementById("session-rail"), rail.jump);
|
|||||||
|
|
||||||
if (!reduced) {
|
if (!reduced) {
|
||||||
initChoreography(mind, rail.setActive);
|
initChoreography(mind, rail.setActive);
|
||||||
initRoundAutoFocus(lenis);
|
initRoundSnap(lenis);
|
||||||
playBoot(mind);
|
playBoot(mind);
|
||||||
} else {
|
} else {
|
||||||
applyReducedMotion();
|
applyReducedMotion();
|
||||||
|
|||||||
+3
-3
@@ -193,7 +193,7 @@ main {
|
|||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 96px 7vw;
|
padding: 72px 7vw;
|
||||||
}
|
}
|
||||||
|
|
||||||
.round-inner {
|
.round-inner {
|
||||||
@@ -210,7 +210,7 @@ main {
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
margin-bottom: 34px;
|
margin-bottom: 24px;
|
||||||
padding: 12px 20px;
|
padding: 12px 20px;
|
||||||
border: 1px solid rgba(34, 211, 238, 0.45);
|
border: 1px solid rgba(34, 211, 238, 0.45);
|
||||||
border-radius: 18px 18px 4px 18px;
|
border-radius: 18px 18px 4px 18px;
|
||||||
@@ -246,7 +246,7 @@ main {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
margin-bottom: 30px;
|
margin-bottom: 22px;
|
||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
letter-spacing: 0.2em;
|
letter-spacing: 0.2em;
|
||||||
|
|||||||
Reference in New Issue
Block a user