import { TL } from './timelineData' import type { Frame, View, OverlayApi } from './engine' import { POSE, type SceneState, type Pose } from './model' import { baseState, orbit } from './state' import { clamp, kf, lerp, lerpArr, ramp, smooth, win, easeOut, noise1, type V3 } from './math' import * as O from './overlay' export const DURATION = TL.total export const SHOTS = TL.shots export const LINES = TL.lines type C = CanvasRenderingContext2D /* ---------------- helpers ---------------- */ const shotLines = (i: number) => LINES.filter((l) => l.shot === i).map((l) => ({ ...l, s: l.start - SHOTS[i].start, e: l.start - SHOTS[i].start + l.dur })) const SL = SHOTS.map((_, i) => shotLines(i)) const withThumb = (p: Pose, th: number[]) => { const q = [...p]; for (let i = 0; i < 5; i++) q[16 + i] = th[i]; return q } const THUMB_FLEX = [-0.08, 0.75, 0.1, 0.9, 0.65] const THUMB_EXT = [0.35, -0.1, 0.0, -0.05, -0.05] /** thumb flex/extension cycle on top of a base pose */ function thumbCycle(base: Pose, u: number, hz: number, amp: number, jerky = 0): Pose { let k = 0.5 - 0.5 * Math.cos(u * hz * Math.PI * 2) if (jerky > 0) { // stick–slip: hold then jump const stepK = Math.round(k * 4) / 4 k = lerp(k, stepK, jerky) + noise1(u * 13) * 0.04 * jerky } const ext = withThumb(base, THUMB_EXT) const flx = withThumb(base, THUMB_FLEX) return lerpArr(ext, flx, clamp(k * amp + (1 - amp) * 0.35)) } const mixPose = (a: Pose, b: Pose, k: number) => lerpArr(a, b, clamp(k)) const setW = (p: Pose, flex: number, dev: number, pron = p[2]) => { const q = [...p]; q[0] = flex; q[1] = dev; q[2] = pron; return q } const full = (state: SceneState, bg = 'lab'): View[] => [{ rect: [0, 0, 1, 1], state, bg }] const painCam = (dist: number, yaw: number, pitch: number, fov = 35) => ({ pos: orbit([0, 0, 0], dist, yaw, pitch), target: [0, 0, 0] as V3, fov, follow: 1 }) /* ========================================================================= SHOT 1 · 疼痛开始 ========================================================================= */ function shot1(u: number, d: number, t: number): Frame { const L = SL[0] const a = L[0].s, b = L[1].s // pose let pose: Pose if (u < a - 0.2) pose = thumbCycle(POSE.rest, u, 0.85, 0.9) else pose = mixPose(mixPose(POSE.rest, POSE.fist, smooth(ramp(u, a - 0.2, a + 0.6))), POSE.tuck, smooth(ramp(u, a + 0.55, a + 1.15))) const hit = a + 1.05 const painK = smooth(ramp(u, hit, hit + 0.35)) // relax after the hit, back to near rest when going inside pose = mixPose(pose, mixPose(POSE.rest, POSE.tuck, 0.25), smooth(ramp(u, b + 0.9, b + 1.6))) const shakeA = Math.max(0, 1 - Math.abs(u - hit - 0.15) / 0.35) * 0.035 const shake: V3 = [noise1(u * 40) * shakeA, noise1(u * 37 + 5) * shakeA, 0] const push = smooth(ramp(u, b - 0.25, b + 1.0)) const xr = smooth(ramp(u, b + 0.7, b + 1.3)) const camWide = { pos: orbit([0, 0.45, 0], lerp(5.6, 4.8, smooth(u / a)), 0.25 - 0.15 * smooth(u / d), 0.08), target: [0, 0.45, 0] as V3, fov: 35 } const state = baseState({ time: t, pose, shake, arm: { pos: [0, 0, 0], rot: [0.0, lerp(0.45, 0.95, push), 0.04] }, cam: push < 0.001 ? camWide : (() => { const pc = painCam(lerp(4.8, 1.75, push), lerp(0.1, 0.05, push), lerp(0.08, 0.12, push)) // blend from hand-centered to pain-follow return { ...pc, follow: push, pos: lerpVec(camWide.pos, pc.pos, push), target: lerpVec(camWide.target, pc.target, push) } })(), pain: painK * (1 - 0.2 * xr), xray: xr, skin: 1, bone: smooth(ramp(u, b + 1.0, b + 1.7)), tendon: smooth(ramp(u, b + 1.25, b + 1.95)), sheath: smooth(ramp(u, b + 1.5, d + 0.4)), friction: 0.0, }) return { views: full(state), fade: 1 - smooth(u / 0.8), overlay: (ctx, api) => { const p = api.anchor('pain') O.painBurst(ctx, p.x, p.y, Math.max(0, 1 - Math.abs(u - hit - 0.4) / 1.2) * painK, t) O.reticle(ctx, p.x, p.y, 70, win(u, b, d, 0.3, 0.4) * (1 - xr * 0.5), t, O.COL.red, smooth(ramp(u, b, b + 0.6))) O.bigWord(ctx, '?', p.x + 150, p.y - 150, ramp(u, b + 0.2, b + 0.6) * (1 - ramp(u, b + 1.3, b + 1.6)), O.COL.red, 150) // layer labels const lb = api.anchor('bone'), lt = api.anchor('tendon'), ls = api.anchor('sheath') O.label(ctx, lb.x, lb.y, lb.x + 230, lb.y + 120, '骨骼', ramp(u, b + 1.2, b + 1.5), '#f2e7cf') O.label(ctx, lt.x, lt.y, lt.x + 260, lt.y - 60, '肌腱', ramp(u, b + 1.45, b + 1.75), O.COL.white) O.label(ctx, ls.x, ls.y, ls.x - 230, ls.y - 170, '腱鞘', ramp(u, b + 1.7, b + 2.0), O.COL.cyan) }, } } const lerpVec = (a: V3 | number[], b: V3 | number[], k: number): V3 => [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)] /* ========================================================================= SHOT 2 · 腱鞘炎到底发生了什么 ========================================================================= */ function shot2(u: number, d: number, t: number): Frame { const L = SL[1] const thick = smooth(ramp(u, L[2].s, L[3].e)) const fr = Math.max(0.45 * smooth(ramp(u, L[2].s, L[3].s)), 0.95 * smooth(ramp(u, L[4].s, L[5].s + 0.6))) const jerk = smooth(ramp(u, L[3].s, L[5].s)) const pose = thumbCycle(POSE.rest, u, lerp(0.9, 1.1, jerk), lerp(1, 0.7, jerk), jerk) const state = baseState({ time: t, pose, arm: { pos: [0, 0, 0], rot: [0.0, lerp(0.95, 0.75, smooth(u / d)), 0.04] }, cam: painCam(lerp(1.75, 1.08, easeOut(u / 3)), lerp(0.05, -0.12, smooth(u / d)), 0.12), skin: 0.75, xray: 1, bone: 1, tendon: 1, sheath: 1, focus: smooth(ramp(u, 0, 1.0)), thick, friction: fr, pain: 0.85 * smooth(ramp(u, L[5].s, L[5].s + 0.6)), }) return { views: full(state), overlay: (ctx, api) => { const s = api.anchor('sheath'), tn = api.anchor('tendon') O.label(ctx, s.x, s.y, s.x - 200, s.y - 260, '腱鞘 = 小通道', win(u, L[0].s, L[2].s + 0.3), O.COL.cyan) O.label(ctx, tn.x, tn.y, tn.x + 150, tn.y + 200, '肌腱', win(u, L[0].s + 0.4, L[2].s + 0.3), O.COL.white) O.chip(ctx, 540, 1300, '正常:顺畅滑动', { icon: 'check', color: O.COL.green, alpha: win(u, L[1].s, L[2].s + 0.2) }) O.crossSection(ctx, 830, 560, 105, thick, fr, smooth(ramp(u, 0.5, 1.0)), t) const k = (i: number) => ramp(u, L[i].s + 0.1, L[i].s + 0.5) O.chip(ctx, 540, 1150, '腱鞘增厚', { icon: 'warn', color: O.COL.yellow, alpha: k(3), scale: 0.8 + 0.2 * easeOut(k(3)) }) O.chip(ctx, 540, 1268, '空间变窄', { icon: 'warn', color: '#ff9a3c', alpha: k(4), scale: 0.8 + 0.2 * easeOut(k(4)) }) O.chip(ctx, 540, 1386, '活动疼痛', { icon: 'warn', color: O.COL.red, alpha: k(5), scale: 0.8 + 0.2 * easeOut(k(5)) }) }, } } /* ========================================================================= SHOT 3 · 为什么一动就疼 (抓 / 捏 / 拧) ========================================================================= */ function shot3(u: number, d: number, t: number): Frame { const seg = d / 3 const i = Math.min(2, Math.floor(u / seg)), v = u - i * seg const squeeze = 0.5 - 0.5 * Math.cos(v * Math.PI * 2 * 1.3) let pose: Pose, props: SceneState['props'] = {} let twist: { pivot: V3; axis: V3; angle: number } | undefined if (i === 0) { pose = mixPose(POSE.open, POSE.gripBall, smooth(ramp(v, 0, 0.35)) * (0.8 + 0.2 * squeeze)); props = { ball: { vis: smooth(ramp(v, 0, 0.2)) } } } else if (i === 1) { pose = mixPose(POSE.open, POSE.pinch, smooth(ramp(v, 0, 0.35)) * (0.9 + 0.1 * squeeze)); props = { bead: { vis: smooth(ramp(v, 0.25, 0.4)) } } } else { pose = mixPose(POSE.open, POSE.gripBall, smooth(ramp(v, 0, 0.3))) pose = setW(pose, pose[0], pose[1] + 0.25 * squeeze) const ang = -0.8 * squeeze twist = { pivot: [-0.02, 0.6, -0.3], axis: [0, 0, 1], angle: ang } props = { bottle: { vis: smooth(ramp(v, 0, 0.2)), twist: ang } } } const lvl = [0.4, 0.7, 1.0][i] const state = baseState({ time: t, pose, props, arm: { pos: [0, 0, 0], rot: [0.05, 0.85, 0.05], twist }, cam: { ...painCam(2.5, 0.25, 0.18), pos: orbit([0, 0, 0], 2.5, 0.25, 0.18) }, skin: 1, xray: 0.62, bone: 1, tendon: 1, sheath: 1, focus: 0.25, thick: 1, friction: lvl * (0.7 + 0.3 * squeeze), pain: lvl * (0.75 + 0.25 * squeeze), }) // camera looks slightly above the pain point so the hand & prop are visible state.cam.target = [0, 0.35, 0]; state.cam.pos = [state.cam.pos[0], state.cam.pos[1] + 0.35, state.cam.pos[2]] return { views: full(state), overlay: (ctx, api) => { const names = ['抓', '捏', '拧'] as const const kinds = ['grab', 'pinch', 'twist'] as const names.forEach((n, k) => { const on = k === i ? 1 : k < i ? 0.55 : 0.3 const x = 250 + k * 290, y = 1230 ctx.save(); ctx.globalAlpha = on ctx.fillStyle = k === i ? 'rgba(255,59,59,0.22)' : 'rgba(4,14,30,0.7)' ctx.strokeStyle = k === i ? O.COL.red : 'rgba(95,212,255,0.5)'; ctx.lineWidth = k === i ? 5 : 2 ctx.beginPath(); ctx.arc(x, y, 92 * (k === i ? 1 + 0.06 * squeeze : 1), 0, 7); ctx.fill(); ctx.stroke() ctx.restore() O.actionIcon(ctx, kinds[k], x, y - 22, 90, k === i ? '#fff' : O.COL.cyan, on) ctx.save(); ctx.globalAlpha = on; ctx.font = `900 46px ${O.FONT}`; ctx.fillStyle = '#fff'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(n, x, y + 48); ctx.restore() }) // pain meter const p = api.anchor('pain') O.painBurst(ctx, p.x, p.y, lvl * 0.5 * squeeze, t) const mx = 975, my0 = 520, mh = 480 ctx.save() ctx.fillStyle = 'rgba(4,14,30,0.75)'; ctx.fillRect(mx - 20, my0, 40, mh) const fillH = mh * lvl * (0.85 + 0.15 * squeeze) const g = ctx.createLinearGradient(0, my0 + mh, 0, my0); g.addColorStop(0, O.COL.yellow); g.addColorStop(1, O.COL.red) ctx.fillStyle = g; ctx.fillRect(mx - 20, my0 + mh - fillH, 40, fillH) ctx.strokeStyle = 'rgba(255,255,255,0.6)'; ctx.lineWidth = 2; ctx.strokeRect(mx - 20, my0, 40, mh) ctx.font = `800 30px ${O.FONT}`; ctx.fillStyle = '#fff'; ctx.textAlign = 'center'; ctx.fillText('疼痛', mx, my0 + mh + 40) ctx.restore() }, } } /* ========================================================================= SHOT 4 · 哪些人更容易出现 ========================================================================= */ function shot4(u: number, d: number, t: number): Frame { const L = SL[3] const s0 = L[1].s - 0.05 const segD = (d - s0) / 4 const intro = u < s0 const i = intro ? -1 : Math.min(3, Math.floor((u - s0) / segD)) const v = intro ? u : u - s0 - i * segD const words = ['重复用手', '长期抓握', '频繁捏拧', '抱孩子'] const icons = ['keyboard', 'tool', 'home', 'baby'] as const let state: SceneState const painFlash = intro ? 0 : smooth(ramp(v, 0.35, 0.6)) * (0.75 + 0.25 * Math.sin(t * 9)) if (intro) { state = baseState({ time: t, pose: mixPose(POSE.rest, POSE.open, 0.5 + 0.5 * Math.sin(u * 3)), arm: { pos: [0, 0, 0], rot: [0, -0.4 + u * 0.9, 0] }, cam: { pos: orbit([0, 0.4, 0], 5.4, 0, 0.1), target: [0, 0.4, 0], fov: 35 }, }) } else if (i === 0) { const tap = Math.max(0, Math.sin(v * 18)) const pose = setW(POSE.mouse, -0.25, 0.1 + 0.18 * Math.sin(v * 5)) pose[4] += tap * 0.18 state = baseState({ time: t, pose, arm: { pos: [0, 0, 0], rot: [-1.25, 0, 0] }, props: { keyboard: { vis: 1, x: -1.75, y: 0.7, z: -0.38, rx: Math.PI / 2 } }, cam: { pos: [-1.0, 2.4, 2.6], target: [-0.35, 0.15, -0.6], fov: 38 }, pain: painFlash, }) } else if (i === 1) { const hamK = Math.pow(0.5 + 0.5 * Math.sin(v * 9), 2) state = baseState({ time: t, pose: setW(POSE.grip, 0.05, -0.15 + 0.5 * hamK), arm: { pos: [0, 0, 0], rot: [0, 0.85, 0.15] }, props: { hammer: { vis: 1 } }, cam: { pos: orbit([-0.2, 0.55, 0], 4.2, 0.05, 0.12), target: [-0.2, 0.55, 0], fov: 35 }, pain: painFlash, }) } else if (i === 2) { const w = Math.sin(v * 6) state = baseState({ time: t, pose: setW(POSE.grip, 0.1, 0.15 + 0.15 * w, 0.55 * w), arm: { pos: [0, 0, 0], rot: [0, 0.75, -0.2] }, props: { towel: { vis: 1 } }, cam: { pos: orbit([0.1, 0.5, 0], 4.4, 0.1, 0.15), target: [0.1, 0.5, 0], fov: 35 }, pain: painFlash, }) } else { state = baseState({ time: t, pose: setW(POSE.baby, -0.25, 0.05 * Math.sin(v * 4)), arm: { pos: [0, -0.1 * Math.sin(v * 3), 0], rot: [1.25, 0.7, 0] }, props: { baby: { vis: 1 } }, cam: { pos: [1.6, 3.0, 3.6], target: [0.0, 0.35, 0.3], fov: 38 }, pain: painFlash, }) } return { views: full(state, intro ? 'lab' : 'warm'), overlay: (ctx, api) => { if (!intro && v < 0.14) O.flash(ctx, 0.35 * (1 - v / 0.14)) if (intro) { icons.forEach((ic, k) => { const a = ramp(u, 0.2 + k * 0.12, 0.45 + k * 0.12) const x = 195 + k * 230, y = 1230 ctx.save(); ctx.globalAlpha = a; ctx.fillStyle = 'rgba(4,14,30,0.75)'; ctx.strokeStyle = O.COL.cyan; ctx.lineWidth = 3 ctx.beginPath(); ctx.arc(x, y, 80, 0, 7); ctx.fill(); ctx.stroke(); ctx.restore() O.actionIcon(ctx, ic, x, y, 80, O.COL.cyan, a) }) O.bigWord(ctx, '哪些人更容易出现?', 540, 1050, ramp(u, 0.1, 0.5), '#ffffff', 74) return } const p = api.anchor('pain') O.painBurst(ctx, p.x, p.y, painFlash * 0.6, t) // scene counter + keyword ctx.save(); ctx.font = `900 34px ${O.FONT}`; ctx.fillStyle = 'rgba(255,255,255,0.75)'; ctx.textAlign = 'right' ctx.fillText(`0${i + 1} / 04`, 1030, 330); ctx.restore() O.chip(ctx, 540, 1290, words[i], { color: O.COL.red, size: 60, alpha: ramp(v, 0.05, 0.25), scale: 0.85 + 0.15 * easeOut(ramp(v, 0.05, 0.35)), icon: 'dot' }) O.actionIcon(ctx, icons[i], 120, 330, 90, O.COL.yellow, 0.9) }, } } /* ========================================================================= SHOT 5 · 不要一上来就直接治疗 ========================================================================= */ function shot5(u: number, d: number, t: number): Frame { const L = SL[4] const lock = smooth(ramp(u, L[1].s - 0.2, L[1].s + 0.6)) const motion = ramp(u, L[2].s, L[2].s + 0.2) const probeK = smooth(ramp(u, L[3].s - 0.1, L[3].s + 0.4)) const usK = smooth(ramp(u, L[3].s + 0.5, L[3].s + 1.0)) const pose = motion > 0 ? thumbCycle(POSE.rest, u, 1.1, 0.9) : POSE.rest const rotY = lerp(-0.7, 0.9, smooth(ramp(u, 0, L[1].s))) const wide = { pos: orbit([0, 0.35, 0], 4.9, 0, 0.1), target: [0, 0.35, 0] as V3, fov: 35 } const pc = painCam(lerp(4.9, 1.6, lock), 0.05, 0.12) const state = baseState({ time: t, pose, arm: { pos: [0, 0, 0], rot: [0.03, rotY, 0.04] }, cam: { ...pc, follow: lock, pos: lerpVec(wide.pos, pc.pos, lock), target: lerpVec(wide.target, pc.target, lock) }, skin: 1, xray: 0.75, bone: 1, tendon: 1, sheath: 1, thick: 0.8, focus: smooth(ramp(u, L[2].s, L[2].s + 0.5)) * 0.9, friction: 0.6 * motion, pain: 0.55 + 0.3 * lock, props: { probe: { vis: probeK, lift: (1 - probeK) * 0.6 } }, }) return { views: full(state), overlay: (ctx, api) => { O.chip(ctx, 540, 1180, '直接治疗', { icon: 'cross', color: O.COL.red, alpha: win(u, 0.2, L[1].s + 0.2), strike: ramp(u, 0.9, 1.5) }) O.chip(ctx, 540, 1300, '先判断,再治疗', { icon: 'check', color: O.COL.teal, alpha: win(u, 1.2, L[2].s + 0.1) }) const p = api.anchor('pain') O.reticle(ctx, p.x, p.y, 90, win(u, L[1].s - 0.1, L[3].s + 0.4), t, O.COL.red, lock) O.label(ctx, p.x, p.y, p.x - 240, p.y - 250, '疼痛位置', win(u, L[1].s + 0.4, L[3].s + 0.4), O.COL.red) // tendon trajectory (animated dashes along tendon path) const tr = win(u, L[2].s, L[3].s + 0.5) if (tr > 0.01) { const a1 = api.anchor('tendon'), a2 = api.anchor('pain'), a3 = api.anchor('thumbBase') ctx.save(); ctx.globalAlpha = tr; ctx.strokeStyle = O.COL.yellow; ctx.lineWidth = 7; ctx.setLineDash([22, 18]); ctx.lineDashOffset = -t * 120 ctx.shadowColor = O.COL.yellow; ctx.shadowBlur = 14 ctx.beginPath(); ctx.moveTo(a1.x, a1.y); ctx.quadraticCurveTo(a2.x, a2.y, a3.x, a3.y); ctx.stroke(); ctx.restore() O.chip(ctx, 540, 1300, '肌腱活动轨迹', { color: O.COL.yellow, alpha: tr, icon: 'dot' }) } O.ultrasound(ctx, 110, 470, 860, 620, t, { alpha: usK, thick: 0.85, labels: ramp(u, L[4].s, L[4].s + 1.2), narrowMark: ramp(u, L[4].s + 0.6, L[4].s + 1.0) }) }, } } /* ========================================================================= SHOT 6 · 常见治疗方法 (四宫格) ========================================================================= */ function shot6(u: number, d: number, t: number): Frame { const L = SL[5] const act = (k: number) => (u >= L[k].s - 0.1 && (k === 3 || u < L[k + 1].s - 0.1) ? 1 : 0) const prog = (k: number) => clamp((u - L[k].s) / (k === 3 ? d - L[k].s : L[k + 1].s - L[k].s)) const y0 = 0.16, ph = 0.3 const rects: [number, number, number, number][] = [[0, y0, 0.5, ph], [0.5, y0, 0.5, ph], [0, y0 + ph, 0.5, ph], [0.5, y0 + ph, 0.5, ph]] const cam = (dist: number, tgt: V3, yaw = 0, pitch = 0.1) => ({ pos: orbit(tgt, dist, yaw, pitch), target: tgt, fov: 35 }) // 1 减少抓握 const p0 = prog(0) const v0 = baseState({ time: t, pose: mixPose(POSE.grip, POSE.open, smooth(ramp(p0, 0.1, 0.5))), arm: { pos: [0, 0, 0], rot: [0, 0.5, 0] }, cam: cam(3.3, [0, 0.55, 0]), pain: 0.5 * (1 - p0) }) // 2 支具 const p1 = prog(1) const v1 = baseState({ time: t, pose: POSE.rest, arm: { pos: [0, 0, 0], rot: [0, 0.75 + 0.2 * Math.sin(t * 0.8), 0] }, cam: cam(3.5, [0, 0.2, 0]), props: { splint: { vis: smooth(ramp(p1, 0.05, 0.3)) }, splintThumb: { vis: smooth(ramp(p1, 0.05, 0.3)) }, splintPalm: { vis: smooth(ramp(p1, 0.05, 0.3)) } }, pain: 0.4 }) // 3 局部治疗示意 const p2 = prog(2) const v2 = baseState({ time: t, pose: POSE.rest, arm: { pos: [0, 0, 0], rot: [0, -0.35, 0] }, cam: painCam(1.5, 0.0, 0.1), skin: 1, xray: 0.9, bone: 1, tendon: 1, sheath: 1, thick: 0.9, focus: 0.7, pain: 0.8 * (1 - smooth(ramp(p2, 0.6, 1)) * 0.6), props: { syringe: { vis: smooth(ramp(p2, 0, 0.15)), depth: lerp(0.8, 0.12, smooth(ramp(p2, 0.05, 0.45))), push: smooth(ramp(p2, 0.5, 0.85)) }, cloud: { vis: smooth(ramp(p2, 0.5, 0.7)) * 0.8, r: 0.04 + 0.1 * smooth(ramp(p2, 0.5, 0.9)) } } }) // 4 手术松解示意 const p3 = prog(3) const rel = smooth(ramp(p3, 0.35, 0.75)) const v3 = baseState({ time: t, pose: thumbCycle(POSE.rest, t, 0.8, lerp(0.3, 1, rel)), arm: { pos: [0, 0, 0], rot: [0, 0.85, 0] }, cam: painCam(1.25, 0.05, 0.1), skin: 0.7, xray: 1, bone: 1, tendon: 1, sheath: 1, thick: 1 - 0.8 * rel, friction: 0.8 * (1 - rel), focus: 1, pain: 0.9 * (1 - rel * 0.8), props: { ring: { vis: win(p3, 0.35, 0.8, 0.05, 0.2), r: rel } } }) const states = [v0, v1, v2, v3] const titles = ['减少抓握', '支具固定', '局部治疗示意', '手术松解示意'] return { views: states.map((s, k) => ({ rect: rects[k], state: s, bg: 'lab' })), overlay: (ctx, api) => { ctx.save(); ctx.fillStyle = '#02060d'; ctx.fillRect(0, 0, 1080, y0 * 1920); ctx.fillRect(0, (y0 + 2 * ph) * 1920, 1080, 1920 - (y0 + 2 * ph) * 1920); ctx.restore() states.forEach((_, k) => { const [rx, ry, rw, rh] = rects[k] O.panel(ctx, rx * 1080, ry * 1920, rw * 1080, rh * 1920, titles[k], k + 1, act(k), act(k) ? 0 : (u >= L[k].s ? 0.35 : 0.7)) }) // panel 1: no-grip icon const a0 = act(0) ? 1 : 0.5 ctx.save(); ctx.globalAlpha = a0; ctx.translate(430, 420); ctx.strokeStyle = O.COL.red; ctx.lineWidth = 7 ctx.beginPath(); ctx.arc(0, 0, 52, 0, 7); ctx.stroke(); ctx.beginPath(); ctx.moveTo(-36, -36); ctx.lineTo(36, 36); ctx.stroke(); ctx.restore() O.actionIcon(ctx, 'grab', 430, 420, 60, '#fff', a0) // panel 4 incision dashed line (on sheath) if (act(3)) { const s = api.anchor('pain', 3) ctx.save(); ctx.strokeStyle = '#fff'; ctx.globalAlpha = win(p3, 0.15, 0.6, 0.1, 0.1); ctx.lineWidth = 4; ctx.setLineDash([12, 10]) ctx.beginPath(); ctx.moveTo(s.x - 10, s.y - 120); ctx.lineTo(s.x + 10, s.y + 120); ctx.stroke(); ctx.restore() } O.chip(ctx, 540, 1528 - 1528 + 245, '常见处理方式 · 需医生评估', { size: 36, color: O.COL.cyan, alpha: ramp(u, 0, 0.4) }) }, } } /* ========================================================================= SHOT 7 · 重点进入小针刀 ========================================================================= */ function shot7(u: number, d: number, t: number): Frame { const L = SL[6] const depth = u < L[3].s ? lerp(1.7, 0.72, smooth(ramp(u, 0.2, L[1].e))) : lerp(0.72, 0.1, smooth(ramp(u, L[3].s, d - 0.2))) const state = baseState({ time: t, pose: POSE.rest, arm: { pos: [0, 0, 0], rot: [0.12, -0.3, 0.0] }, cam: painCam(lerp(2.6, 1.35, smooth(ramp(u, 0, L[2].s))), 0.1, 0.06), skin: 1, xray: lerp(0.55, 1, smooth(ramp(u, L[2].s - 0.4, L[2].s + 0.3))), bone: 1, tendon: 1, sheath: 1, thick: 1, friction: 0.5, pain: 0.85, focus: lerp(0.2, 0.8, smooth(ramp(u, L[2].s, L[2].s + 0.5))), props: { knife: { vis: smooth(ramp(u, 0.1, 0.5)), depth } }, }) return { views: full(state), overlay: (ctx, api) => { O.bigWord(ctx, '小针刀在做什么?', 540, 380, win(u, 0.2, L[1].s, 0.3, 0.3), '#ffffff', 70) O.chip(ctx, 540, 1250, '扎一下就结束', { icon: 'cross', color: O.COL.red, alpha: win(u, L[1].s, L[2].s + 0.2), strike: ramp(u, L[1].s + 0.4, L[1].e) }) const n = api.anchor('narrow') O.reticle(ctx, n.x, n.y, 80, win(u, L[2].s, d + 1, 0.3, 0.3), t, O.COL.red, smooth(ramp(u, L[2].s, L[2].s + 0.6))) O.label(ctx, n.x, n.y, n.x + 210, n.y + 230, '狭窄位置', ramp(u, L[2].s + 0.6, L[2].s + 1.0), O.COL.red) const k = api.anchor('knife') O.label(ctx, k.x, k.y, k.x - 40, k.y - 170, '原创器械示意', win(u, 0.8, L[2].s), O.COL.teal, 34) O.chip(ctx, 540, 1300, '精准松解', { icon: 'check', color: O.COL.teal, alpha: ramp(u, L[3].s + 0.8, L[3].s + 1.2), size: 52 }) }, } } /* ========================================================================= SHOT 8 · 小针刀治疗过程 ========================================================================= */ function shot8(u: number, d: number, t: number): Frame { const L = SL[7] const step = u < L[1].s - 0.1 ? 0 : u < L[2].s - 0.1 ? 1 : u < L[3].s - 0.1 ? 2 : 3 const sL = [L[0].s, L[1].s, L[2].s, L[3].s] const loc = win(u, 0, L[1].s - 0.05, 0.2, 0.25) const dis = win(u, L[1].s - 0.1, L[2].s, 0.2, 0.25) const ane = win(u, L[2].s - 0.1, L[3].s, 0.2, 0.3) const fin = smooth(ramp(u, L[3].s - 0.2, L[3].s + 0.4)) const relK = smooth(ramp(u, L[5].s, L[5].s + 0.9)) const xray = step === 0 ? 0.6 : step === 1 ? lerp(0.6, 0.12, smooth(ramp(u, L[1].s - 0.1, L[1].s + 0.3))) : step === 2 ? lerp(0.12, 0.5, smooth(ramp(u, L[2].s, L[2].s + 0.4))) : lerp(0.5, 1, fin) const kDepth = lerp(0.9, 0.0, smooth(ramp(u, L[3].s + 0.3, L[4].e))) const state = baseState({ time: t, pose: POSE.rest, arm: { pos: [0, 0, 0], rot: [0.12, -0.3, 0.0] }, cam: painCam(step === 3 ? lerp(1.6, 1.2, fin) : 1.6, 0.1, 0.06), skin: 1, xray, bone: 1, tendon: 1, sheath: 1, thick: 1 - 0.7 * relK, friction: 0.5 * (1 - relK), pain: 0.85 - 0.6 * relK, focus: 0.75, slide: relK * Math.sin(t * 5) * 0.03, props: { probe: { vis: Math.max(loc, 0), lift: 0 }, locator: { vis: Math.max(loc, step === 3 ? win(u, L[3].s, L[4].e, 0.2, 0.3) * 0.8 : 0) }, swab: { vis: dis, r: 0.03 + 0.12 * smooth(ramp(u, L[1].s, L[2].s - 0.2)) }, iodine: { vis: Math.max(dis, step >= 2 ? 0.55 * (1 - fin) : 0), r: 0.04 + 0.26 * smooth(ramp(u, L[1].s, L[2].s - 0.2)) }, syringe: { vis: ane, depth: lerp(0.7, 0.13, smooth(ramp(u, L[2].s, L[2].s + 0.7))), push: smooth(ramp(u, L[2].s + 0.7, L[3].s - 0.3)) }, cloud: { vis: ane * smooth(ramp(u, L[2].s + 0.7, L[2].s + 1.1)) * 0.8, r: 0.03 + 0.1 * smooth(ramp(u, L[2].s + 0.7, L[3].s - 0.2)) }, knife: { vis: fin, depth: kDepth, release: relK * (1 - ramp(u, d - 0.3, d)) }, ring: { vis: relK > 0.01 ? (0.5 + 0.5 * Math.sin(t * 14)) * win(u, L[5].s, d, 0.1, 0.3) : 0, r: relK }, }, }) return { views: full(state), overlay: (ctx, api) => { const active = step + (step < 3 ? smooth(ramp(u, sL[step + 1] - 0.4, sL[step + 1])) * 0 : 0) O.steps(ctx, ['定位', '消毒', '麻醉', '松解'], active, 1, 330) O.ultrasound(ctx, 190, 520, 700, 400, t, { alpha: loc, thick: 1, locator: ramp(u, 0.2, 0.5), title: '超声定位 · 示意' }) const us2 = win(u, L[3].s + 0.3, d, 0.3, 0.3) O.ultrasound(ctx, 620, 520, 400, 240, t, { alpha: us2, thick: 1 - 0.7 * relK, knife: smooth(ramp(u, L[3].s + 0.3, L[4].e)), release: relK, title: '影像引导' }) const p = api.anchor('pain') if (step === 0) O.label(ctx, p.x, p.y, p.x - 250, p.y + 230, '目标区域', ramp(u, 0.4, 0.8), '#ffffff') if (step === 1) O.chip(ctx, 540, 1300, '皮肤消毒', { icon: 'check', color: '#e0a050', alpha: dis }) if (step === 2) O.chip(ctx, 540, 1300, '局部麻醉(必要时)', { icon: 'dot', color: '#59c8ff', alpha: ane }) if (step === 3) { const k = api.anchor('knife'), tn = api.anchor('tendon'), sh = api.anchor('sheath') const la = win(u, L[3].s + 0.6, L[5].s + 0.2, 0.3, 0.3) O.label(ctx, k.x, k.y, k.x - 60, k.y - 150, '器械位置', la, O.COL.teal, 36) O.label(ctx, tn.x, tn.y, tn.x + 200, tn.y + 150, '肌腱位置', la, '#ffffff', 36) O.label(ctx, sh.x, sh.y, sh.x - 200, sh.y + 300, '腱鞘位置', la, O.COL.cyan, 36) const ws = ['精准', '有限', '局部'] ws.forEach((w, i) => O.chip(ctx, 290 + i * 250, 1330, w, { color: O.COL.teal, fill: true, size: 50, alpha: ramp(u, L[5].s + 0.1 + i * 0.25, L[5].s + 0.35 + i * 0.25), scale: 0.8 + 0.2 * easeOut(ramp(u, L[5].s + i * 0.25, L[5].s + 0.4 + i * 0.25)) })) if (relK > 0) O.flash(ctx, 0.25 * win(u, L[5].s, L[5].s + 0.3, 0.05, 0.25), '200,255,240') } }, } } /* ========================================================================= SHOT 9 · 松解前后对比 ========================================================================= */ function shot9(u: number, d: number, t: number): Frame { const L = SL[8] const after = smooth(ramp(u, L[1].s, L[2].e)) const base = { arm: { pos: [0, 0, 0] as V3, rot: [0, 0.85, 0.04] as V3 }, cam: painCam(1.35, 0.05, 0.1, 40), skin: 0.7, xray: 1, bone: 1, tendon: 1, sheath: 1, focus: 1, time: t } const left = baseState({ ...base, pose: thumbCycle(POSE.rest, u, 1.0, 0.45, 1), thick: 1, friction: 0.95, pain: 0.95 }) const right = baseState({ ...base, pose: thumbCycle(POSE.rest, u, 1.0, lerp(0.45, 1, after), 1 - after), thick: lerp(1, 0.12, after), friction: lerp(0.95, 0, after), pain: lerp(0.95, 0.08, after) }) const y0 = 0.15, h = 0.62 return { views: [{ rect: [0, y0, 0.5, h], state: left, bg: 'red' }, { rect: [0.5, y0, 0.5, h], state: right, bg: after > 0.5 ? 'clean' : 'lab' }], overlay: (ctx) => { ctx.save(); ctx.fillStyle = '#02060d'; ctx.fillRect(0, 0, 1080, y0 * 1920); ctx.fillRect(0, (y0 + h) * 1920, 1080, 1920); ctx.restore() const yT = y0 * 1920 + 60 const lOn = u < L[1].s ? 1 : 0.55, rOn = u >= L[1].s - 0.1 ? 1 : 0.5 O.chip(ctx, 270, yT, '治疗前', { color: O.COL.red, size: 48, alpha: lOn, fill: lOn > 0.9 }) O.chip(ctx, 810, yT, '治疗后示意', { color: O.COL.teal, size: 48, alpha: rOn, fill: rOn > 0.9 }) ctx.save(); const g = ctx.createLinearGradient(0, y0 * 1920, 0, (y0 + h) * 1920); g.addColorStop(0, 'rgba(95,212,255,0)'); g.addColorStop(0.5, 'rgba(95,212,255,0.9)'); g.addColorStop(1, 'rgba(95,212,255,0)') ctx.fillStyle = g; ctx.fillRect(538, y0 * 1920, 4, h * 1920); ctx.restore() const yb = (y0 + h) * 1920 - 70 O.chip(ctx, 160, yb, '受限', { color: O.COL.red, size: 38, icon: 'cross', alpha: ramp(u, 0.6, 1.0) }) O.chip(ctx, 390, yb, '摩擦', { color: O.COL.red, size: 38, icon: 'warn', alpha: ramp(u, 1.0, 1.4) }) O.chip(ctx, 690, yb, '空间改善', { color: O.COL.teal, size: 38, icon: 'check', alpha: ramp(u, L[1].s + 0.3, L[1].s + 0.7) }) O.chip(ctx, 940, yb, '更顺畅', { color: O.COL.teal, size: 38, icon: 'check', alpha: ramp(u, L[2].s, L[2].s + 0.4) }) O.crossSection(ctx, 270, 1330, 80, 1, 0.95, 0.9, t, '狭窄') O.crossSection(ctx, 810, 1330, 80, lerp(1, 0.12, after), lerp(0.95, 0, after), 0.9, t, '改善') }, } } /* ========================================================================= SHOT 10 · 从疼痛到恢复 ========================================================================= */ function shot10(u: number, d: number, t: number): Frame { const n = 6, seg = d / n const i = Math.min(n - 1, Math.floor(u / seg)), v = u - i * seg const labels = ['治疗前:活动困难', '早期:轻微活动', '正常抓握', '正常拿杯子', '正常使用手机', '自然活动'] const pains = [1, 0.7, 0.45, 0.25, 0.1, 0] const pain = lerp(pains[i], pains[Math.min(n - 1, i + 1)], smooth(ramp(v, seg * 0.6, seg))) let pose: Pose, props: SceneState['props'] = {} let rot: V3 = [0, 0.6, 0.04] let cam = { pos: orbit([0, 0.5, 0], 4.3, 0.05, 0.1), target: [0, 0.5, 0] as V3, fov: 35 } const blendIn = smooth(ramp(v, 0, 0.35)) if (i === 0) pose = thumbCycle(POSE.rest, v, 1.4, 0.3, 1) else if (i === 1) pose = thumbCycle(POSE.rest, v, 0.7, 0.6) else if (i === 2) { pose = mixPose(POSE.open, POSE.gripBall, blendIn * (0.85 + 0.15 * Math.sin(v * 6))); props = { ball: { vis: blendIn } } } else if (i === 3) { pose = mixPose(POSE.open, POSE.cup, blendIn); props = { cup: { vis: blendIn } }; rot = [0, 0.5, 0.04 + 0.15 * smooth(ramp(v, 0.4, 1))] } else if (i === 4) { pose = mixPose(POSE.open, POSE.phone, blendIn); pose[17] += 0.25 * Math.max(0, Math.sin(v * 12)); pose[19] += 0.15 * Math.max(0, Math.sin(v * 12)); props = { phone: { vis: blendIn } }; rot = [0.1, -0.25, 0]; cam = { pos: orbit([0, 0.6, 0], 4.1, 0.0, 0.12), target: [0, 0.6, 0], fov: 35 } } else { pose = mixPose(POSE.open, POSE.rest, 0.5 + 0.5 * Math.sin(v * 5)); rot = [0, 0.4 + 0.3 * Math.sin(v * 2), 0.04] } const state = baseState({ time: t, pose, props, arm: { pos: [0, 0, 0], rot }, cam, pain, skin: 1 }) return { views: full(state, i >= 3 ? 'clean' : 'lab'), overlay: (ctx, api) => { if (v < 0.12 && i > 0) O.flash(ctx, 0.18 * (1 - v / 0.12)) // progress track const y = 1300, x0 = 140, x1 = 940 ctx.save() const g = ctx.createLinearGradient(x0, 0, x1, 0); g.addColorStop(0, O.COL.red); g.addColorStop(0.5, O.COL.yellow); g.addColorStop(1, O.COL.green) ctx.strokeStyle = 'rgba(255,255,255,0.18)'; ctx.lineWidth = 10; ctx.lineCap = 'round'; ctx.beginPath(); ctx.moveTo(x0, y); ctx.lineTo(x1, y); ctx.stroke() ctx.strokeStyle = g; ctx.beginPath(); ctx.moveTo(x0, y); ctx.lineTo(x0 + (x1 - x0) * clamp(u / d), y); ctx.stroke() for (let k = 0; k < n; k++) { const x = x0 + (x1 - x0) * k / (n - 1) ctx.fillStyle = k <= i ? '#fff' : 'rgba(255,255,255,0.3)'; ctx.beginPath(); ctx.arc(x, y, k === i ? 16 : 10, 0, 7); ctx.fill() } ctx.restore() O.chip(ctx, 540, 1200, labels[i], { color: i === 0 ? O.COL.red : i < 2 ? O.COL.yellow : O.COL.green, size: 50, alpha: smooth(ramp(v, 0, 0.2)), icon: i === 0 ? 'warn' : 'check' }) if (pain > 0.3) { const p = api.anchor('pain'); O.painBurst(ctx, p.x, p.y, (pain - 0.3) * 0.6, t) } ctx.save(); ctx.font = `800 30px ${O.FONT}`; ctx.fillStyle = 'rgba(255,255,255,0.7)'; ctx.textAlign = 'left'; ctx.fillText('疼痛', x0 - 10, y - 34); ctx.textAlign = 'right'; ctx.fillText('功能恢复', x1 + 10, y - 34); ctx.restore() }, } } /* ========================================================================= SHOT 11 · 最后的记忆点 ========================================================================= */ function shot11(u: number, d: number, t: number): Frame { const L = SL[10] const out = smooth(ramp(u, L[1].s + 0.6, d - 0.3)) const pc = painCam(lerp(1.4, 5.4, out), lerp(0.05, 0.0, out), 0.1) const wide = { pos: orbit([0, 0.45, 0], 5.4, 0.0, 0.1), target: [0, 0.45, 0] as V3, fov: 35 } const fol = 1 - out const state = baseState({ time: t, pose: mixPose(POSE.rest, POSE.open, out * 0.5), arm: { pos: [0, 0, 0], rot: [0, lerp(0.85, 0.35, out), 0.04] }, cam: { ...pc, follow: fol, pos: lerpVec(wide.pos, pc.pos, fol), target: lerpVec(wide.target, pc.target, fol) }, skin: 1, xray: 1 - smooth(ramp(u, L[2].s, d - 0.5)), bone: 1, tendon: 1, sheath: 1, thick: lerp(0.7, 0.1, smooth(ramp(u, L[1].s, L[2].s))), pain: 0.75 * (1 - smooth(ramp(u, L[1].s, L[3].s))), focus: 0.4 * (1 - out), }) return { views: full(state, u > L[2].s ? 'clean' : 'lab'), overlay: (ctx) => { O.chip(ctx, 540, 1180, '忍住疼', { icon: 'cross', color: O.COL.red, size: 54, alpha: win(u, 0.3, L[1].s + 0.1), strike: ramp(u, L[0].s + 1.4, L[0].e) }) O.chip(ctx, 540, 1110, '找到疼痛原因', { icon: 'check', color: O.COL.teal, size: 50, alpha: win(u, L[1].s + 0.1, L[2].s + 0.1) }) O.chip(ctx, 540, 1240, '选择合适方式', { icon: 'check', color: O.COL.teal, size: 50, alpha: win(u, L[1].s + 1.1, L[2].s + 0.1) }) // slogan const sA = ramp(u, L[3].s - 0.1, L[3].s + 0.4) if (sA > 0) { ctx.save() ctx.globalAlpha = sA * 0.75; ctx.fillStyle = 'rgba(2,8,18,0.7)'; ctx.fillRect(0, 1000, 1080, 330); ctx.restore() O.bigWord(ctx, '手越疼,', 540, 1090, sA, '#ffffff', 96) O.bigWord(ctx, '越不能只靠硬扛。', 540, 1230, ramp(u, L[3].s + 0.35, L[3].s + 0.8), O.COL.yellow, 96) } O.chip(ctx, 540, 960, '记住一句话', { size: 40, color: O.COL.cyan, alpha: win(u, L[2].s, d + 1, 0.2, 0.2) }) }, } } /* ========================================================================= SHOT 12 · 评论区互动 ========================================================================= */ function shot12(u: number, d: number, t: number): Frame { const state = baseState({ time: t, pose: mixPose(POSE.open, POSE.rest, 0.5 + 0.5 * Math.sin(u * 2.4)), arm: { pos: [0, 0.15, 0], rot: [0, 0.35 + 0.2 * Math.sin(u * 1.5), 0.04 + 0.08 * Math.sin(u * 3)] }, cam: { pos: orbit([0, 0.7, 0], 6.2, 0, 0.1), target: [0, 0.2, 0], fov: 35 }, }) const items = [['grab', '抓'], ['pinch', '捏'], ['twist', '拧'], ['baby', '抱孩子']] as const return { views: full(state, 'clean'), fade: smooth(ramp(u, d - 0.5, d)), overlay: (ctx) => { const a = smooth(ramp(u, 0.1, 0.5)) ctx.save(); ctx.globalAlpha = a ctx.fillStyle = 'rgba(3,12,26,0.86)'; ctx.strokeStyle = O.COL.cyan; ctx.lineWidth = 3 ctx.beginPath(); ctx.roundRect(70, 980, 940, 430, 36); ctx.fill(); ctx.stroke() ctx.font = `900 50px ${O.FONT}`; ctx.fillStyle = '#fff'; ctx.textAlign = 'center' ctx.fillText('评论区告诉我', 540, 1060) ctx.font = `800 40px ${O.FONT}`; ctx.fillStyle = '#bfe9ff' ctx.fillText('是抓、捏、拧,还是抱孩子?', 540, 1125) ctx.restore() items.forEach(([ic, w], k) => { const p = ramp(u, 0.6 + k * 0.25, 1.0 + k * 0.25) const x = 200 + k * 227, y = 1265 const bob = Math.sin(t * 4 + k) * 6 ctx.save(); ctx.globalAlpha = p; ctx.translate(x, y + bob); const sc = 0.6 + 0.4 * easeOut(p); ctx.scale(sc, sc) ctx.fillStyle = 'rgba(95,212,255,0.12)'; ctx.strokeStyle = O.COL.cyan; ctx.lineWidth = 3 ctx.beginPath(); ctx.arc(0, 0, 82, 0, 7); ctx.fill(); ctx.stroke(); ctx.restore() O.actionIcon(ctx, ic, x, y - 16 + bob, 70, '#fff', p) ctx.save(); ctx.globalAlpha = p; ctx.font = `900 34px ${O.FONT}`; ctx.fillStyle = '#fff'; ctx.textAlign = 'center'; ctx.fillText(w, x, y + 50 + bob); ctx.restore() }) O.actionIcon(ctx, 'comment', 540, 880, 120 * (1 + 0.06 * Math.sin(t * 6)), O.COL.yellow, a) }, } } const SHOT_FNS = [shot1, shot2, shot3, shot4, shot5, shot6, shot7, shot8, shot9, shot10, shot11, shot12] export function shotAt(t: number) { let i = SHOTS.length - 1 for (let k = 0; k < SHOTS.length; k++) if (t < SHOTS[k].end) { i = k; break } return i } export function subtitleAt(t: number) { let cur: (typeof LINES)[number] | null = null for (let k = 0; k < LINES.length; k++) { const l = LINES[k] const next = LINES[k + 1] const hold = next ? Math.min(next.start, l.start + l.dur + 0.5) : l.start + l.dur + 0.8 if (t >= l.start - 0.05 && t < hold) cur = l } return cur } /** The whole film: pure function of time */ export function frameAt(t: number): Frame { t = clamp(t, 0, DURATION - 1e-4) const i = shotAt(t) const sh = SHOTS[i] const u = t - sh.start, d = sh.end - sh.start const f = SHOT_FNS[i](u, d, t) const inner = f.overlay const sub = subtitleAt(t) f.overlay = (ctx: C, api: OverlayApi) => { inner?.(ctx, api) // transition flash at shot start if (i > 0 && u < 0.2) O.flash(ctx, 0.22 * (1 - u / 0.2), '180,230,255') O.header(ctx, i + 1, sh.title, i === 0 ? win(u, 0.8, 3.2, 0.4, 0.5) : win(u, 0.05, 2.4, 0.3, 0.5)) if (sub) { const a = Math.min(smooth(ramp(t, sub.start - 0.05, sub.start + 0.12)), 1) O.subtitle(ctx, sub.text, a) } O.footer(ctx) } return f } /* ---------------- sound cue list (for the procedural mix) ---------------- */ export type CueKind = 'whoosh' | 'pain' | 'tick' | 'scan' | 'pop' | 'release' | 'chime' | 'thud' | 'click' | 'swipe' export function soundCues(): { t: number; k: CueKind; v?: number }[] { const c: { t: number; k: CueKind; v?: number }[] = [] const S = (i: number) => SHOTS[i].start const Ls = (i: number, j: number) => S(i) + SL[i][j].s SHOTS.forEach((s, i) => { if (i > 0) c.push({ t: s.start - 0.15, k: 'whoosh', v: 0.6 }) }) // shot 1 c.push({ t: Ls(0, 0) + 1.05, k: 'pain', v: 1 }, { t: Ls(0, 1) - 0.2, k: 'whoosh', v: 0.5 }, { t: Ls(0, 1) + 0.7, k: 'scan', v: 0.6 }) c.push({ t: Ls(0, 1) + 1.2, k: 'tick' }, { t: Ls(0, 1) + 1.45, k: 'tick' }, { t: Ls(0, 1) + 1.7, k: 'tick' }) // shot 2 for (const j of [3, 4, 5]) c.push({ t: Ls(1, j) + 0.1, k: 'pop', v: 0.7 }) c.push({ t: Ls(1, 5) + 0.1, k: 'pain', v: 0.6 }) // shot 3 const d3 = SHOTS[2].end - S(2) for (let k = 0; k < 3; k++) { c.push({ t: S(2) + k * d3 / 3 + 0.05, k: 'thud', v: 0.6 + k * 0.2 }); c.push({ t: S(2) + k * d3 / 3 + 0.35, k: 'pain', v: 0.4 + k * 0.25 }) } // shot 4 const s0 = S(3) + SL[3][1].s - 0.05, seg4 = (SHOTS[3].end - s0) / 4 for (let k = 0; k < 4; k++) { c.push({ t: s0 + k * seg4, k: 'swipe', v: 0.7 }); c.push({ t: s0 + k * seg4 + 0.45, k: 'pain', v: 0.35 }) } // shot 5 c.push({ t: Ls(4, 1), k: 'click', v: 0.8 }, { t: Ls(4, 3) + 0.5, k: 'scan', v: 0.8 }, { t: Ls(4, 4), k: 'tick' }, { t: Ls(4, 4) + 0.3, k: 'tick' }, { t: Ls(4, 4) + 0.6, k: 'tick' }) // shot 6 for (let j = 0; j < 4; j++) c.push({ t: Ls(5, j) - 0.1, k: 'click', v: 0.7 }) // shot 7 c.push({ t: Ls(6, 1) + 0.4, k: 'pop', v: 0.6 }, { t: Ls(6, 2), k: 'click', v: 0.8 }, { t: Ls(6, 3) + 0.9, k: 'chime', v: 0.5 }) // shot 8 for (let j = 0; j < 4; j++) c.push({ t: S(7) + SL[7][[0, 1, 2, 3][j]].s, k: 'click', v: 0.9 }) c.push({ t: Ls(7, 0) + 0.2, k: 'scan', v: 0.7 }, { t: Ls(7, 5), k: 'release', v: 1 }) // shot 9 c.push({ t: Ls(8, 1) + 0.3, k: 'release', v: 0.6 }) // shot 10 const d10 = SHOTS[9].end - S(9) for (let k = 1; k < 6; k++) c.push({ t: S(9) + k * d10 / 6, k: 'tick', v: 0.8 }) // shot 11 c.push({ t: Ls(10, 0) + 1.4, k: 'pop', v: 0.6 }, { t: Ls(10, 3), k: 'chime', v: 1 }) // ending for (let k = 0; k < 4; k++) c.push({ t: S(11) + 0.6 + k * 0.25, k: 'pop', v: 0.5 }) return c.sort((a, b) => a.t - b.t) }