Files
shengsuan-homepage/src/scene/rig.ts
T

151 lines
5.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// rig.ts — 滚动驱动核心:一个数值驱动所有层(ARCHITECTURE.md §2
import { SEGMENTS, type Segment } from '../config/stage'
export interface RigState {
raw: number // 原生滚动进度 0..1
smooth: number // 阻尼平滑后的进度(reduced-motion 下 = raw
content: number // 映射到内容时间轴 0..1(末尾 8.5% 滚动留给终章驻留)
seg: Segment // 当前段落
segT: number // 段内进度 0..1
reduced: boolean
isMobile: boolean
pointer: { x: number; y: number } // NDC -1..1
pointerDamped: { x: number; y: number }
pointerActive: number // 入场收敛 0→1
// 供 DOM 层每帧读取的 3D 锚点屏幕投影
anchors: Record<string, { x: number; y: number; visible: boolean }>
// W1 故障事件时钟(秒,-1 = 未触发)
glitchClock: number
glitchActive: boolean
// 终章日出进度 0..1
sunrise: number
}
const TAIL = 0.915 // 内容时间轴占滚动比例,余下为终章驻留
class Rig {
state: RigState
private segListeners = new Set<(seg: Segment) => void>()
private frameListeners = new Set<(dt: number, t: number) => void>()
private lastSegId = ''
private glitchStart = -1
private sunriseStart = -1
constructor() {
const reduced =
typeof window !== 'undefined' &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches
const isMobile =
typeof window !== 'undefined' &&
(window.matchMedia('(pointer: coarse)').matches || window.innerWidth < 768)
this.state = {
raw: 0,
smooth: 0,
content: 0,
seg: SEGMENTS[0],
segT: 0,
reduced,
isMobile,
pointer: { x: 0, y: 0 },
pointerDamped: { x: 0, y: 0 },
pointerActive: 0,
anchors: {},
glitchClock: -1,
glitchActive: false,
sunrise: 0,
}
window.addEventListener('scroll', this.onScroll, { passive: true })
window.addEventListener('pointermove', this.onPointer, { passive: true })
this.onScroll()
}
private onScroll = () => {
const max = document.documentElement.scrollHeight - window.innerHeight
this.state.raw = max > 0 ? Math.min(1, Math.max(0, window.scrollY / max)) : 0
}
private onPointer = (e: PointerEvent) => {
if (this.state.isMobile || this.state.reduced) return
const nx = (e.clientX / window.innerWidth) * 2 - 1
const ny = (e.clientY / window.innerHeight) * 2 - 1
// 死区:中心 ±5%
const dz = (v: number) => (Math.abs(v) < 0.05 ? 0 : v)
this.state.pointer.x = dz(nx)
this.state.pointer.y = dz(ny)
}
onSegmentChange(fn: (seg: Segment) => void) {
this.segListeners.add(fn)
return () => { this.segListeners.delete(fn) }
}
onFrame(fn: (dt: number, t: number) => void) {
this.frameListeners.add(fn)
return () => { this.frameListeners.delete(fn) }
}
// 每帧(由世界渲染循环调用,顺序:滚动层 → 时间层 → 指针层)
update(dt: number, time: number) {
const s = this.state
// 滚动平滑 0.08/帧(60fps 基准,帧率无关化)
const k = s.reduced ? 1 : 1 - Math.pow(1 - 0.08, dt * 60)
s.smooth += (s.raw - s.smooth) * k
if (Math.abs(s.raw - s.smooth) < 0.0004) s.smooth = s.raw
s.content = Math.min(1, s.smooth / TAIL)
// 段落定位
let seg = SEGMENTS[SEGMENTS.length - 1]
for (const g of SEGMENTS) {
if (s.content >= g.t0 && s.content <= g.t1) {
seg = g
break
}
}
// 终章驻留:content 到 1 后视为 W7
if (s.content >= 1) seg = { ...SEGMENTS[SEGMENTS.length - 1], id: 'W7', type: 'dwell' }
s.seg = seg
s.segT = seg.t1 > seg.t0 ? (s.content - seg.t0) / (seg.t1 - seg.t0) : 1
s.segT = Math.min(1, Math.max(0, s.segT))
if (seg.id !== this.lastSegId) {
this.lastSegId = seg.id
this.segListeners.forEach((f) => f(seg))
if (seg.id === 'W1' && this.glitchStart < 0 && !s.reduced) this.glitchStart = time
if (seg.id === 'W7' && this.sunriseStart < 0) this.sunriseStart = time
}
// 离开 W1:故障收干净(可重放)
if (seg.id !== 'W1' && this.glitchStart >= 0) this.glitchStart = -1
// 故障时钟(3s 编排)
if (this.glitchStart >= 0) {
s.glitchClock = time - this.glitchStart
s.glitchActive = s.glitchClock < 3
} else {
s.glitchClock = -1
s.glitchActive = false
}
// 日出进度:T6 中后段由滚动爬升至 30%,进入 W7 后时间驱动 9s 爬满
let sun = 0
if (s.content > 0.97) sun = ((s.content - 0.97) / 0.03) * 0.3
if (this.sunriseStart >= 0) {
const el = time - this.sunriseStart
// 缓出:先快后慢
const timed = 0.3 + 0.7 * (1 - Math.pow(1 - Math.min(1, el / 9), 2.2))
sun = Math.max(sun, timed)
}
if (s.reduced && s.content >= 1) sun = 1 // 降级:静态终章
s.sunrise = Math.min(1, sun)
// 指针阻尼(v20.03 更重,"惯性云台"感)+ 入场收敛 0.5s
const pk = 1 - Math.pow(1 - 0.03, dt * 60)
s.pointerDamped.x += (s.pointer.x - s.pointerDamped.x) * pk
s.pointerDamped.y += (s.pointer.y - s.pointerDamped.y) * pk
s.pointerActive = Math.min(1, s.pointerActive + dt / 0.5)
this.frameListeners.forEach((f) => f(dt, time))
}
}
export const rig = new Rig()