148 lines
4.6 KiB
TypeScript
148 lines
4.6 KiB
TypeScript
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<string, NoteEvent>();
|
||
|
|
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);
|
||
|
|
}
|