feat(client): chunithm graphs, song durations (#1284)

* feat: chunithm score graph

* feat: add life

* feat: add song duration data for all songs

* fix: make chunithm graphs non-nullable

* fix: test data

* fix: potrząsanie kanalizacją

* fix: misleading old function name

* fix: make duration optional for chunithm

* fix: add the missing duration
This commit is contained in:
nairobi
2025-04-29 20:33:49 +01:00
committed by GitHub
parent ab9474a6fa
commit 1148a5fdff
15 changed files with 3192 additions and 66 deletions
@@ -11,7 +11,7 @@ import {
PointTooltipProps,
LineSvgProps,
} from "@nivo/line";
import { COLOUR_SET, Difficulties } from "tachi-common";
import { COLOUR_SET, Difficulties, Game } from "tachi-common";
import { GPT_CLIENT_IMPLEMENTATIONS } from "lib/game-implementations";
import ChartTooltip from "./ChartTooltip";
@@ -22,21 +22,39 @@ const formatTime = (s: DatumValue) =>
.toString()
.padStart(2, "0")}`;
const scoreToLamp = (s: number) => {
switch (s) {
case 970000:
return "S";
case 990000:
return "SS";
case 1000000:
return "SSS";
case 1007500:
return "SSS+";
const getScoreYAxisNotch = (game: Game) => (s: number) => {
if (game === "ongeki") {
switch (s) {
case 970_000:
return "S";
case 990_000:
return "SS";
case 1000_000:
return "SSS";
case 1007_500:
return "SSS+";
}
}
if (game === "chunithm") {
switch (s) {
case 990_000:
return "S+";
case 1000_000:
return "SS";
case 1005_000:
return "SS+";
case 1007_500:
return "SSS";
case 1009_000:
return "SSS+";
}
}
return "";
};
const strokeColor = (type: Difficulties["ongeki:Single"] | "BELLS") => {
const strokeColor = (
type: Difficulties["ongeki:Single"] | Difficulties["chunithm:Single"] | "BELLS"
) => {
const isLight = getTheme() === "light";
switch (type) {
case "BASIC":
@@ -49,21 +67,26 @@ const strokeColor = (type: Difficulties["ongeki:Single"] | "BELLS") => {
return `hsl(280, 60%, ${isLight ? 35 : 67}%)`;
case "BELLS":
return `hsl(55, 90%, ${isLight ? 35 : 42}%)`;
case "ULTIMA":
return `hsl(360, 50%, ${isLight ? 35 : 67}%)`;
default:
return `hsl(0, 0%, ${isLight ? 35 : 67}%)`;
}
};
const limitScoreGraph = (data: Serie[]) => {
const limitScoreGraph = (game: Game, data: Serie[]) => {
for (const val of data[0].data) {
if (val.y === null || val.y === undefined) {
if (typeof val.y !== "number") {
break;
}
if (val.y < 970000) {
if (game === "ongeki" && val.y < 970_000) {
// 969999 will be used to represent values below S
// Without this, the line would cross the bottom axis
// which looks very bad
val.y = 969999;
val.y = 969_999;
}
if (game === "chunithm" && val.y < 990_000) {
val.y = 989_999;
}
}
return data;
@@ -77,7 +100,7 @@ const bellFloor = (data: Datum[], totalBellCount: number) => {
: clamp(-totalBellCount, Math.floor(lowestValue * 1.333), -1);
};
export default function OngekiScoreChart({
export default function GekichuScoreChart({
width = "100%",
height = "100%",
mobileHeight = "100%",
@@ -86,28 +109,46 @@ export default function OngekiScoreChart({
difficulty,
totalBellCount,
data,
game,
duration,
}: {
mobileHeight?: number | string;
mobileWidth?: number | string;
width?: number | string;
height?: number | string;
type: "Score" | "Bells" | "Life";
difficulty: Difficulties["ongeki:Single"];
totalBellCount: number;
difficulty: Difficulties["ongeki:Single"] | Difficulties["chunithm:Single"];
totalBellCount?: number;
data: Serie[];
game: Game;
duration: number;
} & ResponsiveLine["props"]) {
const color =
type === "Score"
? GPT_CLIENT_IMPLEMENTATIONS["ongeki:Single"].difficultyColours[difficulty]
: type === "Bells"
? COLOUR_SET.vibrantYellow
: COLOUR_SET.vibrantGreen;
let color = COLOUR_SET.gray;
if (type === "Score") {
if (game === "chunithm") {
color =
GPT_CLIENT_IMPLEMENTATIONS["chunithm:Single"].difficultyColours[
difficulty as Difficulties["chunithm:Single"]
];
} else if (game === "ongeki") {
color =
GPT_CLIENT_IMPLEMENTATIONS["ongeki:Single"].difficultyColours[
difficulty as Difficulties["ongeki:Single"]
];
}
} else if (type === "Bells") {
color = COLOUR_SET.vibrantYellow;
} else {
color = COLOUR_SET.vibrantGreen;
}
const gradientId = type === "Score" ? difficulty : type;
const commonProps: Omit<LineSvgProps, "data"> = {
margin: { top: 30, bottom: 50, left: 50, right: 50 },
enableGridX: false,
xScale: { type: "linear", min: 0, max: data[0].data.length - 1 },
xScale: { type: "linear", min: 0, max: duration },
axisBottom: { format: (d: number) => formatTime(d) },
motionConfig: "stiff",
crosshairType: "x",
@@ -134,28 +175,53 @@ export default function OngekiScoreChart({
let component;
if (type === "Score") {
component = (
<ResponsiveLine
{...commonProps}
data={limitScoreGraph(data)}
yScale={{ type: "linear", min: 970000, max: 1010000 }}
yFormat={">-,.0f"}
axisLeft={{
tickValues: [970000, 990000, 1000000, 1007500, 1010000],
format: scoreToLamp,
}}
gridYValues={[970000, 980000, 990000, 1000000, 1007500, 1010000]}
enableGridY={true}
colors={strokeColor(difficulty)}
areaBaselineValue={970000}
tooltip={(d: PointTooltipProps) => (
<ChartTooltip>
{d.point.data.y === 969999 ? "< 970,000 " : d.point.data.yFormatted}@{" "}
{formatTime(d.point.data.x)}
</ChartTooltip>
)}
/>
);
if (game === "ongeki") {
component = (
<ResponsiveLine
{...commonProps}
data={limitScoreGraph(game, data)}
yScale={{ type: "linear", min: 970000, max: 1010000 }}
yFormat={">-,.0f"}
axisLeft={{
tickValues: [970000, 990000, 1000000, 1007500, 1010000],
format: getScoreYAxisNotch(game),
}}
gridYValues={[970000, 980000, 990000, 1000000, 1007500, 1010000]}
enableGridY={true}
colors={strokeColor(difficulty)}
areaBaselineValue={970000}
tooltip={(d: PointTooltipProps) => (
<ChartTooltip>
{d.point.data.y === 969999 ? "< 970,000 " : d.point.data.yFormatted}@{" "}
{formatTime(d.point.data.x)}
</ChartTooltip>
)}
/>
);
} else if (game === "chunithm") {
component = (
<ResponsiveLine
{...commonProps}
data={limitScoreGraph(game, data)}
yScale={{ type: "linear", min: 990_000, max: 1010_000 }}
yFormat={">-,.0f"}
axisLeft={{
tickValues: [990_000, 1000_000, 1005_000, 1007_500, 1009_000, 1010_000],
format: getScoreYAxisNotch(game),
}}
gridYValues={[990_000, 1000_000, 1005_000, 1007_500, 1009_000, 1010_000]}
enableGridY={true}
colors={strokeColor(difficulty)}
areaBaselineValue={990000}
tooltip={(d: PointTooltipProps) => (
<ChartTooltip>
{d.point.data.y === 989_999 ? "< 990,000 " : d.point.data.yFormatted}@{" "}
{formatTime(d.point.data.x)}
</ChartTooltip>
)}
/>
);
}
} else if (type === "Bells") {
component = (
<ResponsiveLine
@@ -163,14 +229,14 @@ export default function OngekiScoreChart({
data={data}
yScale={{
type: "linear",
min: bellFloor(data[0].data, totalBellCount),
min: bellFloor(data[0].data, totalBellCount!),
max: 0,
stacked: false,
}}
enableGridY={false}
axisLeft={{ format: (e: number) => Math.floor(e) === e && e }}
colors={strokeColor("BELLS")}
areaBaselineValue={bellFloor(data[0].data, totalBellCount)}
areaBaselineValue={bellFloor(data[0].data, totalBellCount!)}
tooltip={(d: PointTooltipProps) => (
<ChartTooltip>
MAX{d.point.data.y === 0 ? "" : d.point.data.y} @{" "}
@@ -179,19 +245,22 @@ export default function OngekiScoreChart({
)}
/>
);
} else {
} else if (type === "Life") {
const max = game === "ongeki" ? 100 : (data[0].data[0].y as number);
const suffix = game === "ongeki" ? "%" : "";
component = (
<ResponsiveLine
{...commonProps}
data={data}
yScale={{ type: "linear", min: 0, max: 100 }}
yScale={{ type: "linear", min: 0, max }}
enableGridY={false}
axisLeft={{ format: (d: number) => `${d}%` }}
axisLeft={{ format: (d: number) => `${d}${suffix}` }}
colors={strokeColor("BASIC")}
areaBaselineValue={0}
tooltip={(d: PointTooltipProps) => (
<ChartTooltip>
{d.point.data.y}% @ {formatTime(d.point.data.x)}
{d.point.data.y}
{suffix} @ {formatTime(d.point.data.x)}
</ChartTooltip>
)}
/>
@@ -4,6 +4,7 @@ import { IIDXGraphsComponent } from "./components/IIDXScoreDropdownParts";
import { ITGGraphsComponent } from "./components/ITGScoreDropdownParts";
import { JubeatGraphsComponent } from "./components/JubeatScoreDropdownParts";
import { OngekiGraphsComponent } from "./components/OngekiScoreDropdownParts";
import { ChunithmGraphsComponent } from "./components/ChunithmScoreDropdownParts";
export function GPTDropdownSettings(game: Game, playtype: Playtype): any {
if (game === "iidx") {
@@ -33,6 +34,11 @@ export function GPTDropdownSettings(game: Game, playtype: Playtype): any {
renderScoreInfo: true,
GraphComponent: OngekiGraphsComponent as any,
};
} else if (game === "chunithm") {
return {
renderScoreInfo: true,
GraphComponent: ChunithmGraphsComponent as any,
};
}
return {};
@@ -148,6 +148,7 @@ export default function PBDropdown({
pbData={data}
scoreState={scoreState}
chart={chart}
song={song}
/>
);
}
@@ -120,10 +120,11 @@ export default function ScoreDropdown({
onScoreUpdate={onScoreUpdate}
pbData={data}
chart={chart}
song={song}
/>
);
} else if (view === "vsPB") {
body = <PBCompare data={data} DocComponent={DocComponent} scoreState={scoreState} />;
body = <PBCompare data={data} DocComponent={DocComponent as any} scoreState={scoreState} />;
} else if (view === "manage") {
body = <DeleteScoreBtn score={thisScore} />;
} else if (view === "targets") {
@@ -0,0 +1,93 @@
import React, { useState } from "react";
import {
ChartDocument,
Difficulties,
PBScoreDocument,
ScoreData,
ScoreDocument,
SongDocument,
} from "tachi-common";
import GekichuScoreChart from "components/charts/GekichuScoreChart";
import SelectNav from "components/util/SelectNav";
import { Nav } from "react-bootstrap";
type ChartType = "Score" | "Life";
export function ChunithmGraphsComponent({
score,
chart,
song,
}: {
score: ScoreDocument<"chunithm:Single"> | PBScoreDocument<"chunithm:Single">;
chart: ChartDocument<"chunithm:Single">;
song: SongDocument<"chunithm">;
}) {
const [graph, setGraph] = useState<ChartType>("Score");
const available =
score.scoreData.optional.scoreGraph &&
score.scoreData.optional.lifeGraph &&
song.data.duration !== undefined;
return (
<>
<div className="col-12 d-flex justify-content-center">
<Nav variant="pills">
<SelectNav id="Score" value={graph} setValue={setGraph} disabled={!available}>
Score
</SelectNav>
<SelectNav id="Life" value={graph} setValue={setGraph} disabled={!available}>
Life
</SelectNav>
</Nav>
</div>
<div className="col-12">
{available ? (
<GraphComponent
type={graph}
scoreData={score.scoreData}
difficulty={chart.difficulty}
song={song}
/>
) : (
<div
className="d-flex align-items-center justify-content-center"
style={{ height: "200px" }}
>
<span className="text-body-secondary">No charts available</span>
</div>
)}
</div>
</>
);
}
function GraphComponent({
scoreData,
song,
difficulty,
type,
}: {
scoreData: ScoreData<"chunithm:Single">;
song: SongDocument<"chunithm">;
difficulty: Difficulties["chunithm:Single"];
type: ChartType;
}) {
const values =
type === "Score" ? scoreData.optional.scoreGraph! : scoreData.optional.lifeGraph!;
return (
<GekichuScoreChart
height="360px"
mobileHeight="175px"
type={type}
difficulty={difficulty}
data={[
{
id: type,
data: values.map((e, i) => ({ x: i, y: e })),
},
]}
game="chunithm"
duration={song.data.duration!}
/>
);
}
@@ -1,10 +1,16 @@
import { IsScore } from "util/asserts";
import { FormatGPTProfileRatingName, UppercaseFirst } from "util/misc";
import { FormatGPTProfileRatingName } from "util/misc";
import TimestampCell from "components/tables/cells/TimestampCell";
import ScoreCoreCells from "components/tables/game-core-cells/ScoreCoreCells";
import useScoreRatingAlg from "components/util/useScoreRatingAlg";
import React, { useContext, useEffect, useState } from "react";
import { ChartDocument, GetGPTString, PBScoreDocument, ScoreDocument } from "tachi-common";
import {
ChartDocument,
GetGPTString,
PBScoreDocument,
ScoreDocument,
SongDocument,
} from "tachi-common";
import { UGPTChartPBComposition } from "types/api-returns";
import { SetState } from "types/react";
import { UserContext } from "context/UserContext";
@@ -68,6 +74,7 @@ export default function DocumentComponent({
forceScoreData = false,
pbData,
chart,
song,
onScoreUpdate,
}: {
score: ScoreDocument | PBScoreDocument;
@@ -81,14 +88,17 @@ export default function DocumentComponent({
pbData: UGPTChartPBComposition;
forceScoreData?: boolean;
chart: ChartDocument;
song: SongDocument;
onScoreUpdate?: (sc: ScoreDocument) => void;
GraphComponent?:
| (({
score,
chart,
song,
}: {
score: ScoreDocument | PBScoreDocument;
chart: ChartDocument;
song: SongDocument;
}) => JSX.Element)
| null;
}) {
@@ -111,7 +121,7 @@ export default function DocumentComponent({
<div style={{ flex: 9 }}>
<div className="row h-100 justify-content-center">
{GraphComponent ? (
<GraphComponent chart={chart} score={score} />
<GraphComponent chart={chart} score={score} song={song} />
) : (
<div
className="d-flex align-items-center justify-content-center"
@@ -7,19 +7,22 @@ import {
PBScoreDocument,
ScoreData,
ScoreDocument,
SongDocument,
} from "tachi-common";
import OngekiScoreChart from "components/charts/OngekiScoreChart";
import GekichuScoreChart from "components/charts/GekichuScoreChart";
type ChartTypes = "Score" | "Bells" | "Life";
type ChartType = "Score" | "Bells" | "Life";
export function OngekiGraphsComponent({
score,
chart,
song,
}: {
score: ScoreDocument<"ongeki:Single"> | PBScoreDocument<"ongeki:Single">;
chart: ChartDocument<"ongeki:Single">;
song: SongDocument<"ongeki">;
}) {
const [graph, setGraph] = useState<ChartTypes>("Score");
const [graph, setGraph] = useState<ChartType>("Score");
const available =
score.scoreData.optional.scoreGraph &&
score.scoreData.optional.bellGraph &&
@@ -47,6 +50,7 @@ export function OngekiGraphsComponent({
<GraphComponent
type={graph}
scoreData={score.scoreData}
song={song}
difficulty={chart.difficulty}
/>
) : (
@@ -65,10 +69,12 @@ export function OngekiGraphsComponent({
function GraphComponent({
type,
scoreData,
song,
difficulty,
}: {
type: ChartTypes;
type: ChartType;
scoreData: ScoreData<"ongeki:Single">;
song: SongDocument<"ongeki">;
difficulty: Difficulties["ongeki:Single"];
}) {
const values =
@@ -78,7 +84,7 @@ function GraphComponent({
? scoreData.optional.bellGraph!
: scoreData.optional.lifeGraph!;
return (
<OngekiScoreChart
<GekichuScoreChart
height="360px"
mobileHeight="175px"
type={type}
@@ -90,6 +96,8 @@ function GraphComponent({
data: values.map((e, i) => ({ x: i, y: e })),
},
]}
game="ongeki"
duration={song.data.duration}
/>
);
}
@@ -11,6 +11,7 @@ export const CHUNITHM_CONF = {
songData: z.strictObject({
genre: z.string(),
displayVersion: z.string(),
duration: z.number().optional(),
}),
} as const satisfies INTERNAL_GAME_CONFIG;
@@ -87,6 +88,16 @@ export const CHUNITHM_SINGLE_CONF = {
optionalMetrics: {
...FAST_SLOW_MAXCOMBO,
scoreGraph: {
type: "GRAPH",
validate: p.isBetween(0, 1010000),
description: "The history of the projected score, queried in one-second intervals.",
},
lifeGraph: {
type: "GRAPH",
validate: p.isBetween(0, 999),
description: "Challenge gauge history, queried in one-second intervals.",
},
},
scoreRatingAlgs: {
+1
View File
@@ -19,6 +19,7 @@ export const ONGEKI_CONF = {
"LUNATIC",
"ボーナストラック",
]),
duration: z.number(),
}),
} as const satisfies INTERNAL_GAME_CONFIG;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,146 @@
/* eslint-disable no-await-in-loop */
import { Command, InvalidArgumentError } from "commander";
import { exec } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
import { ChartDocument, SongDocument } from "tachi-common";
import { ReadCollection, WriteCollection } from "../../util";
import { XMLParser } from "fast-xml-parser";
import { PathLike } from "fs";
const command = new Command()
.requiredOption("-v, --vgms <path-to-vgmstream-cli>")
.requiredOption("-d, --data <path-with-AXXX>")
.requiredOption("-g, --game <ongeki|chunithm>")
.parse(process.argv);
const options = command.opts();
const vgmsPath = options.vgms;
const optPath = options.data;
const game = options.game;
const readOpt = async (
musicPath: string,
charts: ChartDocument<"ongeki:Single" | "chunithm:Single">[],
songs: SongDocument<"ongeki" | "chunithm">[]
) => {
let musicDir: string[];
try {
musicDir = await fs.readdir(musicPath);
} catch (_) {
// musicless opt, most likely
return;
}
const parser = new XMLParser();
for (const songPath of musicDir) {
const p = path.join(musicPath, songPath);
if (!(await fs.stat(p)).isDirectory()) {
console.log(`${p}: not a directory`);
continue;
}
const songDir = await fs.readdir(p);
for (const f of songDir) {
if (f === "Music.xml") {
const parsed = parser.parse(await fs.readFile(path.join(p, f)));
let sourceId;
let id;
if (game === "ongeki") {
sourceId = parsed.MusicData.MusicSourceName.id;
id = parsed.MusicData.Name.id;
} else {
sourceId = parsed.MusicData.cueFileName.id;
id = parsed.MusicData.name.id;
}
const chart = charts.find((c) => c.data.inGameID === id);
if (chart === undefined) {
if (!(game === "chunithm" && id >= 8000)) {
console.error(`Song #${id}: not present in the seeds`);
}
continue;
}
const song = songs.find((s) => s.id === chart.songID);
if (song === undefined) {
console.error(`Song #${id}: orphan`);
continue;
}
if ("duration" in song.data) {
continue;
}
const padded4 = `${sourceId}`.padStart(4, "0");
const padded6 = `${sourceId}`.padStart(6, "0");
let cuePath: PathLike;
if (game === "ongeki") {
cuePath = path.join(
p,
"..",
"..",
"musicsource",
`musicsource${padded4}`,
`music${padded4}.awb`
);
} else {
cuePath = path.join(
p,
"..",
"..",
"cueFile",
`cueFile${padded6}`,
`music${padded4}.awb`
);
}
try {
await fs.stat(cuePath);
} catch (_) {
console.error(`Song ${id}: MISSING (expected: ${cuePath}) [${song.title}]`);
continue;
}
/* eslint-disable-next-line */
await new Promise<void>((resolve, reject) => {
exec(`${vgmsPath} -m -I ${cuePath}`, (err, stdout) => {
if (err) {
reject(new Error(`${err}`));
}
const res = JSON.parse(stdout);
if (res.sampleRate !== 48000) {
console.log(`Warning: Song #${id}'s sample rate is ${res.sampleRate}`);
}
const duration = res.numberOfSamples / res.sampleRate;
song.data.duration = Number(duration.toFixed(3));
console.log(`Song #${id}: ${song.data.duration}`);
resolve();
});
});
}
}
}
};
const main = async () => {
if (game !== "ongeki" && game !== "chunithm") {
throw new InvalidArgumentError("Bad game");
}
const charts = ReadCollection(`charts-${game}.json`);
const songs = ReadCollection(`songs-${game}.json`);
const dir = await fs.readdir(optPath);
const promises: Promise<void>[] = [];
for (const opt of dir) {
if (opt.startsWith("A") && opt.length === 4) {
promises.push(readOpt(path.join(optPath, opt, "music"), charts, songs));
}
}
await Promise.all(promises);
WriteCollection(`songs-${game}.json`, songs);
};
main();
@@ -15,10 +15,11 @@
"artist": "分島花音「selector infected WIXOSS」",
"data": {
"displayVersion": "crystalplus",
"genre": "POPS & ANIME"
"genre": "POPS & ANIME",
"duration": 120
},
"id": 956,
"searchTerms": [],
"title": "killy killy JOKER"
}
]
]
@@ -3,7 +3,8 @@
"altTitles": ["SENOTETOHETSUTEITSUTENNO"],
"artist": "本城香澄(CV:岩橋由佳)「Re:ステージ!プリズムステップ」",
"data": {
"genre": "POPS&ANIME"
"genre": "POPS&ANIME",
"duration": 120
},
"id": 683,
"searchTerms": [],
+2
View File
@@ -1470,6 +1470,7 @@ export const TestingOngekiSongConverter: SongDocument<"ongeki"> = {
artist: "本城香澄(CV:岩橋由佳)「Re:ステージ!プリズムステップ」",
data: {
genre: "POPS&ANIME",
duration: 120,
},
id: 683,
searchTerms: [],
@@ -1496,6 +1497,7 @@ export const TestingChunithmSongConverter: SongDocument<"chunithm"> = {
data: {
displayVersion: "crystalplus",
genre: "POPS & ANIME",
duration: 120,
},
id: 956,
searchTerms: [],