mirror of
https://github.com/zkldi/Tachi.git
synced 2026-09-27 17:38:11 +03:00
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:
+123
-54
@@ -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: {
|
||||
|
||||
@@ -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": [],
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
Reference in New Issue
Block a user