Added replay-viewer support for replay pairs

This commit is contained in:
nise.moe 2024-03-04 15:26:11 +01:00
parent 8ca3fa70b6
commit e413b2d76e
10 changed files with 154 additions and 36 deletions

View File

@ -102,6 +102,15 @@ data class ReplayViewerData(
val judgements: List<CircleguardService.ScoreJudgement>
)
data class ReplayPairViewerData(
val beatmap: String,
val replay1: String,
val replay2: String,
val mods: Int,
val judgements1: List<CircleguardService.ScoreJudgement>,
val judgements2: List<CircleguardService.ScoreJudgement>
)
data class ReplayData(
val replay_id: Long,
val user_id: Int,

View File

@ -1,12 +1,8 @@
package com.nisemoe.nise.controller
import com.nisemoe.nise.Format
import com.nisemoe.nise.ReplayData
import com.nisemoe.nise.ReplayPair
import com.nisemoe.nise.ReplayViewerData
import com.nisemoe.nise.*
import com.nisemoe.nise.database.ScoreService
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.CrossOrigin
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RestController
@ -32,6 +28,14 @@ class ScoreController(
return ResponseEntity.ok(replayData)
}
@GetMapping("pair/{replay1Id}/{replay2Id}/replay")
fun getPairReplays(@PathVariable replay1Id: Long, @PathVariable replay2Id: Long): ResponseEntity<ReplayPairViewerData> {
val replayPairViewerData = this.scoreService.getReplayPairViewerData(replay1Id, replay2Id)
?: return ResponseEntity.notFound().build()
return ResponseEntity.ok(replayPairViewerData)
}
@GetMapping("pair/{replay1Id}/{replay2Id}")
fun getPairDetails(@PathVariable replay1Id: Long, @PathVariable replay2Id: Long): ResponseEntity<ReplayPair> {
val replay1Data = this.scoreService.getReplayData(replay1Id)

View File

@ -1,5 +1,6 @@
package com.nisemoe.nise.database
import com.nisemoe.generated.enums.JudgementType
import com.nisemoe.generated.tables.records.ScoresJudgementsRecord
import com.nisemoe.generated.tables.records.ScoresRecord
import com.nisemoe.generated.tables.references.*
@ -48,6 +49,20 @@ class ScoreService(
.mapNotNull { (scoreType, title) -> db.get(scoreType)?.let { ReplayDataChart(title, it.filterNotNull()) } }
}
fun getReplayPairViewerData(replay1Id: Long, replay2Id: Long): ReplayPairViewerData? {
val replay1 = getReplayViewerData(replay1Id) ?: return null
val replay2 = getReplayViewerData(replay2Id) ?: return null
return ReplayPairViewerData(
beatmap = replay1.beatmap,
replay1 = replay1.replay,
replay2 = replay2.replay,
mods = replay1.mods,
judgements1 = replay1.judgements,
judgements2 = replay2.judgements
)
}
fun getReplayViewerData(replayId: Long): ReplayViewerData? {
val beatmapId = dslContext.select(SCORES.BEATMAP_ID)
.from(SCORES)
@ -463,6 +478,15 @@ class ScoreService(
replayData.comparable_error_skewness = otherScores.get("avg_error_skewness", Double::class.java)
}
fun mapLegacyJudgement(judgementType: JudgementType): CircleguardService.JudgementType {
return when(judgementType) {
JudgementType.Miss -> CircleguardService.JudgementType.MISS
JudgementType.`300` -> CircleguardService.JudgementType.THREE_HUNDRED
JudgementType.`100` -> CircleguardService.JudgementType.ONE_HUNDRED
JudgementType.`50` -> CircleguardService.JudgementType.FIFTY
}
}
fun getJudgements(replayId: Long): List<CircleguardService.ScoreJudgement> {
val judgementsRecord = dslContext.select(SCORES.JUDGEMENTS)
.from(SCORES)
@ -486,7 +510,7 @@ class ScoreService(
distance_center = it.distanceCenter!!,
distance_edge = it.distanceEdge!!,
time = it.time!!,
type = CircleguardService.JudgementType.valueOf(it.type!!.literal),
type = mapLegacyJudgement(it.type!!)
)
}
}

View File

@ -4,7 +4,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>/replay/</title>
<title>/replay/ - nise.moe</title>
<link rel="icon" type="image/x-icon" href="https://nise.moe/assets/favicon.ico">
<!-- Embed data -->
<meta property="og:title" content="/nise.moe/ - osu!cheaters finder">

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@ -2,28 +2,35 @@ import {AboutDialog} from "./composites/about-dialog";
import {Navbar} from "./composites/Menu";
import {SongSlider} from "./composites/song-slider";
import {Helper} from "./composites/helper";
import {useEffect, useState} from "react";
import {useEffect} from "react";
import {OsuRenderer} from "@/osu/OsuRenderer";
import {Stats} from "@/interface/composites/stats";
export function App() {
const [replayId, setReplayId] = useState<number>(0);
useEffect(() => {
let pathReplayId = Number.parseInt(location.pathname.slice(1, location.pathname.length));
const loadReplay = async () => {
if(document.location.hostname === "localhost") {
await OsuRenderer.loadReplayFromUrl(`http://localhost:8080/score/${pathReplayId}/replay`, pathReplayId);
return;
}
await OsuRenderer.loadReplayFromUrl(`https://nise.moe/api/score/${pathReplayId}/replay`, pathReplayId);
const loadReplay = async (replayId: number) => {
await OsuRenderer.loadReplayFromUrl(replayId);
};
if(replayId !== pathReplayId) {
setReplayId(pathReplayId);
loadReplay();
const loadReplayPair = async (replayId1: number, replayId2: number) => {
await OsuRenderer.loadReplayPairFromUrl(replayId1, replayId2);
}
useEffect(() => {
// This pattern matches one or more digits followed by an optional slash and any characters (non-greedy)
const pathRegex = /^\/(\d+)(?:\/(\d+))?/;
const match = location.pathname.match(pathRegex);
if (match) {
// match[1] will contain the first ID, match[2] (if present) will contain the second ID
const pathReplayId1 = Number.parseInt(match[1]);
const pathReplayId2 = match[2] ? Number.parseInt(match[2]) : null;
if(pathReplayId2 != null) {
loadReplayPair(pathReplayId1, pathReplayId2);
} else {
loadReplay(pathReplayId1);
}
}
}, [location.pathname]);

View File

@ -23,11 +23,16 @@ export function Navbar() {
{OsuRenderer.beatmap && (
<>
{" "}
{OsuRenderer.replay2 == null && (
<a href={"https://nise.moe/s/" + OsuRenderer.replay.info.id} target="_blank"
className="flex items-center rounded-sm px-3 py-1.5 text-sm font-medium outline-none focus:bg-accent hover:bg-accent">
View on nise.moe
</a>
</a>)}
{OsuRenderer.replay2 && (
<a href={"https://nise.moe/p/" + OsuRenderer.replay.info.id + "/" + OsuRenderer.replay2.info.id} target="_blank"
className="flex items-center rounded-sm px-3 py-1.5 text-sm font-medium outline-none focus:bg-accent hover:bg-accent">
View on nise.moe
</a>)}
</>
)}
</div>
@ -37,7 +42,7 @@ export function Navbar() {
{mods?.map((mod) => {
return (
<img
src={`./mod_${mod.acronym.toLowerCase()}.png`}
src={`/mod_${mod.acronym.toLowerCase()}.png`}
className="h-5"
></img>
);

View File

@ -10,6 +10,7 @@ export class Drawer {
static images = {
cursor: undefined as any as p5.Image,
cursor2: undefined as any as p5.Image,
cursortrail: undefined as any as p5.Image,
hitcircle: undefined as any as p5.Image,
hitcircleoverlay: undefined as any as p5.Image,
@ -183,6 +184,7 @@ export class Drawer {
static drawCursorPath(
cursorImage: p5.Image,
path: {
position: Vector2;
time: number;
@ -251,7 +253,7 @@ export class Drawer {
if (cursor.position)
Drawer.p.image(
this.images.cursor,
cursorImage,
cursor.position.x,
cursor.position.y,
55,

View File

@ -1,4 +1,4 @@
import {HitResult, LegacyReplayFrame, Score, Vector2} from "osu-classes";
import {HitResult, LegacyReplayFrame, Replay, Score, Vector2} from "osu-classes";
import {BeatmapDecoder, BeatmapEncoder} from "osu-parsers";
import {
Circle,
@ -15,6 +15,7 @@ import {Vec2} from "@osujs/math";
import {clamp, getBeatmap, getReplay} from "@/utils";
import EventEmitter from "eventemitter3";
import {toast} from "sonner";
import p5 from "p5";
export enum OsuRendererEvents {
UPDATE = "UPDATE",
@ -56,6 +57,8 @@ export class OsuRenderer {
static event = new OsuRendererBridge();
static judgements: Judgements[] = [];
static judgements2: Judgements[] = [];
static speedMultiplier = 1;
static settings: OsuRendererSettings = {
showCursorPath: true,
@ -73,7 +76,10 @@ export class OsuRenderer {
static beatmap: StandardBeatmap;
static og_beatmap: StandardBeatmap;
static replay: Score;
static replay2: Score | undefined;
static og_replay_mods: StandardModCombination;
static forceHR: boolean | undefined = undefined;
@ -137,7 +143,11 @@ export class OsuRenderer {
this.lastRender = Date.now();
this.renderPath();
this.renderPath(this.replay.replay!, Drawer.images.cursor);
if(this.replay2) {
this.renderPath(this.replay2.replay!, Drawer.images.cursor2);
}
Drawer.drawField();
}
@ -216,9 +226,56 @@ export class OsuRenderer {
});
}
static async loadReplayFromUrl(url: string, replayId: number) {
static getApiUrl(): string {
return document.location.hostname === "localhost"
? `http://localhost:8080`
: `https://nise.moe/api`;
}
static async loadReplayPairFromUrl(replayId1: number, replayId2: number) {
OsuRenderer.purge();
const apiUrl = `${this.getApiUrl()}/pair/${replayId1}/${replayId2}/replay`;
const response = await fetch(apiUrl, {
headers: {
'X-NISE-REPLAY': '20240303'
}
});
let data;
if (!response.ok) {
toast.error("Failed to load replay :(");
return Promise.reject();
} else {
data = await response.json();
}
const { beatmap, replay1, replay2, mods, judgements1, judgements2 } = data;
// Load replays
const i_replay1 = await getReplay(replay1);
i_replay1.info.id = replayId1;
i_replay1.info.rawMods = mods;
i_replay1.info.mods = new StandardModCombination(mods);
const i_replay2 = await getReplay(replay2);
i_replay2.info.id = replayId2;
i_replay2.info.rawMods = mods;
i_replay2.info.mods = new StandardModCombination(mods);
const i_beatmap = await getBeatmap(beatmap, i_replay1);
OsuRenderer.setPairOptions(i_beatmap, i_replay1, i_replay2, judgements1, judgements2);
this.event.emit(OsuRendererEvents.LOAD);
}
static async loadReplayFromUrl(replayId: number) {
OsuRenderer.purge();
const url = `${this.getApiUrl()}/score/${replayId}/replay`;
const response = await fetch(url, {
headers: {
'X-NISE-REPLAY': '20240303'
@ -247,6 +304,22 @@ export class OsuRenderer {
this.event.emit(OsuRendererEvents.LOAD);
}
static setPairOptions(beatmap: StandardBeatmap, replay1: Score, replay2: Score, judgements1: Judgements[], judgements2: Judgements[]) {
this.judgements = judgements1;
this.judgements2 = judgements2;
this.forceHR = undefined;
this.replay = replay1;
this.replay2 = replay2;
this.beatmap = beatmap;
this.og_beatmap = beatmap.clone();
this.og_replay_mods = replay1.info.mods?.clone() as StandardModCombination;
this.setMetadata({
AR: this.beatmap.difficulty.approachRate,
CS: this.beatmap.difficulty.circleSize,
OD: this.beatmap.difficulty.overallDifficulty,
});
}
static setOptions(beatmap: StandardBeatmap, replay: Score, judgements: Judgements[] ) {
this.judgements = judgements;
this.forceHR = undefined;
@ -457,8 +530,8 @@ export class OsuRenderer {
return arScale;
}
private static renderPath() {
const frames = this.replay.replay!.frames as LegacyReplayFrame[];
private static renderPath(replay: Replay, cursorImage: p5.Image) {
const frames = replay.frames as LegacyReplayFrame[];
const renderFrames: {
position: Vector2;
time: number;
@ -505,7 +578,7 @@ export class OsuRenderer {
});
}
Drawer.drawCursorPath(renderFrames, cursorPushed);
Drawer.drawCursorPath(cursorImage, renderFrames, cursorPushed);
return cursorPushed;
}

View File

@ -73,12 +73,6 @@ export const state = create<{
speed: 1
}));
state.subscribe((newState) => {
if (newState.beatmap) {
document.title = `Viewing replay #${newState.replay?.info.id}`
}
})
export let p: p5;
export function setEnv(_p: p5) {