151 lines
5.2 KiB
TypeScript
151 lines
5.2 KiB
TypeScript
// 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)
|
||
|
||
// 指针阻尼(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()
|