import type {NoteEvent} from '../shared/types'; export const BALL_ENTRY_SECONDS = 0.18; /** Resting height above the paper, in score units: just enough to clear the ink. */ export const REST_DEPTH = 1.6; /** * Amplifies only the part of the height that comes from a hop, so the ball * still rests just above the ink and 0 flattens every jump onto the sheet. */ export function liftDepth(depth: number, lift: number): number { return REST_DEPTH + (depth - REST_DEPTH) * Math.max(0, lift); } export type Motion = { position: [number, number]; /** Height above the paper along its normal, in score units. */ depth: number; pulse: number; landed: boolean; opacity: number; from: NoteEvent; to: NoteEvent; u: number; trailOpacity: number; departing: boolean; }; const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)); export function groupLandings(notes: NoteEvent[], staff: number): NoteEvent[] { const groups: NoteEvent[][] = []; for (const note of notes .filter((n) => n.staff === staff) .sort((a, b) => a.time - b.time)) { const last = groups[groups.length - 1]; if (last && Math.abs(last[0]!.time - note.time) < 1e-6) last.push(note); else groups.push([note]); } const preferHigh = staff % 2 === 1; return groups.map( (group) => [...group].sort( (a, b) => (a.kind === 'attack' ? 0 : 1) - (b.kind === 'attack' ? 0 : 1) || (preferHigh ? b.pitch - a.pitch : a.pitch - b.pitch), )[0]!, ); } /** * Hop from note `a` to note `b`. X/Y travel across the sheet in a straight line * while Z arcs above it, so the ball lands back on the paper at every note. */ export function arcPoint( a: NoteEvent, b: NoteEvent, u: number, ): [number, number, number] { const phase = clamp(u, 0, 1); const distance = Math.hypot(b.x - a.x, b.y - a.y); const interval = Math.max(0, b.time - a.time); // Big enough that a hop still reads on a nearly face-on sheet, where a // movement along the paper normal mostly points at the camera. const hop = (22 + Math.min(22, distance * 0.15)) * Math.min(1, Math.max(0.25, interval / 0.24)); return [ a.x + (b.x - a.x) * phase, a.y + (b.y - a.y) * phase, REST_DEPTH + 4 * phase * (1 - phase) * hop, ]; } export function performanceAt(targets: NoteEvent[], t: number): Motion | null { if (!targets.length) return null; let i = 0; while (i + 1 < targets.length && targets[i + 1]!.time <= t) i++; const current = targets[i]!; const next = targets[i + 1]; const age = t - current.time; const landed = age >= 0; const resting = Boolean(next && next.time - current.time - current.duration > 0.65); const enteringNext = Boolean(resting && next && t >= next.time - BALL_ENTRY_SECONDS); const entryTarget = enteringNext ? next! : current; const entryPhase = clamp((t - entryTarget.time + BALL_ENTRY_SECONDS) / BALL_ENTRY_SECONDS, 0, 1); const departing = Boolean(next && landed && !resting); const u = departing ? clamp(age / Math.max(1e-6, next!.time - current.time), 0, 1) : 1; const [ballX, ballY, ballZ] = departing ? arcPoint(current, next!, u) : arcPoint(entryTarget, entryTarget, 1); const position: [number, number] = [ballX, ballY]; let opacity = 1; if (!landed || enteringNext) { opacity = Math.min(1, entryPhase * 2.5); } else if (resting || !next) { opacity = clamp((current.time + current.duration + 0.18 - t) / 0.18, 0, 1); } const pulse = landed && !enteringNext ? Math.exp(-Math.max(0, age) / 0.1) : 0; const from = departing ? current : targets[Math.max(0, i - 1)] ?? current; const to = departing ? next! : current; const trailOpacity = departing ? 0.68 : from.scoreId !== to.scoreId && i > 0 ? 0.48 * Math.max(0, 1 - age / 0.3) : 0; return { position, depth: ballZ, pulse, landed, opacity, from, to, u, trailOpacity, departing, }; } function lowerBound(notes: NoteEvent[], time: number): number { let low = 0; let high = notes.length; while (low < high) { const mid = (low + high) >> 1; if (notes[mid]!.time < time) low = mid + 1; else high = mid; } return low; } /** Latest ring per scoreId within the 3.6s window, matching the original behavior. */ export function activeRingsAt(notes: NoteEvent[], t: number): NoteEvent[] { const start = lowerBound(notes, t - 3.6); const latest = new Map(); for (let i = start; i < notes.length; i++) { const note = notes[i]!; if (note.time > t) break; latest.set(note.scoreId, note); } return [...latest.values()]; } export function staffColorIndex(staff: number): number { return Math.max(0, staff - 1); }