click the squares?!?!?!?!!?!?!?
This commit is contained in:
@@ -0,0 +1,342 @@
|
||||
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 };
|
||||
};
|
||||
Reference in New Issue
Block a user