Files
f0ckm/src/inc/beatmap.mjs
T
2026-09-30 19:15:10 +02:00

343 lines
17 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import JSZip from 'jszip';
import db from './sql.mjs';
import { MAX_NOTES, MAX_HOLD_MS, cleanNotes, cleanTitle, insertMap } from './square_clicker.mjs';
// Beatmap import for Square Clicker: .osz sets (zip: audio, background, one .osu text file per
// difficulty) and loose .osu files, converted to Square Clicker notes { t, x, y, d, p? }.
//
// Standard and catch: circle / fruit -> tap, slider / juice stream -> slider (curve sampled, repeats
// become a path that goes back and forth, duration from length, timing and slider velocity),
// spinner / banana shower -> hold in the middle.
// Taiko: every object sits in the middle of the playfield, so positions are generated: a path that
// flows over the screen (don turns gently, kat turns sharply, distance grows with the time gap).
// Drumroll and denden -> hold.
// Mania: columns become lanes side by side, long notes holds; chords collapse to one note (one cursor).
const PF_W = 512, PF_H = 384; // playfield in osu pixels
// playfield -> viewport fractions, leaving room for the top bar and the edges
const toX = (x) => Math.max(0.02, Math.min(0.98, 0.1 + (x / PF_W) * 0.8));
const toY = (y) => Math.max(0.02, Math.min(0.98, 0.14 + (y / PF_H) * 0.74));
const MAX_OSU_BYTES = 4 * 1024 * 1024; // one .osu file
const MAX_DIFFS = 40;
// ── .osu text -> sections ─────────────────────────────────────────────────────
export const parseOsu = (text) => {
const out = { General: {}, Metadata: {}, Difficulty: {}, Events: [], TimingPoints: [], HitObjects: [] };
let sec = null;
for (let line of String(text).replace(/^/, '').split(/\r?\n/)) {
line = line.trim();
if (!line || line.startsWith('//')) continue;
const m = /^\[(\w+)\]$/.exec(line);
if (m) { sec = m[1]; continue; }
if (!sec) continue;
if (sec === 'General' || sec === 'Metadata' || sec === 'Difficulty') {
const i = line.indexOf(':');
if (i > 0) out[sec][line.slice(0, i).trim()] = line.slice(i + 1).trim();
} else if (Array.isArray(out[sec])) {
out[sec].push(line);
}
}
return out;
};
// ── curves ────────────────────────────────────────────────────────────────────
const dist = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1]);
const lerp = (a, b, t) => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
const bezier = (pts) => {
let len = 0;
for (let i = 1; i < pts.length; i++) len += dist(pts[i - 1], pts[i]);
const n = Math.max(2, Math.min(200, Math.ceil(len / 4)));
const out = [];
for (let s = 0; s <= n; s++) {
let q = pts.slice();
const t = s / n;
while (q.length > 1) q = q.slice(1).map((p, i) => lerp(q[i], p, t)); // de Casteljau
out.push(q[0]);
}
return out;
};
const catmull = (pts) => {
const out = [];
for (let i = 0; i < pts.length - 1; i++) {
const p0 = pts[i - 1] || pts[i], p1 = pts[i], p2 = pts[i + 1], p3 = pts[i + 2] || p2;
for (let s = 0; s < 16; s++) {
const t = s / 16, t2 = t * t, t3 = t2 * t;
out.push([0, 1].map(k => 0.5 * (2 * p1[k] + (-p0[k] + p2[k]) * t + (2 * p0[k] - 5 * p1[k] + 4 * p2[k] - p3[k]) * t2 + (-p0[k] + 3 * p1[k] - 3 * p2[k] + p3[k]) * t3)));
}
}
out.push(pts[pts.length - 1]);
return out;
};
// Perfect circle through three points; null when they are (nearly) on a line
const arc = (a, b, c) => {
const d = 2 * (a[0] * (b[1] - c[1]) + b[0] * (c[1] - a[1]) + c[0] * (a[1] - b[1]));
if (Math.abs(d) < 1e-3) return null;
const sq = (p) => p[0] * p[0] + p[1] * p[1];
const cx = (sq(a) * (b[1] - c[1]) + sq(b) * (c[1] - a[1]) + sq(c) * (a[1] - b[1])) / d;
const cy = (sq(a) * (c[0] - b[0]) + sq(b) * (a[0] - c[0]) + sq(c) * (b[0] - a[0])) / d;
const r = Math.hypot(a[0] - cx, a[1] - cy);
const a0 = Math.atan2(a[1] - cy, a[0] - cx);
let a2 = Math.atan2(c[1] - cy, c[0] - cx);
// direction: the way that passes through b (sign of the turn a -> b -> c)
const ccw = ((b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])) > 0;
if (ccw) { while (a2 < a0) a2 += 2 * Math.PI; } else { while (a2 > a0) a2 -= 2 * Math.PI; }
// sample well past the end point: the slider length decides where it stops
const span = (a2 - a0) * 2;
const n = Math.max(8, Math.min(200, Math.ceil(Math.abs(span) * r / 4)));
const out = [];
for (let s = 0; s <= n; s++) { const t = a0 + span * s / n; out.push([cx + r * Math.cos(t), cy + r * Math.sin(t)]); }
return out;
};
// Slider curve as a polyline (osu pixels), before cutting to the slider length
const curve = (type, pts) => {
if (type === 'P' && pts.length === 3) {
const c = arc(pts[0], pts[1], pts[2]);
if (c) return c;
return pts;
}
if (type === 'L') return pts;
if (type === 'C') return catmull(pts);
// Bezier (also the fallback): a repeated point starts a new segment
const out = [];
let seg = [pts[0]];
for (let i = 1; i < pts.length; i++) {
if (pts[i][0] === pts[i - 1][0] && pts[i][1] === pts[i - 1][1]) { out.push(...bezier(seg)); seg = [pts[i]]; }
else seg.push(pts[i]);
}
out.push(...bezier(seg));
return out;
};
// Cut / extend a polyline to exactly `length`, then resample it to k points evenly along it
const fitLength = (poly, length, k) => {
const cum = [0];
for (let i = 1; i < poly.length; i++) cum.push(cum[i - 1] + dist(poly[i - 1], poly[i]));
const total = cum[cum.length - 1];
const at = (s) => {
if (poly.length < 2) return poly[0];
if (s >= total) { // past the end: go on along the last direction
const a = poly[poly.length - 2], b = poly[poly.length - 1], l = dist(a, b) || 1;
return [b[0] + (b[0] - a[0]) / l * (s - total), b[1] + (b[1] - a[1]) / l * (s - total)];
}
let i = 1;
while (i < cum.length - 1 && cum[i] < s) i++;
const seg = cum[i] - cum[i - 1] || 1;
return lerp(poly[i - 1], poly[i], (s - cum[i - 1]) / seg);
};
return Array.from({ length: k }, (_, i) => at(length * i / (k - 1)));
};
// ── timing ────────────────────────────────────────────────────────────────────
// -> sorted [{ time, beatLength, sv }]: the beat length and slider velocity in effect from `time` on
const timingOf = (lines) => {
const out = [];
let beat = 500, sv = 1;
const pts = lines.map(l => l.split(',')).filter(p => p.length >= 2)
.map(p => ({ time: +p[0], bl: +p[1], unin: p.length > 6 ? p[6] === '1' : +p[1] > 0 }))
.filter(p => isFinite(p.time) && isFinite(p.bl))
.sort((a, z) => a.time - z.time || (z.unin - a.unin)); // uninherited first at the same time
for (const p of pts) {
if (p.unin && p.bl > 0) { beat = p.bl; sv = 1; }
else if (!p.unin && p.bl < 0) sv = Math.max(0.1, Math.min(10, -100 / p.bl));
out.push({ time: p.time, beatLength: beat, sv });
}
if (!out.length) out.push({ time: 0, beatLength: 500, sv: 1 });
return out;
};
const timingAt = (tps, t) => {
let cur = tps[0];
for (const p of tps) { if (p.time <= t + 1) cur = p; else break; }
return cur;
};
// ── generated positions (taiko) ───────────────────────────────────────────────
// A path that wanders over the screen: every note turns it a little (don) or a lot (kat), notes further
// apart in time are further apart on screen, and a spot that would cover a note still on screen (or leave
// the play area) is avoided by turning further until the spot is free
const flowPlace = (list) => {
const ASPECT = 1.6; // x/y are fractions of a wide viewport: a y step this much larger is the same distance
const ON_SCREEN_MS = 1200; // notes this close in time are visible together
const FREE_X = 0.065, FREE_Y = 0.12; // about a note and a bit, as viewport fractions
const inside = (x, y) => x >= 0.15 && x <= 0.85 && y >= 0.2 && y <= 0.82;
const out = [];
let x = 0.5, y = 0.5, ang = -Math.PI / 2, prevEnd = null;
for (const n of list) {
if (prevEnd !== null) {
const want = ang + (n.kat ? 2.2 : 0.3);
const step = Math.max(0.11, Math.min(0.3, (n.t - prevEnd) / 1000 * 0.35));
const recent = out.filter(o => n.t - o.t < ON_SCREEN_MS);
const free = (px, py) => inside(px, py) && !recent.some(o => Math.abs(o.x - px) < FREE_X && Math.abs(o.y - py) < FREE_Y);
let best = null;
for (let k = 0; k < 24 && !best; k++) { // want, want+0.26, want-0.26, want+0.52, ...
const a = want + (k % 2 ? 1 : -1) * Math.ceil(k / 2) * 0.26;
const px = x + Math.cos(a) * step, py = y + Math.sin(a) * step * ASPECT;
if (free(px, py)) best = [a, px, py];
}
if (!best) { // boxed in: the farthest in-bounds spot from the recent notes
let far = -1;
for (let k = 0; k < 24; k++) {
const a = want + k * Math.PI / 12;
const px = Math.max(0.15, Math.min(0.85, x + Math.cos(a) * step)), py = Math.max(0.2, Math.min(0.82, y + Math.sin(a) * step * ASPECT));
const dmin = recent.reduce((m, o) => Math.min(m, Math.hypot(o.x - px, (o.y - py) / ASPECT)), Infinity);
if (dmin > far) { far = dmin; best = [a, px, py]; }
}
}
[ang, x, y] = best;
}
prevEnd = n.t + (n.d || 0); // after a hold the gap counts from its end
out.push({ t: n.t, x: +x.toFixed(4), y: +y.toFixed(4), d: n.d || 0 });
}
return out;
};
// ── one difficulty -> notes ───────────────────────────────────────────────────
const MODES = ['standard', 'taiko', 'catch', 'mania'];
// -> { version, creator, notes, mode, error? }
export const convertOsu = (text) => {
const o = parseOsu(text);
const mode = parseInt(o.General.Mode || '0', 10) || 0;
const version = o.Metadata.Version || 'Normal';
const res = { version, creator: o.Metadata.Creator || '', mode, meta: o.Metadata, audioFile: o.General.AudioFilename || '', notes: [] };
if (!MODES[mode]) { res.error = 'unknown game mode ' + mode; return res; }
const tps = timingOf(o.TimingPoints);
const mult = parseFloat(o.Difficulty.SliderMultiplier) || 1.4;
const keys = Math.max(1, Math.min(18, Math.round(parseFloat(o.Difficulty.CircleSize) || 4))); // mania columns
const holdUntil = (t, end) => Math.max(0, Math.min(MAX_HOLD_MS, Math.round(end) - t));
const sliderMs = (t, p) => {
const tp = timingAt(tps, t);
const slides = Math.max(1, Math.min(50, parseInt(p[6], 10) || 1));
return (Math.max(1, +p[7] || 0) / (mult * 100 * tp.sv) * tp.beatLength) * slides;
};
let notes = [];
const flow = []; // taiko: { t, d, kat } placed afterwards
for (const line of o.HitObjects) {
const p = line.split(',');
if (p.length < 4) continue;
const x = +p[0], y = +p[1], t = Math.round(+p[2]), type = +p[3];
if (!isFinite(x) || !isFinite(y) || !isFinite(t) || t < 0) continue;
if (mode === 1) { // taiko: hitSound whistle (2) or clap (8) = kat
if (type & 2) flow.push({ t, d: holdUntil(t, t + sliderMs(t, p)) }); // drumroll
else if (type & 8) flow.push({ t, d: holdUntil(t, +p[5] || t) }); // denden
else if (type & 1) flow.push({ t, d: 0, kat: !!((+p[4] || 0) & 10) });
continue;
}
if (mode === 3) { // mania: x picks the column; type 128 = long note, end time in the extras
const col = Math.max(0, Math.min(keys - 1, Math.floor(x * keys / PF_W)));
const d = type & 128 ? holdUntil(t, parseInt(String(p[5] || '').split(':')[0], 10) || t) : 0;
notes.push({ t, x: +(0.15 + (col + 0.5) / keys * 0.7).toFixed(4), y: 0.62, d });
continue;
}
if (type & 2) { // slider: x,y,time,type,hitSound,curve,slides,length,...
const [ctype, ...cps] = String(p[5] || '').split('|');
const ctrl = [[x, y], ...cps.map(s => s.split(':').map(Number)).filter(q => q.length === 2 && q.every(isFinite))];
const slides = Math.max(1, Math.min(50, parseInt(p[6], 10) || 1));
const length = Math.max(1, +p[7] || 0);
const tp = timingAt(tps, t);
const perSlide = length / (mult * 100 * tp.sv) * tp.beatLength;
const d = Math.round(perSlide * slides);
if (!isFinite(d) || d <= 0 || ctrl.length < 2) { notes.push({ t, x: toX(x), y: toY(y), d: 0 }); continue; }
const k = Math.max(2, Math.min(Math.ceil(length / 12) + 1, 40, Math.floor(900 / slides)));
const leg = fitLength(curve(ctype, ctrl), length, k);
const path = [];
for (let s = 0; s < slides; s++) {
const pts = s % 2 ? leg.slice().reverse() : leg;
pts.forEach((q, i) => {
if (s > 0 && i === 0) return; // the turn point is already there
path.push([Math.round(perSlide * (s + i / (k - 1))), +toX(q[0]).toFixed(4), +toY(q[1]).toFixed(4)]);
});
}
notes.push({ t, x: toX(x), y: toY(y), d: Math.min(MAX_HOLD_MS, d), p: path });
} else if (type & 8) { // spinner: x,y,time,type,hitSound,endTime -> hold in the middle
const end = Math.round(+p[5] || t);
notes.push({ t, x: 0.5, y: toY(PF_H / 2), d: Math.max(0, Math.min(MAX_HOLD_MS, end - t)) });
} else if (type & 1) {
notes.push({ t, x: toX(x), y: toY(y), d: 0 });
}
}
if (mode === 1) notes = flowPlace(flow.sort((a, z) => a.t - z.t));
notes.sort((a, z) => a.t - z.t);
// one cursor: notes at the same moment (mania chords) collapse to the first
notes = notes.filter((n, i) => i === 0 || n.t - notes[i - 1].t > 1);
if (!notes.length) res.error = 'no hit objects';
else if (notes.length > MAX_NOTES) res.error = 'too many notes (' + notes.length + ', max ' + MAX_NOTES + ')';
res.notes = notes;
return res;
};
// Title of an imported map: "Insane (mapper)", other modes marked: "ONI (mapper, taiko)"
export const importedTitle = (diff) => {
const extra = [diff.creator, diff.mode ? MODES[diff.mode] : ''].filter(Boolean).join(', ');
return diff.version + (extra ? ' (' + extra + ')' : '');
};
// ── .osz -> { meta, audio: { name, data }, background: { name, data } | null, diffs, skipped } ──
// null when the zip has no .osu files (then it is a normal archive)
export const readOsz = async (buffer) => {
let zip;
try { zip = await JSZip.loadAsync(buffer); } catch (_) { return null; }
const files = Object.values(zip.files).filter(f => !f.dir);
const osuFiles = files.filter(f => /\.osu$/i.test(f.name)).slice(0, MAX_DIFFS);
if (!osuFiles.length) return null;
const byName = (name) => {
if (!name) return null;
const want = name.replace(/\\/g, '/').toLowerCase();
return files.find(f => f.name.toLowerCase() === want) || files.find(f => f.name.toLowerCase().endsWith('/' + want)) || null;
};
const diffs = [], skipped = [];
let meta = null, audioName = null, bgName = null;
for (const f of osuFiles) {
const text = await f.async('string');
if (text.length > MAX_OSU_BYTES) { skipped.push({ version: f.name, reason: 'file too large' }); continue; }
const d = convertOsu(text);
if (d.error) { skipped.push({ version: d.version, reason: d.error }); continue; }
diffs.push(d);
if (!meta) meta = d.meta;
if (!audioName) audioName = d.audioFile;
if (!bgName) {
const ev = parseOsu(text).Events.find(l => /^0\s*,\s*0\s*,/.test(l));
const m = ev && /^0\s*,\s*0\s*,\s*"?([^",]+)"?/.exec(ev);
if (m) bgName = m[1].trim();
}
}
// audio: the file the difficulties name, or any audio file in the set
const audioFile = byName(audioName) || files.find(f => /\.(mp3|ogg|wav|flac|m4a)$/i.test(f.name));
const bgFile = byName(bgName) || files.find(f => /\.(jpe?g|png)$/i.test(f.name));
diffs.sort((a, z) => a.notes.length - z.notes.length); // easiest first
return {
meta: meta || {},
audio: audioFile ? { name: audioFile.name, data: await audioFile.async('nodebuffer') } : null,
background: bgFile ? { name: bgFile.name, data: await bgFile.async('nodebuffer') } : null,
diffs,
skipped,
};
};
// "Artist - Title" for the item title
export const setTitle = (meta) => [meta.Artist || meta.ArtistUnicode, meta.Title || meta.TitleUnicode].filter(Boolean).join(' - ');
// Store converted difficulties as maps of an item (or album entry). A difficulty that is already
// there (same title and note count) is skipped, so importing a set twice adds nothing.
// -> { ids, skipped: [{ version, reason }] }
export const saveDiffs = async ({ itemId, albumItemId = null, userId, diffs, durationMs = 0 }) => {
const ids = [], skipped = [];
for (const d of diffs) {
const v = cleanNotes(d.notes, 0);
if (v.error) { skipped.push({ version: d.version, reason: v.error }); continue; }
const title = cleanTitle(importedTitle(d));
const [dupe] = await db`
SELECT id FROM sqc_maps
WHERE item_id = ${itemId} AND album_item_id IS NOT DISTINCT FROM ${albumItemId}::int
AND title = ${title} AND note_count = ${v.notes.length}
LIMIT 1
`;
if (dupe) { skipped.push({ version: d.version, reason: 'already imported' }); continue; }
const last = v.notes[v.notes.length - 1];
ids.push(await insertMap({ itemId, albumItemId, userId, title, notes: v.notes, durationMs: durationMs || last.t + last.d + 2000 }));
}
return { ids, skipped };
};