wip
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed

This commit is contained in:
Michael Zhang 2024-05-15 13:57:16 -04:00
parent 4a6997d232
commit cb553b52bd
27 changed files with 1846 additions and 20 deletions

3
.gitignore vendored
View file

@ -1,3 +1,6 @@
node_modules node_modules
dist dist
certs certs
.env.local
.env.production
stepData.ndjson

3
Makefile Normal file
View file

@ -0,0 +1,3 @@
deploy:
pnpm run build --base=/ddr
rsync -azrP dist/ root@veil:/home/blogDeploy/public/ddr

View file

@ -1,7 +1,11 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<title>DDR Companion</title>
<meta name="description" content="My Awesome App description">
<link rel="icon" href="/ddr/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#ffffff">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

430
lib/parseDwi.ts Normal file
View file

@ -0,0 +1,430 @@
import { readdir } from "node:fs/promises";
import Fraction from "fraction.js";
import type { RawSimfile } from "./parseSimfile";
import {
determineBeat,
mergeSimilarBpmRanges,
normalizedDifficultyMap,
} from "./util";
const metaTagsToConsume = ["title", "artist"];
const dwiToSMDirection: Record<string, Arrow["direction"]> = {
0: "0000",
1: "1100", // down-left
2: "0100", // down
3: "0101", // down-right
4: "1000", // left
6: "0001", // right
7: "1010", // up-left
8: "0010", // up
9: "0011", // up-right
A: "0110", // up-down jump
B: "1001", // left-right jump
};
const smToDwiDirection = Object.entries(dwiToSMDirection).reduce<
Record<string, string>
>((building, entry) => {
building[entry[1]] = entry[0];
return building;
}, {});
type ArrowParseResult = {
arrows: Arrow[];
freezes: FreezeBody[];
};
function combinePadsIntoOneStream(
p1: ArrowParseResult,
p2: ArrowParseResult,
): ArrowParseResult {
const arrows = p1.arrows
.concat(p2.arrows)
.sort((a, b) => a.offset - b.offset);
const combinedArrows = arrows.reduce<Arrow[]>((building, arrow, i, rest) => {
const prevArrow = rest[i - 1];
// since previous offset matches, the previous one already
// grabbed and combined with this arrow, throw it away
if (prevArrow?.offset === arrow.offset) {
return building;
}
const nextArrow = rest[i + 1];
if (nextArrow?.offset === arrow.offset) {
return building.concat({
...arrow,
direction: arrow.direction + nextArrow.direction,
} as Arrow);
}
return building.concat({
...arrow,
direction: p1.arrows.includes(arrow)
? `${arrow.direction}0000`
: `0000${arrow.direction}`,
} as Arrow);
}, []);
const bumpedP2Freezes = p2.freezes.map((f) => {
return {
...f,
direction: f.direction + 4,
} as FreezeBody;
});
const freezes = p1.freezes
.concat(bumpedP2Freezes)
.sort((a, b) => a.startOffset - b.startOffset);
return {
arrows: combinedArrows,
freezes,
};
}
function findFirstNonEmptyMeasure(
p1Notes: string,
p2Notes: string | undefined,
): number {
let i = 0;
while (
p1Notes.startsWith("00000000") &&
(!p2Notes || p2Notes.startsWith("00000000"))
) {
p1Notes = p1Notes.substring(8);
p2Notes = p2Notes?.substring(8);
i += 8;
}
return i;
}
function parseArrowStream(
notes: string,
firstNonEmptyMeasureIndex: number,
): ArrowParseResult {
const arrows: Arrow[] = [];
const freezes: FreezeBody[] = [];
let currentFreezeDirections: string[] = [];
const openFreezes: Record<
FreezeBody["direction"],
Partial<FreezeBody> | null
> = {
0: null,
1: null,
2: null,
3: null,
4: null,
5: null,
6: null,
7: null,
};
let curOffset = new Fraction(0);
// dwi's default increment is 8th notes
let curMeasureFraction = new Fraction(1).div(8);
for (
let i = firstNonEmptyMeasureIndex;
i < notes.length && notes[i] !== ";";
++i
) {
let note = notes[i];
const nextNote = notes[i + 1];
const smDirection = dwiToSMDirection[note];
// give the current note a chance to conclude any freezes that may be pending
if (smDirection) {
const smDirectionSplit = smDirection.split("");
for (let d = 0; d < smDirection.length; ++d) {
if (
smDirection[d] === "1" &&
openFreezes[d as FreezeBody["direction"]]
) {
const of = openFreezes[d as FreezeBody["direction"]];
of!.endOffset = curOffset.n / curOffset.d + 0.25;
freezes.push(of as FreezeBody);
openFreezes[d as FreezeBody["direction"]] = null;
smDirectionSplit[d] = "0";
}
}
note = smToDwiDirection[smDirectionSplit.join("")];
}
if (nextNote === "!") {
// B!602080B
// this means the freeze starts with B (left and right), but then only right (6) has the freeze body
// during the freeze there is down (2) then up (8), concluding with the second B
const freezeNote = notes[i + 2];
const smDirection = dwiToSMDirection[freezeNote];
for (let d = 0; d < smDirection.length; ++d) {
if (smDirection[d] === "1") {
openFreezes[d as FreezeBody["direction"]] = {
direction: d as FreezeBody["direction"],
startOffset: curOffset.n / curOffset.d,
};
}
}
// the head of a freeze is still an arrow
arrows.push({
direction: dwiToSMDirection[note].replace(
/1/g,
"2",
) as Arrow["direction"],
beat: determineBeat(curOffset),
offset: curOffset.n / curOffset.d,
});
// remember the direction to know when to close the freeze
currentFreezeDirections.push(freezeNote);
// move past the exclamation and trailing note
i += 2;
curOffset = curOffset.add(curMeasureFraction);
} else if (note === "(") {
curMeasureFraction = new Fraction(1).div(16);
} else if (note === "[") {
curMeasureFraction = new Fraction(1).div(24);
} else if (note === "{") {
curMeasureFraction = new Fraction(1).div(64);
} else if (note === "`") {
curMeasureFraction = new Fraction(1).div(192);
} else if ([")", "]", "}", "'"].includes(note)) {
curMeasureFraction = new Fraction(1).div(8);
} else if (note === "0") {
curOffset = curOffset.add(curMeasureFraction);
} else {
const direction = dwiToSMDirection[note];
if (direction) {
arrows.push({
direction,
beat: determineBeat(curOffset),
offset: curOffset.n / curOffset.d,
});
}
curOffset = curOffset.add(curMeasureFraction);
}
}
return { arrows, freezes };
}
async function findBanner(titlePath: string): Promise<string> {
const files = await readdir(titlePath);
const bannerFile = files.find(
(f) => f.endsWith(".png") && f.indexOf("-bg.png") === -1,
);
return bannerFile ?? null;
}
async function parseDwi(dwi: string, titlePath?: string): Promise<RawSimfile> {
let bpm: string | null = null;
let changebpm: string | null = null;
let displaybpm: string | null = null;
let stops: string | null = null;
const lines = dwi.split("\n").map((l) => l.trim());
let i = 0;
const sc: Partial<RawSimfile> = {
charts: {},
availableTypes: [],
banner: titlePath ? await findBanner(titlePath) : null,
};
function parseNotes(mode: "single" | "double", rawNotes: string) {
const values = rawNotes.split(":");
const difficulty = normalizedDifficultyMap[values[0].toLowerCase()];
const feet = Number(values[1]);
const notes = values[2];
const playerTwoNotes = values[3];
const firstNonEmptyMeasureIndex = findFirstNonEmptyMeasure(
notes,
playerTwoNotes,
);
let arrowResult = parseArrowStream(notes, firstNonEmptyMeasureIndex);
if (mode === "double") {
const playerTwoResult = parseArrowStream(
playerTwoNotes,
firstNonEmptyMeasureIndex,
);
arrowResult = combinePadsIntoOneStream(arrowResult, playerTwoResult);
}
sc.availableTypes!.push({
slug: `${mode}-${difficulty}`,
mode,
difficulty: difficulty as any,
feet,
});
sc.charts![`${mode}-${difficulty}`] = {
arrows: arrowResult.arrows,
freezes: arrowResult.freezes,
bpm: determineBpm(firstNonEmptyMeasureIndex),
stops: determineStops(firstNonEmptyMeasureIndex),
};
}
function determineStops(emptyOffset: number) {
if (!stops) {
return [];
}
return stops.split(",").map((s) => {
const [eigthNoteS, stopDurationS] = s.split("=");
return {
offset: Number(eigthNoteS) * (1 / 16) - emptyOffset * (1 / 8),
duration: Number(stopDurationS),
};
});
}
function determineBpm(emptyOffset: number) {
let finalBpms: Bpm[] = [];
if (bpm && !Number.isNaN(Number(bpm))) {
finalBpms = [{ startOffset: 0, endOffset: null, bpm: Number(bpm) }];
}
if (changebpm) {
if (!finalBpms) {
throw new Error("parseDwi: a simfile has changebpm but not bpm");
}
const entries = changebpm.split(",");
const additionalBpms = entries.map((bpmES, i, a) => {
const [eigthNoteS, bpmVS] = bpmES.split("=");
const nextEigthNoteS = a[i + 1]?.split("=")[0] ?? null;
const startOffset =
Number(eigthNoteS) * (1 / 16) - emptyOffset * (1 / 8);
let endOffset = null;
if (nextEigthNoteS) {
endOffset = Number(nextEigthNoteS) * (1 / 16) - emptyOffset * (1 / 8);
}
return {
startOffset,
endOffset,
bpm: Number(bpmVS),
};
});
finalBpms = finalBpms.concat(additionalBpms);
finalBpms[0].endOffset = finalBpms[1].startOffset;
}
if (!finalBpms) {
throw new Error("parseDwi, determineBpm: failed to get bpm");
}
finalBpms = mergeSimilarBpmRanges(finalBpms);
if (displaybpm) {
if (!Number.isNaN(Number(displaybpm))) {
sc.displayBpm = displaybpm;
} else if (displaybpm.indexOf("..") > -1) {
const [min, max] = displaybpm.split("..");
sc.displayBpm = `${min}-${max}`;
} else {
// displayBpm is allowed to be '*', I know of no simfiles
// that actually do that though
sc.displayBpm = displaybpm;
}
} else {
const minBpm = Math.min(...finalBpms.map((b) => b.bpm));
const maxBpm = Math.max(...finalBpms.map((b) => b.bpm));
if (minBpm === maxBpm) {
sc.displayBpm = Math.round(minBpm).toString();
} else {
sc.displayBpm = `${Math.round(minBpm)}-${Math.round(maxBpm)}`;
}
}
return finalBpms;
}
function parseTag(lines: string[], index: number): number {
const line = lines[index];
const r = /#([A-Za-z]+):([^;]*)/;
const result = r.exec(line);
if (result) {
const tag = result[1].toLowerCase();
const value = result[2];
if (metaTagsToConsume.includes(tag)) {
// @ts-ignore
sc[tag] = value;
} else if (tag === "displaybpm") {
displaybpm = value;
} else if (tag === "bpm") {
bpm = value;
} else if (tag === "changebpm") {
changebpm = value;
} else if (tag === "freeze") {
stops = value;
} else if (tag === "single" || tag === "double") {
parseNotes(tag, value);
}
}
return index + 1;
}
try {
while (i < lines.length) {
const line = lines[i];
if (!line.length || line.startsWith("//")) {
i += 1;
continue;
}
if (line.startsWith("#")) {
i = parseTag(lines, i);
} else {
i += 1;
}
}
if (!displaybpm && !bpm) {
throw new Error(`No BPM found for ${titlePath}`);
}
return sc as RawSimfile;
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
const stack = e instanceof Error ? e.stack : "";
throw new Error(`error, ${message}, ${stack}, parsing ${dwi}`);
}
}
export { parseDwi };

114
lib/parseSimfile.ts Normal file
View file

@ -0,0 +1,114 @@
import { stat, readFile, readdir, copyFile } from "node:fs/promises";
import { join, extname, resolve } from "node:path";
import { parseDwi } from "./parseDwi";
import { parseSm } from "./parseSm";
type RawSimfile = Omit<Simfile, "mix" | "title"> & {
title: string;
titletranslit: string | null;
banner: string | null;
bannerEncoded: string | null;
displayBpm: string | undefined;
};
type Parser = (simfileSource: string, titleDir: string) => Promise<RawSimfile>;
const parsers: Record<string, Parser> = {
".sm": parseSm,
".dwi": parseDwi,
};
async function getSongFile(songDir: string): Promise<string> {
const files = await readdir(songDir);
// TODO: support more than .sm
const extensions = Object.keys(parsers);
const songFile = files.find((f) => extensions.some((ext) => f.endsWith(ext)));
if (!songFile) {
throw new Error(`No song file found in ${songDir}`);
}
return songFile;
}
function toSafeName(name: string): string {
let name2 = name;
name2 = name2.replace(".png", "");
name2 = name2.replace(/\s/g, "-").replace(/[^\w]/g, "_");
return `${name}.png`;
}
function getBpms(sm: RawSimfile): number[] {
const chart = Object.values(sm.charts)[0];
return chart.bpm.map((b) => b.bpm);
}
async function parseSimfile(
rootDir: string,
mixDir: string,
titleDir: string,
): Promise<Omit<Simfile, "mix">> {
const stepchartSongDirPath = join(rootDir, mixDir, titleDir);
const songFile = await getSongFile(stepchartSongDirPath);
const stepchartPath = join(stepchartSongDirPath, songFile);
const extension = extname(stepchartPath);
const parser = parsers[extension];
if (!parser) {
throw new Error(`No parser registered for extension: ${extension}`);
}
const fileContents = await readFile(stepchartPath);
const rawStepchart = await parser(
fileContents.toString(),
stepchartSongDirPath,
);
const bannerPath = join(stepchartSongDirPath, rawStepchart.banner);
try {
const bannerData = await readFile(bannerPath);
rawStepchart.bannerEncoded = bannerData.toString("base64");
} catch (e) {}
// if (bannerMeta !== undefined) {
// const publicName = toSafeName(`${mixDir}-${rawStepchart.banner}`);
// const srcPath = resolve(stepchartSongDirPath, rawStepchart.banner);
// const destPath = resolve("public/bannerImages", publicName);
// await copyFile(srcPath, destPath);
// await copyFile(
// join(stepchartSongDirPath, rawStepchart.banner),
// join("components/bannerImages", publicName),
// );
// rawStepchart.banner = publicName;
// } else {
// rawStepchart.banner = null;
// }
const bpms = getBpms(rawStepchart);
const minBpm = Math.round(Math.min(...bpms));
const maxBpm = Math.round(Math.max(...bpms));
const displayBpm =
minBpm === maxBpm ? minBpm.toString() : `${minBpm}-${maxBpm}`;
return {
...rawStepchart,
title: {
titleName: rawStepchart.title,
translitTitleName: rawStepchart.titletranslit ?? null,
titleDir,
banner: rawStepchart.banner,
},
minBpm,
maxBpm,
displayBpm,
stopCount: Object.values(rawStepchart.charts)[0].stops.length,
};
}
export { parseSimfile };
export type { RawSimfile };

336
lib/parseSm.ts Normal file
View file

@ -0,0 +1,336 @@
import Fraction from "fraction.js";
import type { RawSimfile } from "./parseSimfile";
import {
determineBeat,
mergeSimilarBpmRanges,
normalizedDifficultyMap,
} from "./util";
const metaTagsToConsume = ["title", "titletranslit", "artist", "banner"];
function concludesANoteTag(line: string | undefined): boolean {
if (line === undefined) {
return true;
}
return line[0] === ";" || (line[0] === "," && line[1] === ";");
}
function getMeasureLength(lines: string[], i: number): number {
let measureLength = 0;
for (
;
i < lines.length && !concludesANoteTag(lines[i]) && lines[i][0] !== ",";
++i
) {
if (lines[i].trim() !== "") {
measureLength += 1;
}
}
return measureLength;
}
function trimNoteLine(line: string, mode: "single" | "double"): string {
if (mode === "single") return line.substring(0, 4);
return line.substring(0, 8);
}
function isRest(line: string): boolean {
return line.split("").every((d) => d === "0");
}
function findFirstNonEmptyMeasure(
mode: "single" | "double",
lines: string[],
i: number,
): { firstNonEmptyMeasureIndex: number; numMeasuresSkipped: number } {
let numMeasuresSkipped = 0;
let measureIndex = i;
for (; i < lines.length && !concludesANoteTag(lines[i]); ++i) {
const line = lines[i];
if (line.trim() === "") {
continue;
}
if (line.startsWith(",")) {
measureIndex = i + 1;
numMeasuresSkipped += 1;
continue;
}
if (!isRest(trimNoteLine(line, mode))) {
return { firstNonEmptyMeasureIndex: measureIndex, numMeasuresSkipped };
}
}
throw new Error(
"findFirstNonEmptyMeasure, failed to find a non-empty measure in entire song",
);
}
async function parseSm(sm: string, _titlePath: string): Promise<RawSimfile> {
const lines = sm.split("\n").map((l) => l.trim());
let i = 0;
let bpmString: string | null = null;
let stopsString: string | null = null;
const sc: Partial<RawSimfile> = {
charts: {},
availableTypes: [],
banner: null,
};
function parseStops(
stopsString: string | null,
emptyOffsetInMeasures: number,
) {
if (!stopsString) {
return [];
}
const entries = stopsString.split(",");
return entries.map((s) => {
const [stopS, durationS] = s.split("=");
return {
offset: Number(stopS) * 0.25 - emptyOffsetInMeasures,
duration: Number(durationS),
};
});
}
function parseBpms(bpmString: string, emptyOffsetInMeasures: number) {
// 0=79.3,4=80,33=79.8,36=100,68=120,100=137,103=143,106=139,108=140,130=141.5,132=160,164=182,166=181,168=180;
const entries = bpmString.split(",");
const bpms = entries.map((e, i, a) => {
const [beatS, bpmS] = e.split("=");
const nextBeatS = a[i + 1]?.split("=")[0] ?? null;
return {
startOffset: Number(beatS) * 0.25 - emptyOffsetInMeasures,
endOffset:
nextBeatS === null
? null
: Number(nextBeatS) * 0.25 - emptyOffsetInMeasures,
bpm: Number(bpmS),
};
});
const mergedBpms = mergeSimilarBpmRanges(bpms);
const minBpm = Math.min(...mergedBpms.map((b) => b.bpm));
const maxBpm = Math.max(...mergedBpms.map((b) => b.bpm));
if (Math.abs(minBpm - maxBpm) < 2) {
sc.displayBpm = Math.round(minBpm).toString();
} else {
sc.displayBpm = `${Math.round(minBpm)}-${Math.round(maxBpm)}`;
}
return mergedBpms;
}
function parseFreezes(
lines: string[],
i: number,
mode: string,
difficulty: string,
): FreezeBody[] {
const freezes: FreezeBody[] = [];
const open: Record<number, Partial<FreezeBody> | undefined> = {};
let curOffset = new Fraction(0);
let curMeasureFraction = new Fraction(1).div(
getMeasureLength(lines, i) || 1,
);
for (; i < lines.length && !concludesANoteTag(lines[i]); ++i) {
const line = lines[i];
if (line.trim() === "") {
continue;
}
if (line[0] === ",") {
curMeasureFraction = new Fraction(1).div(
getMeasureLength(lines, i + 1) || 1,
);
continue;
}
if (line.indexOf("2") === -1 && line.indexOf("3") === -1) {
curOffset = curOffset.add(curMeasureFraction);
continue;
}
const cleanedLine = line.replace(/[^23]/g, "0");
for (let d = 0; d < cleanedLine.length; ++d) {
if (cleanedLine[d] === "2") {
if (open[d]) {
throw new Error(
`${sc.title}, ${mode}, ${difficulty} -- error parsing freezes, found a new starting freeze before a previous one finished`,
);
}
const startBeatFraction = curOffset;
open[d] = {
direction: d as FreezeBody["direction"],
startOffset: startBeatFraction.n / startBeatFraction.d,
};
} else if (cleanedLine[d] === "3") {
if (!open[d]) {
throw new Error(
`${sc.title}, ${mode}, ${difficulty} -- error parsing freezes, needed to close a freeze that never opened`,
);
}
const endBeatFraction = curOffset.add(new Fraction(1).div(4));
open[d]!.endOffset = endBeatFraction.n / endBeatFraction.d;
freezes.push(open[d] as FreezeBody);
open[d] = undefined;
}
}
curOffset = curOffset.add(curMeasureFraction);
}
return freezes;
}
function parseNotes(lines: string[], i: number, bpmString: string): number {
// move past #NOTES into the note metadata
i++;
const mode = lines[i++].replace("dance-", "").replace(":", "");
i++; // skip author for now
const difficulty =
normalizedDifficultyMap[lines[i++].replace(":", "").toLowerCase()];
const feet = Number(lines[i++].replace(":", ""));
i++; // skip groove meter data for now
// skip couple, versus, etc for now
if (mode !== "single" && mode !== "double") {
return i + 1;
}
// now i is pointing at the first measure
const arrows: Arrow[] = [];
const { firstNonEmptyMeasureIndex, numMeasuresSkipped } =
findFirstNonEmptyMeasure(mode, lines, i);
i = firstNonEmptyMeasureIndex;
const firstMeasureIndex = i;
let curOffset = new Fraction(0);
// in case the measure is size zero, fall back to dividing by one
// this is just being defensive, this would mean the stepfile has no notes in it
let curMeasureFraction = new Fraction(1).div(
getMeasureLength(lines, i) || 1,
);
for (; i < lines.length && !concludesANoteTag(lines[i]); ++i) {
// for now, remove freeze ends as they are handled in parseFreezes
// TODO: deal with freezes here, no need to have two functions doing basically the same thing
const line = trimNoteLine(lines[i], mode).replace(/3/g, "0");
if (line.trim() === "") {
continue;
}
if (line.startsWith(",")) {
curMeasureFraction = new Fraction(1).div(
getMeasureLength(lines, i + 1) || 1,
);
continue;
}
if (!isRest(line)) {
arrows.push({
beat: determineBeat(curOffset),
offset: curOffset.n / curOffset.d,
direction: line as Arrow["direction"],
});
}
curOffset = curOffset.add(curMeasureFraction);
}
const freezes = parseFreezes(lines, firstMeasureIndex, mode, difficulty);
sc.charts![`${mode}-${difficulty}`] = {
arrows,
freezes,
bpm: parseBpms(bpmString, numMeasuresSkipped),
stops: parseStops(stopsString, numMeasuresSkipped),
};
sc.availableTypes!.push({
slug: `${mode}-${difficulty}`,
mode,
difficulty: difficulty as any,
feet,
});
return i + 1;
}
function parseTag(lines: string[], index: number): number {
const line = lines[index];
const r = /#([A-Za-z]+):([^;]*)/;
const result = r.exec(line);
if (result) {
const tag = result[1].toLowerCase();
const value = result[2];
if (metaTagsToConsume.includes(tag)) {
// @ts-ignore
sc[tag] = value;
} else if (tag === "bpms") {
bpmString = value;
} else if (tag === "stops") {
stopsString = value;
} else if (tag === "notes") {
if (!bpmString) {
throw new Error("parseSm: about to parse notes but never got bpm");
}
return parseNotes(lines, index, bpmString);
}
}
return index + 1;
}
try {
while (i < lines.length) {
const line = lines[i];
if (!line.length || line.startsWith("//")) {
i += 1;
continue;
}
if (line.startsWith("#")) {
i = parseTag(lines, i);
} else {
i += 1;
}
}
return sc as RawSimfile;
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
const stack = e instanceof Error ? e.stack : "";
throw new Error(
`error, ${message}, ${stack}, parsing ${sm.substring(0, 300)}`,
);
}
}
export { parseSm };

91
lib/stepcharts.d.ts vendored Normal file
View file

@ -0,0 +1,91 @@
type Arrow = {
// other beats such as 5ths and 32nds end up being colored
// the same as 6ths. This probably should be "color" not "beat" TODO
beat: 4 | 6 | 8 | 12 | 16;
direction:
| `${0 | 1 | 2}${0 | 1 | 2}${0 | 1 | 2}${0 | 1 | 2}`
| `${0 | 1 | 2}${0 | 1 | 2}${0 | 1 | 2}${0 | 1 | 2}${0 | 1 | 2}${
| 0
| 1
| 2}${0 | 1 | 2}${0 | 1 | 2}`;
offset: number;
};
type FreezeBody = {
direction: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7;
startOffset: number;
endOffset: number;
};
type Mode = "single" | "double";
type Difficulty =
| "beginner"
| "basic"
| "difficult"
| "expert"
| "challenge"
| "edit";
type StepchartType = {
slug: string;
mode: Mode;
difficulty: Difficulty;
feet: number;
};
type Stats = {
jumps: number;
jacks: number;
freezes: number;
gallops: number;
};
type Bpm = {
startOffset: number;
endOffset: number | null;
bpm: number;
};
type Stop = {
offset: number;
duration: number;
};
type Stepchart = {
arrows: Arrow[];
freezes: FreezeBody[];
bpm: Bpm[];
stops: Stop[];
};
type Simfile = {
title: Title;
artist: string;
mix: Mix;
availableTypes: StepchartType[];
charts: Record<string, Stepchart>;
minBpm: number;
maxBpm: number;
displayBpm: string;
stopCount: number;
stats: Stats;
};
type Mix = {
mixName: string;
mixDir: string;
songCount: number;
};
type Title = {
titleName: string;
translitTitleName: string | null;
titleDir: string;
banner: string | null;
};
type SongDifficultyType = {
title: Title;
mix: Mix;
type: StepchartType;
};

66
lib/util.ts Normal file
View file

@ -0,0 +1,66 @@
import Fraction from "fraction.js";
const beats = [
new Fraction(1).div(4),
new Fraction(1).div(6),
new Fraction(1).div(8),
new Fraction(1).div(12),
new Fraction(1).div(16),
];
function determineBeat(offset: Fraction): Arrow["beat"] {
const match = beats.find((b) => offset.mod(b).n === 0);
if (!match) {
// didn't find anything? then it's a weirdo like a 5th note or 32nd note, they get colored
// the same as 6ths
return 6;
}
return match.d as Arrow["beat"];
}
const normalizedDifficultyMap: Record<string, Difficulty> = {
beginner: "beginner",
easy: "basic",
basic: "basic",
trick: "difficult",
another: "difficult",
medium: "difficult",
difficult: "expert",
expert: "expert",
maniac: "expert",
ssr: "expert",
hard: "expert",
challenge: "challenge",
smaniac: "challenge",
// TODO: filter edits out altogether
edit: "edit",
};
function similarBpm(a: Bpm, b: Bpm): boolean {
return Math.abs(a.bpm - b.bpm) < 1;
}
function mergeSimilarBpmRanges(bpm: Bpm[]): Bpm[] {
return bpm.reduce<Bpm[]>((building, b, i, a) => {
const prev = a[i - 1];
const next = a[i + 1];
if (prev && similarBpm(prev, b)) {
// this bpm was merged on the last iteration, so skip it
return building;
}
if (next && similarBpm(next, b)) {
return building.concat({
...b,
endOffset: next.endOffset,
});
}
return building.concat(b);
}, []);
}
export { determineBeat, normalizedDifficultyMap, mergeSimilarBpmRanges };

View file

@ -5,7 +5,8 @@
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build" "build": "vite build",
"generate-pwa-assets": "pwa-assets-generator"
}, },
"type": "module", "type": "module",
"keywords": [], "keywords": [],
@ -13,10 +14,13 @@
"license": "ISC", "license": "ISC",
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^1.7.3", "@biomejs/biome": "^1.7.3",
"@types/ndjson": "^2.0.4",
"@types/react": "^18.3.2", "@types/react": "^18.3.2",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"@vite-pwa/assets-generator": "^0.2.4",
"@vitejs/plugin-basic-ssl": "^1.1.0", "@vitejs/plugin-basic-ssl": "^1.1.0",
"@vitejs/plugin-react-swc": "^3.6.0", "@vitejs/plugin-react-swc": "^3.6.0",
"fraction.js": "^4.3.7",
"sass": "^1.77.1", "sass": "^1.77.1",
"typescript": "^5.4.5", "typescript": "^5.4.5",
"vite": "^5.2.11", "vite": "^5.2.11",
@ -28,9 +32,13 @@
"@emotion/styled": "^11.11.5", "@emotion/styled": "^11.11.5",
"@mui/icons-material": "^5.15.17", "@mui/icons-material": "^5.15.17",
"@mui/material": "^5.15.17", "@mui/material": "^5.15.17",
"@tanstack/react-query": "^5.36.1",
"can-ndjson-stream": "^1.0.2",
"ndjson": "^2.0.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-router": "^6.23.1", "react-router": "^6.23.1",
"react-router-dom": "^6.23.1" "react-router-dom": "^6.23.1",
"stream-json": "^1.8.0"
} }
} }

File diff suppressed because it is too large Load diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

BIN
public/pwa-192x192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

BIN
public/pwa-512x512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
public/pwa-64x64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

2
public/robots.txt Normal file
View file

@ -0,0 +1,2 @@
User-agent: *
Allow: /

6
pwa-assets.config.ts Normal file
View file

@ -0,0 +1,6 @@
import { defineConfig } from "@vite-pwa/assets-generator/config";
export default defineConfig({
preset: "minimal",
images: ["public/favicon.png"],
});

View file

@ -0,0 +1,47 @@
// TODO: Make this concurrent instead of serial
import { join, extname } from "node:path";
import { pipeline } from "node:stream/promises";
import { Readable } from "node:stream";
import * as ndjson from "ndjson";
import { readdir, writeFile, stat } from "node:fs/promises";
import { createWriteStream } from "node:fs";
import { parseSm } from "../lib/parseSm";
import { parseSimfile } from "../lib/parseSimfile";
const rootDir = process.env.STEPCHARTS_DIR ?? "/tmp/stepcharts";
const stepchartsDir = join(rootDir, "prodStepcharts");
const outFile = process.env.OUT_FILE ?? "data/stepData.ndjson";
async function* mixToSongs(
mixDirs: string[],
): AsyncGenerator<[string, string], void, unknown> {
for (const mix of mixDirs) {
const mixDir = join(stepchartsDir, mix);
const files = await readdir(mixDir);
for (const song of files) {
yield [mix, song];
}
}
}
async function* processSong(gen: AsyncGenerator<[string, string]>) {
for await (const [mix, song] of gen) {
try {
const parsed = await parseSimfile(stepchartsDir, mix, song);
yield parsed;
} catch (e) {
console.error("failed on", mix, song, ":", e.message);
}
}
}
const mixDirs = await readdir(stepchartsDir);
const songIter = mixToSongs(mixDirs);
const outIter = processSong(songIter);
const writer = createWriteStream(outFile);
for await (const songObj of outIter) {
writer.write(JSON.stringify(songObj));
writer.write("\n");
}

View file

@ -2,7 +2,7 @@ import { BottomNavigation, BottomNavigationAction, Icon } from "@mui/material";
import LineWeightIcon from "@mui/icons-material/LineWeight"; import LineWeightIcon from "@mui/icons-material/LineWeight";
import ManageSearchIcon from "@mui/icons-material/ManageSearch"; import ManageSearchIcon from "@mui/icons-material/ManageSearch";
import SettingsIcon from "@mui/icons-material/Settings"; import SettingsIcon from "@mui/icons-material/Settings";
import { useState } from "react"; import { useEffect, useState } from "react";
import { import {
createBrowserRouter, createBrowserRouter,
RouterProvider, RouterProvider,
@ -18,6 +18,10 @@ import Charts from "./pages/Charts";
import styles from "./App.module.scss"; import styles from "./App.module.scss";
import Scores from "./pages/Scores"; import Scores from "./pages/Scores";
import Settings from "./pages/Settings"; import Settings from "./pages/Settings";
import ImportChartWorker from "./importCharts?worker";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const queryClient = new QueryClient();
const router = createBrowserRouter( const router = createBrowserRouter(
[ [
@ -70,9 +74,15 @@ export function AppWrapper() {
} }
export default function App() { export default function App() {
useEffect(() => {
const worker = new ImportChartWorker();
});
return ( return (
<> <>
<RouterProvider router={router} /> <QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</> </>
); );
} }

1
src/constants.ts Normal file
View file

@ -0,0 +1 @@
export const APP_DATA_VERSION = 1;

View file

@ -1,3 +1,12 @@
:root {
--font: sans-serif;
}
html,
body {
font-family: var(--font);
}
html, html,
body, body,
#root { #root {

90
src/importCharts.ts Normal file
View file

@ -0,0 +1,90 @@
import stepchartsUrl from "../data/stepData.ndjson?url";
import { APP_DATA_VERSION } from "./constants";
import ndjsonStream from "can-ndjson-stream";
export async function importCharts() {
const {
db: { result: db },
refetchCharts,
} = await openDb();
console.log("db", db);
if (refetchCharts) {
const response = await fetch(stepchartsUrl);
const reader = ndjsonStream(response.body).getReader();
const iter = iterStream(reader);
await addToDb(db, iter);
}
}
function addToDb(db: IDBDatabase, iter: AsyncGenerator<any>): Promise<void> {
return new Promise((resolve) => {
function openTransaction() {
const tx = db.transaction("chartStore", "readwrite");
return tx.objectStore("chartStore");
}
(async () => {
let batch = [];
for await (const obj of iter) {
if (batch.length >= 100) {
const store = openTransaction();
for (const obj of batch) {
const req = store.add(obj);
req.onsuccess = (evt) => {
console.log("Inserted", evt.target.result);
};
}
batch = [];
}
batch.push(obj);
}
const store = openTransaction();
for (const obj of batch) {
const req = store.add(obj);
req.onsuccess = (evt) => {
console.log("Inserted", evt.target.result);
};
}
batch = [];
})();
});
}
async function* iterStream(reader: ReadableStreamReader) {
let result;
while (!result || !result.done) {
result = await reader.read();
yield result.value;
}
}
function openDb() {
return new Promise((resolve) => {
const db = indexedDB.open("ddrDb", APP_DATA_VERSION);
let refetchCharts = false;
db.onupgradeneeded = (evt) => {
console.log("UPGRADE", evt.oldVersion, evt.newVersion);
refetchCharts = true;
{
const store = db.result.createObjectStore("chartStore", {
// TODO: Try to use the Konami ID here
autoIncrement: true,
});
store.createIndex("title", "title", { unique: false });
store.createIndex("artist", "artist", { unique: false });
store.createIndex("mode", "mode", { unique: false });
}
};
db.onsuccess = () => {
resolve({ db, refetchCharts });
};
});
}
await importCharts();

View file

@ -1,7 +1,23 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { APP_DATA_VERSION } from "../constants";
async function fetchCharts() {
const db = indexedDB.open("ddrDb", APP_DATA_VERSION);
return [];
}
export default function Charts() { export default function Charts() {
const queryClient = useQueryClient();
const fetchChartsQuery = useQuery({
queryKey: ["fetchCharts"],
queryFn: fetchCharts,
});
return ( return (
<> <>
<h1>Charts</h1> <h1>Charts</h1>
{JSON.stringify(fetchChartsQuery)}
</> </>
); );
} }

View file

@ -2,6 +2,7 @@
"compilerOptions": { "compilerOptions": {
"jsx": "react-jsx", "jsx": "react-jsx",
"module": "ESNext", "module": "ESNext",
"target": "ESNext",
"moduleResolution": "Node" "moduleResolution": "Node"
} }
} }

View file

@ -10,7 +10,12 @@ export default defineConfig({
}, },
plugins: [ plugins: [
react(), react(),
VitePWA({ registerType: "autoUpdate" }), VitePWA({
registerType: "autoUpdate",
manifest: {
icons: [{ src: "pwa-192x192.png", purpose: "any", sizes: "192x192" }],
},
}),
basicSsl({ basicSsl({
/** name of certification */ /** name of certification */