Files
dal4segno 0ccf57d535
Publish GitHub Pages output / publish (push) Successful in 49s
feat: improve chart navigation and show original measure numbers
2026-10-04 23:05:57 +09:00

204 lines
11 KiB
JavaScript

const alphabet = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
function charValue(character) {
const value = alphabet.indexOf(character);
if (value < 0) throw new Error(`잘못된 SUS base62 값: ${character}`);
return value;
}
function parseCells(data, includeZero = false) {
const cells = [];
let position = 0;
for (let index = 0; index < data.length;) {
if (data.startsWith('0x', index)) {
position += charValue(data[index + 2]) * 62 + charValue(data[index + 3]);
index += 4;
} else {
const cell = data.slice(index, index + 2);
if (cell.length !== 2) throw new Error('잘린 SUS 셀입니다.');
if (includeZero ? cell !== '00' : cell[0] !== '0') cells.push({ position, cell });
position += 1;
index += 2;
}
}
return { cells, total: position };
}
function parseHeaders(text) {
const headers = new Map();
for (const raw of text.split(/\r?\n/)) {
const match = raw.match(/^#([A-Z_]+)\s+(.+)$/);
if (match) headers.set(match[1], match[2].trim().replaceAll('"', ''));
}
return headers;
}
function measureStarts(lengths) {
const starts = [];
let beat = 0;
for (let measure = 0; measure < 1000; measure += 1) {
starts[measure] = beat;
beat += lengths.get(measure) ?? 4;
}
return starts;
}
export function parseSusTimeline(text) {
const headers = parseHeaders(text);
const lengths = new Map();
const bpmDefinitions = new Map();
const lines = text.split(/\r?\n/).map((raw) => raw.trim());
for (const line of lines) {
const bar = line.match(/^#(\d{3})02:(.+)$/);
if (bar) lengths.set(Number(bar[1]), Number(bar[2]));
const bpm = line.match(/^#BPM(..):(.+)$/);
if (bpm) bpmDefinitions.set(bpm[1], Number(bpm[2]));
}
// A measure-length change persists until the next directive.
let currentLength = 4;
for (let measure = 0; measure < 1000; measure += 1) {
currentLength = lengths.get(measure) ?? currentLength;
lengths.set(measure, currentLength);
}
const starts = measureStarts(lengths);
const toBeat = (measure, position, total) => starts[measure] + (lengths.get(measure) ?? 4) * position / total;
const bpmEvents = [];
const taps = [];
const flicks = [];
const holdStreams = new Map();
const visibleRelays = [];
const specialStarts = [];
for (const line of lines) {
const holdMatch = line.match(/^#(\d{3})3([0-9a-z])([0-9a-zA-Z]):(.+)$/);
if (holdMatch) {
const [, measureText, lane, channel, data] = holdMatch;
const measure = Number(measureText);
const parsed = parseCells(data);
const notes = parsed.cells.map(({ position, cell }) => ({ beat: toBeat(measure, position, parsed.total), lane: charValue(lane), kind: charValue(cell[0]), width: charValue(cell[1]) }));
visibleRelays.push(...notes.filter((note) => note.kind === 3));
const stream = charValue(channel);
if (!holdStreams.has(stream)) holdStreams.set(stream, []);
holdStreams.get(stream).push(...notes);
continue;
}
const match = line.match(/^#(\d{3})([0-9a-z])([0-9a-zA-Z]):(.+)$/);
if (!match) continue;
const [, measureText, family, lane, data] = match;
const measure = Number(measureText);
if (`${family}${lane}` === '08') {
for (const { position, cell } of parseCells(data, true).cells) bpmEvents.push({ beat: toBeat(measure, position, parseCells(data, true).total), bpm: bpmDefinitions.get(cell) ?? Number(headers.get('BASEBPM') ?? 120) });
continue;
}
if (`${family}${lane}` === '0B') {
for (const { position } of parseCells(data, true).cells) specialStarts.push(toBeat(measure, position, parseCells(data, true).total));
continue;
}
if (!['1', '5'].includes(family)) continue;
const parsed = parseCells(data);
const notes = parsed.cells.map(({ position, cell }) => ({ beat: toBeat(measure, position, parsed.total), lane: charValue(lane), kind: charValue(cell[0]), width: charValue(cell[1]) }));
if (family === '1') taps.push(...notes);
else if (family === '5') flicks.push(...notes);
}
if (bpmEvents.length === 0) bpmEvents.push({ beat: 0, bpm: Number(headers.get('BASEBPM') ?? 120) });
bpmEvents.sort((left, right) => left.beat - right.beat);
const uniqueBpmEvents = bpmEvents.filter((event, index) => index === 0 || event.beat !== bpmEvents[index - 1].beat);
function secondsAt(beat) {
const waveOffset = Number(headers.get('WAVEOFFSET') ?? 0);
if (beat < uniqueBpmEvents[0].beat) return beat * 60 / Number(headers.get('BASEBPM') ?? 120) + waveOffset;
let seconds = 0;
let previous = uniqueBpmEvents[0];
if (previous.beat > 0) seconds += previous.beat * 60 / Number(headers.get('BASEBPM') ?? 120);
for (let index = 1; index < uniqueBpmEvents.length && uniqueBpmEvents[index].beat <= beat; index += 1) {
const event = uniqueBpmEvents[index];
seconds += (event.beat - previous.beat) * 60 / previous.bpm;
previous = event;
}
seconds += (beat - previous.beat) * 60 / previous.bpm;
return seconds + waveOffset;
}
const key = (note) => `${note.beat}|${note.lane}`;
const tapKeys = new Set(taps.filter((note) => note.kind === 1 || note.kind === 2).map(key));
const standaloneFlickKeys = new Set(flicks.filter((note) => [1, 3, 4].includes(note.kind)).map(key));
const holds = [];
for (const stream of holdStreams.values()) {
const deduped = [...new Map(stream.sort((left, right) => left.beat - right.beat).map((note) => [`${note.beat}|${note.lane}|${note.kind}|${note.width}`, note])).values()];
const open = [];
const relays = [];
for (const note of deduped) {
if (note.kind === 1) open.push(note);
else if (note.kind === 2) {
const index = open.findIndex((start) => start.beat <= note.beat);
if (index < 0) continue;
const start = open.splice(index, 1)[0];
const inner = relays.filter((relay) => start.beat <= relay.beat && relay.beat <= note.beat);
for (const relay of inner) relays.splice(relays.indexOf(relay), 1);
holds.push([start, ...inner.sort((left, right) => left.beat - right.beat), note]);
} else relays.push(note);
}
}
const holdStartKeys = new Set(holds.map((hold) => key(hold[0])));
const holdEndKeys = new Set(holds.map((hold) => key(hold.at(-1))));
const notes = [];
// A co-located directional is one flick. A hold end directional is part of the hold and scores at normal weight.
for (const tap of taps) {
if (![1, 2].includes(tap.kind) || holdStartKeys.has(key(tap)) || holdEndKeys.has(key(tap))) continue;
notes.push({ timeSeconds: secondsAt(tap.beat), type: 'regular', isFlick: standaloneFlickKeys.has(key(tap)) });
}
for (const flick of flicks) {
if (![1, 3, 4].includes(flick.kind) || tapKeys.has(key(flick)) || holdStartKeys.has(key(flick)) || holdEndKeys.has(key(flick))) continue;
notes.push({ timeSeconds: secondsAt(flick.beat), type: 'regular', isFlick: true });
}
for (const hold of holds) {
const start = hold[0];
const end = hold.at(-1);
notes.push({ timeSeconds: secondsAt(start.beat), type: 'regular', isFlick: false });
// The game creates a continuation judgement every half beat. A visible relay at the same
// position remains a separate score judgement.
// Continuations lie on the song's half-beat grid, not half a beat after the hold head.
for (let beat = (Math.floor(start.beat * 2 + 1e-9) + 1) / 2; beat < end.beat - 1e-9; beat += 0.5) {
notes.push({ timeSeconds: secondsAt(beat), type: 'mid', isFlick: false });
}
notes.push({ timeSeconds: secondsAt(end.beat), type: 'regular', isFlick: false, isHoldEnd: true });
}
// Visible relay ticks are score judgements even if the editor stream contains an unmatched visual segment.
for (const relay of visibleRelays) notes.push({ timeSeconds: secondsAt(relay.beat), type: 'mid', isFlick: false });
notes.sort((left, right) => left.timeSeconds - right.timeSeconds || (left.type === 'mid' ? 1 : -1));
const expectedRegular = Number(headers.get('NORMAL_NOTE_COUNT')) + Number(headers.get('FLICK_NOTE_COUNT')) +
Number(headers.get('LONG_START_NOTE_COUNT')) + Number(headers.get('LONG_END_NOTE_COUNT')) + Number(headers.get('LONG_FLICK_END_NOTE_COUNT'));
const expectedMid = Number(headers.get('LONG_RELAY_NOTE_COUNT')) + Number(headers.get('LONG_CONTINUE_NOTE_COUNT'));
const regularCount = notes.filter((note) => note.type === 'regular').length;
const midCount = notes.length - regularCount;
const flickCount = notes.filter((note) => note.isFlick).length;
if (regularCount !== expectedRegular || midCount !== expectedMid || flickCount !== Number(headers.get('FLICK_NOTE_COUNT'))) {
throw new Error(`${headers.get('MUSIC_ID')}: SUS category mismatch: regular ${regularCount}/${expectedRegular}, mid ${midCount}/${expectedMid}, flick ${flickCount}/${headers.get('FLICK_NOTE_COUNT')}`);
}
notes.sort((left, right) => left.timeSeconds - right.timeSeconds || (left.type === 'mid' ? 1 : -1));
// Keep visual geometry separate from score judgements (invisible continuation ticks
// are not tap notes, and a hold-end flick still has regular score weight).
const directional = new Map(flicks.filter(note => [1, 3, 4].includes(note.kind)).map(note => [key(note), note.kind === 3 ? 'left' : note.kind === 4 ? 'right' : 'up']));
const criticals = new Set(taps.filter(note => [2, 6].includes(note.kind)).map(key));
const attached = new Set(taps.filter(note => note.kind === 3).map(key));
const point = note => ({ timeSeconds: secondsAt(note.beat), lane: note.lane - 2, width: note.width });
const visualNote = (note, kind) => ({ ...point(note), kind,
...(directional.has(key(note)) ? { direction: directional.get(key(note)) } : {}),
...(criticals.has(key(note)) ? { critical: true } : {}) });
const visualNotes = taps.filter(note => [1, 2].includes(note.kind) && !holdStartKeys.has(key(note)) && !holdEndKeys.has(key(note)))
.map(note => visualNote(note, directional.has(key(note)) ? 'flick' : 'tap'));
visualNotes.push(...flicks.filter(note => [1, 3, 4].includes(note.kind) && !tapKeys.has(key(note)) && !holdStartKeys.has(key(note)) && !holdEndKeys.has(key(note)))
.map(note => visualNote(note, 'flick')));
for (const hold of holds) {
visualNotes.push(visualNote(hold[0], 'hold'), visualNote(hold.at(-1), 'hold'));
}
visualNotes.push(...visibleRelays.map(note => visualNote(note, 'relay')));
const visualHolds = holds.map(hold => hold.filter((note, i) => i === 0 || i === hold.length - 1 || !attached.has(key(note)))
.map(note => ({ ...point(note), ...(note.kind === 4 ? { control: true } : {}) })));
const lastSourceMeasure = Math.max(0, ...lines.map(line => Number(line.match(/^#(\d{3})\w+:/)?.[1] ?? 0)));
const measureCount = Number(headers.get('MEASURE_COUNT') ?? lastSourceMeasure + 1);
if (!Number.isInteger(measureCount) || measureCount < 1 || measureCount >= starts.length) throw new Error('잘못된 SUS 마디 수입니다.');
// Keep the SUS's zero-based numbers, including silent bars and the end boundary.
const measures = Array.from({ length: measureCount + 1 }, (_, number) => ({ number, timeSeconds: secondsAt(starts[number]) }));
if (measures.some((measure, index) => !Number.isFinite(measure.timeSeconds) || (index > 0 && measure.timeSeconds <= measures[index - 1].timeSeconds))) throw new Error('잘못된 SUS 마디 시각입니다.');
return { songId: headers.get('MUSIC_ID'), notes, specialStartSeconds: specialStarts.sort((left, right) => left - right).map(secondsAt),
visual: { laneCount: 12, notes: visualNotes.sort((a, b) => a.timeSeconds - b.timeSeconds), holds: visualHolds, measures } };
}