// 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 // 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) // 指针阻尼(v2:0.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()