feat(幕布): GVSDS 改为按字形笔画自动绘制 + 一划到底

- 幕布字改为 SVG:每个字母一个 <text>,用 stroke-dasharray/dashoffset 沿 --font-sans 字形轮廓同时起笔,画完补实心

- 轮廓长度用 getComputedTextLength() * 4.2 估算(getTotalLength 不作用于 <text>),viewBox 按实测 bbox 设

- 幕布露出 60% 时起笔,只画一次;prefers-reduced-motion 下直接显示

- 距底不足 0.6 屏时,下一次向下滑动(滚轮/触屏)由 Lenis 平滑滑到底

- App.vue 用 provide 下发 Lenis 实例,避免与 window.scrollTo 抢滚动
This commit is contained in:
TermiNexus committed 2026-09-29 23:13:06 +08:00
1 parent ec092d648e
commit b8cc5ab483
2 files changed
+197 -20

No files matched your search

+10 -6
View File
@@ -4,12 +4,16 @@
<script setup lang="ts">
// 根组件:仅承载路由出口,并在此挂载全局平滑惯性滚动
import { onMounted, onBeforeUnmount, watch } from 'vue'
import { onMounted, onBeforeUnmount, watch, provide, shallowRef } from 'vue'
import { useRoute } from 'vue-router'
import Lenis from 'lenis'
// Lenis 平滑惯性滚动实例(接管滚轮,手势结束后继续滑行一段距离再停)
let lenis: Lenis | null = null
const lenisRef = shallowRef<Lenis | null>(null)
/** 实例下发到各层组件:SiteLayout 的幕布要"一划到底",得驱动 Lenis,
* 而不是用 window.scrollTo 跟它的 rAF 抢滚动位置(两边互相覆盖会抖) */
provide('gvsds-lenis', lenisRef)
const route = useRoute()
@@ -18,7 +22,7 @@ onMounted(() => {
// autoRaf 由库内部自驱动 requestAnimationFrame;respectReducedMotion 默认开启,
// 系统开启"减弱动态效果"时自动退回原生滚动
// allowNestedScroll:弹窗/下拉/多行输入等自带滚动容器的区域交还原生滚动,避免被接管
lenis = new Lenis({
lenisRef.value = new Lenis({
lerp: 0.08,
wheelMultiplier: 1.05,
autoRaf: true,
@@ -31,12 +35,12 @@ onMounted(() => {
watch(
() => route.fullPath,
() => {
lenis?.scrollTo(0, { immediate: true, force: true })
lenisRef.value?.scrollTo(0, { immediate: true, force: true })
},
)
onBeforeUnmount(() => {
lenis?.destroy()
lenis = null
lenisRef.value?.destroy()
lenisRef.value = null
})
</script>
+187 -14
View File
@@ -187,20 +187,38 @@
<CookieBanner />
</div>
<!-- ===== 品牌幕布:滚到最底后继续滑,露出半屏蓝色底 + 白色巨字 GVSDS =====
<!-- ===== 品牌幕布:滚到最底后继续滑,露出半屏蓝色底 + 巨字 GVSDS =====
幕布固定贴住视口下半屏(z-0),平时被内容层(z-1 + 白底)完整盖住;
末尾留半屏透明占位,滚到底时占位区把幕布整块露出来("抽盖子"式的翻页观感)。
用 z-0 / z-1 这对正数分层,而不是负 z-index:body 的不透明背景会吃掉负 z-index,
幕布就永远看不见了。两者放在内容层之外,否则会被内容层的层叠上下文与白底吃掉。 -->
幕布就永远看不见了。两者放在内容层之外,否则会被内容层的层叠上下文与白底吃掉。
字形不是普通文字,而是 SVG:幕布先是纯蓝背景,露出六成之后五个字母同时起笔,
沿着 --font-sans 的字形轮廓把笔画一段段"写"出来,写完再补上实心。
每个字母一个独立 <text>:同一元素内 stroke-dasharray 会跨字形连着走,
拆开才能五个字同时起笔。轮廓长度由 JS 估算后写进 --dash(见 setupCurtain)。 -->
<div class="brand-curtain" aria-hidden="true">
<span class="brand-curtain-word">GVSDS</span>
<svg ref="curtainSvg" class="brand-curtain-svg" xmlns="http://www.w3.org/2000/svg">
<g ref="curtainGroup">
<text
v-for="(ch, i) in curtainLetters"
:key="i"
:ref="(el) => setLetterEl(el, i)"
class="brand-curtain-letter"
:class="{ 'is-drawn': curtainDrawn }"
x="0"
y="0"
>{{ ch }}</text>
</g>
</svg>
</div>
<div class="brand-curtain-spacer" aria-hidden="true"></div>
<div ref="curtainSpacer" class="brand-curtain-spacer" aria-hidden="true"></div>
</template>
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { computed, inject, nextTick, onMounted, onUnmounted, ref, watch, type ComponentPublicInstance, type Ref } from 'vue'
import { useRouter } from 'vue-router'
import type Lenis from 'lenis'
import { useSiteStore } from '@/stores/site'
import SiteFooter from '@/components/SiteFooter.vue'
import CookieBanner from '@/components/CookieBanner.vue'
@@ -211,6 +229,9 @@ import { megaMenuData, type MegaMenuData, type MegaSection, type MegaSubmenuItem
const router = useRouter()
const siteStore = useSiteStore()
/** App.vue 下发的 Lenis 实例:幕布"一划到底"要驱动它,不能和它抢滚动 */
const lenisRef = inject<Ref<Lenis | null> | null>('gvsds-lenis', null)
/** 顶部导航项 */
const navItems = [
{ key: 'studio', label: '工作室' },
@@ -420,6 +441,123 @@ function hideResultsSoon(): void {
}, 200)
}
/** ===== 品牌幕布:沿字形笔画把 GVSDS 写出来 ===== */
/** 幕布字母。必须是一个字母一个 <text>:SVG 的虚线图案在同一元素内
* 会顺着所有字形轮廓连续走,只有拆成独立元素才能五个字同时起笔 */
const curtainLetters = ['G', 'V', 'S', 'D', 'S'] as const
/** 轮廓长度估算系数。getTotalLength() 只对 <path> 这类几何元素生效,<text> 取不到
* 真实轮廓长度,只能用字宽 getComputedTextLength() 乘经验系数估:
* 大写 D / G 的轮廓周长约为字宽的 4.0 倍、S / V 约 3.0 倍。取 4.2 保证所有笔画都能收尾
* (取小了尾部一段永远画不满),代价是笔画少的 S / V 会比 D / G 稍早停笔 */
const CURTAIN_DASH_FACTOR = 4.2
/** 幕布露出这么多比例即起笔(与 <style> 里的 transition 无关,只管时机) */
const CURTAIN_DRAW_AT = 0.6
/** 一划到底:距页面底部不足这么多屏时,下一次向下滑动直接滑到底 */
const CURTAIN_GLIDE_ZONE = 0.6
const CURTAIN_GLIDE_MS = 1100
const curtainSvg = ref<SVGSVGElement | null>(null)
const curtainGroup = ref<SVGGElement | null>(null)
const curtainSpacer = ref<HTMLElement | null>(null)
const curtainDrawn = ref(false)
/** v-for 里的 <text> 引用,setupCurtain 要逐个量身定位 */
const letterEls: (SVGTextElement | null)[] = []
function setLetterEl(el: Element | ComponentPublicInstance | null, index: number): void {
letterEls[index] = el instanceof SVGTextElement ? el : null
}
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)')
let curtainObserver: IntersectionObserver | null = null
let curtainGlideUntil = 0
/**
* 幕布初始化(挂载后跑一次):
* ① 先等字体就绪——字体没加载完就量,量到的是回退字体的字宽,字母会排错位;
* ② 逐个字母量宽:按累计宽度排 x,并按估算的轮廓周长写 --dash(虚线的长与偏移初值);
* ③ 按整组实测 bbox 设 viewBox,让五个字母整体铺满这半屏幕布,四周留一点边不贴框。
*/
async function setupCurtain(): Promise<void> {
const svg = curtainSvg.value
const els = letterEls.filter((el): el is SVGTextElement => el !== null)
if (!svg || els.length === 0) return
try {
await document.fonts.ready
} catch {
// 字体接口不可用就按当前字体量(退到系统无衬线,位置仍自洽)
}
let x = 0
els.forEach((el) => {
const advance = el.getComputedTextLength()
el.setAttribute('x', String(x))
el.style.setProperty('--dash', String(advance * CURTAIN_DASH_FACTOR))
x += advance
})
const box = curtainGroup.value?.getBBox()
if (box && box.width > 0 && box.height > 0) {
const pad = box.width * 0.03
svg.setAttribute(
'viewBox',
`${box.x - pad} ${box.y - pad} ${box.width + pad * 2} ${box.height + pad * 2}`
)
}
}
/** 幕布露出六成以上即起笔;只画一次,来回滚动不重播(反复重画反而闪) */
function watchCurtain(): void {
if (prefersReducedMotion.matches) {
curtainDrawn.value = true
return
}
const spacer = curtainSpacer.value
if (!spacer) return
curtainObserver = new IntersectionObserver(
(entries) => {
if (!entries.some((e) => e.isIntersecting && e.intersectionRatio >= CURTAIN_DRAW_AT)) return
curtainDrawn.value = true
curtainObserver?.disconnect()
curtainObserver = null
},
{ threshold: [CURTAIN_DRAW_AT] }
)
curtainObserver.observe(spacer)
}
/** 一划到底:幕布已露头(距底不足 0.6 屏)时再一次向下滑动,就直接滑到底,
* 省得用户在最后半屏里一点点蹭;顺带让描边动画在整块幕布前完整播完 */
function glideToBottom(): void {
const now = performance.now()
if (now < curtainGlideUntil) return
const doc = document.documentElement
const remaining = doc.scrollHeight - window.scrollY - window.innerHeight
if (remaining <= 0 || remaining > window.innerHeight * CURTAIN_GLIDE_ZONE) return
curtainGlideUntil = now + CURTAIN_GLIDE_MS + 300
const top = doc.scrollHeight
const lenis = lenisRef?.value
if (lenis) lenis.scrollTo(top, { duration: CURTAIN_GLIDE_MS / 1000 })
else window.scrollTo({ top, behavior: 'smooth' })
}
/** 滚轮向下即触发;监听用 passive——不拦默认行为,交给 Lenis 处理滚动,我们只负责改目标 */
function onCurtainWheel(e: WheelEvent): void {
if (e.deltaY > 0) glideToBottom()
}
/** 触屏:手指上移 = 页面向下滚 */
let lastTouchY = 0
function onCurtainTouchStart(e: TouchEvent): void {
lastTouchY = e.touches[0]?.clientY ?? 0
}
function onCurtainTouchMove(e: TouchEvent): void {
const y = e.touches[0]?.clientY ?? lastTouchY
if (y < lastTouchY) glideToBottom()
lastTouchY = y
}
/** 滚动时 header 透明/白底切换(对齐 nav.js)
* 仅 www 首页(HomeView)在页面顶部时透明,其余页面保持白底不透明 */
function handleScroll(): void {
@@ -458,11 +596,21 @@ onMounted(() => {
window.addEventListener('scroll', handleScroll)
handleScroll()
loadAvatar()
// 幕布:先量字形排好字与 viewBox,再盯着露头起笔;滑动监听用 passive,不拦默认滚动
setupCurtain()
watchCurtain()
window.addEventListener('wheel', onCurtainWheel, { passive: true })
window.addEventListener('touchstart', onCurtainTouchStart, { passive: true })
window.addEventListener('touchmove', onCurtainTouchMove, { passive: true })
})
onUnmounted(() => {
window.removeEventListener('scroll', handleScroll)
window.removeEventListener('wheel', onCurtainWheel)
window.removeEventListener('touchstart', onCurtainTouchStart)
window.removeEventListener('touchmove', onCurtainTouchMove)
if (panelObserver) panelObserver.disconnect()
if (curtainObserver) curtainObserver.disconnect()
if (heightRaf !== null) cancelAnimationFrame(heightRaf)
})
@@ -572,18 +720,43 @@ watch(
pointer-events: none;
}
.brand-curtain-word {
/* 幕布内容:一个 SVG 装 5 个字母,viewBox 由 JS 按实测字形 bbox 设,五个字整体铺满这半屏;
字形取自 --font-sans,所以笔画是"按字体"来的,不是画好的图形 */
.brand-curtain-svg {
width: 100%;
height: 100%;
display: block;
}
/* 每个字母一个独立 <text>,虚线它自己一套;--dash 由 JS 按估算的轮廓周长写入 */
.brand-curtain-letter {
font-family: var(--font-sans);
/* 字号同时受两个方向的约束:横向 5 个字母约 3.6em 不能超出视口,纵向要撑满这半屏幕布。
取两者较小值 → 宽屏时高度顶满、窄屏时宽度顶满(vh 那行是 svh 不支持时的回退) */
font-size: min(26vw, 40vh);
font-size: min(26vw, 40svh);
font-weight: 900;
/* 不用 line-height < 1:字形会超出内容盒(配 background-clip 时会被裁掉一截) */
line-height: 1;
/* 用户单位字号:只决定量出来的字宽与描边粗细的比例,最终大小交给 viewBox 缩放 */
font-size: 200px;
letter-spacing: -0.015em;
white-space: nowrap;
color: #ffffff;
fill: #ffffff;
fill-opacity: 0;
stroke: #ffffff;
stroke-width: 2.5;
stroke-linecap: round;
stroke-linejoin: round;
/* 量完之前整组不可见:幕布先是纯蓝背景,起笔后才把笔画一段段显出来 */
stroke-opacity: 0;
stroke-dasharray: var(--dash, 0);
stroke-dashoffset: var(--dash, 0);
}
/* 起笔:虚线偏移归零 → 描边沿着字形轮廓把笔画画出来;随后实心补上,回到原来的实心白字。
transition 只写在 .is-drawn 里:量完写 --dash 那一次改动不能带过渡,
否则偏移会从 0 缓到量好的值(等于提前把字画一遍) */
.brand-curtain-letter.is-drawn {
stroke-opacity: 1;
stroke-dashoffset: 0;
fill-opacity: 1;
transition:
stroke-dashoffset 2s cubic-bezier(0.33, 0, 0.55, 1),
fill-opacity 0.5s ease 1.65s;
}
/* 末尾占位:与幕布等高(半屏,svh 避开移动端地址栏收缩导致的跳动),透明 → 露出幕布 */