diff --git a/nise-backend/src/main/kotlin/com/nisemoe/nise/Models.kt b/nise-backend/src/main/kotlin/com/nisemoe/nise/Models.kt index c0ecfe9..f7fff2a 100644 --- a/nise-backend/src/main/kotlin/com/nisemoe/nise/Models.kt +++ b/nise-backend/src/main/kotlin/com/nisemoe/nise/Models.kt @@ -102,6 +102,15 @@ data class ReplayViewerData( val judgements: List ) +data class ReplayPairViewerData( + val beatmap: String, + val replay1: String, + val replay2: String, + val mods: Int, + val judgements1: List, + val judgements2: List +) + data class ReplayData( val replay_id: Long, val user_id: Int, diff --git a/nise-backend/src/main/kotlin/com/nisemoe/nise/controller/ScoreController.kt b/nise-backend/src/main/kotlin/com/nisemoe/nise/controller/ScoreController.kt index 3fd5581..fd04344 100644 --- a/nise-backend/src/main/kotlin/com/nisemoe/nise/controller/ScoreController.kt +++ b/nise-backend/src/main/kotlin/com/nisemoe/nise/controller/ScoreController.kt @@ -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 { + 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 { val replay1Data = this.scoreService.getReplayData(replay1Id) diff --git a/nise-backend/src/main/kotlin/com/nisemoe/nise/database/ScoreService.kt b/nise-backend/src/main/kotlin/com/nisemoe/nise/database/ScoreService.kt index 1d50cb2..3ea2b3e 100644 --- a/nise-backend/src/main/kotlin/com/nisemoe/nise/database/ScoreService.kt +++ b/nise-backend/src/main/kotlin/com/nisemoe/nise/database/ScoreService.kt @@ -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 { 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!!) ) } } diff --git a/nise-replay-viewer/index.html b/nise-replay-viewer/index.html index 6ec771e..5ba5ebe 100644 --- a/nise-replay-viewer/index.html +++ b/nise-replay-viewer/index.html @@ -4,7 +4,7 @@ - /replay/ + /replay/ - nise.moe diff --git a/nise-replay-viewer/public/cursor2.png b/nise-replay-viewer/public/cursor2.png new file mode 100644 index 0000000..8f789fe Binary files /dev/null and b/nise-replay-viewer/public/cursor2.png differ diff --git a/nise-replay-viewer/src/interface/App.tsx b/nise-replay-viewer/src/interface/App.tsx index 6808e93..bf96af4 100644 --- a/nise-replay-viewer/src/interface/App.tsx +++ b/nise-replay-viewer/src/interface/App.tsx @@ -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(0); + const loadReplay = async (replayId: number) => { + await OsuRenderer.loadReplayFromUrl(replayId); + }; + + const loadReplayPair = async (replayId1: number, replayId2: number) => { + await OsuRenderer.loadReplayPairFromUrl(replayId1, replayId2); + } useEffect(() => { - let pathReplayId = Number.parseInt(location.pathname.slice(1, location.pathname.length)); + // 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); - const loadReplay = async () => { - if(document.location.hostname === "localhost") { - await OsuRenderer.loadReplayFromUrl(`http://localhost:8080/score/${pathReplayId}/replay`, pathReplayId); - return; + 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); } - await OsuRenderer.loadReplayFromUrl(`https://nise.moe/api/score/${pathReplayId}/replay`, pathReplayId); - }; - - if(replayId !== pathReplayId) { - setReplayId(pathReplayId); - loadReplay(); } }, [location.pathname]); diff --git a/nise-replay-viewer/src/interface/composites/Menu.tsx b/nise-replay-viewer/src/interface/composites/Menu.tsx index 2167058..17c5c8b 100644 --- a/nise-replay-viewer/src/interface/composites/Menu.tsx +++ b/nise-replay-viewer/src/interface/composites/Menu.tsx @@ -23,11 +23,16 @@ export function Navbar() { {OsuRenderer.beatmap && ( <> - {" "} + {OsuRenderer.replay2 == null && ( View on nise.moe - + )} + {OsuRenderer.replay2 && ( + + View on nise.moe + )} )} @@ -37,7 +42,7 @@ export function Navbar() { {mods?.map((mod) => { return ( ); diff --git a/nise-replay-viewer/src/osu/Drawer.ts b/nise-replay-viewer/src/osu/Drawer.ts index 3ca9f91..b93bb98 100644 --- a/nise-replay-viewer/src/osu/Drawer.ts +++ b/nise-replay-viewer/src/osu/Drawer.ts @@ -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, diff --git a/nise-replay-viewer/src/osu/OsuRenderer.ts b/nise-replay-viewer/src/osu/OsuRenderer.ts index 0f9a7ca..93de7d7 100644 --- a/nise-replay-viewer/src/osu/OsuRenderer.ts +++ b/nise-replay-viewer/src/osu/OsuRenderer.ts @@ -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; } diff --git a/nise-replay-viewer/src/utils.ts b/nise-replay-viewer/src/utils.ts index 022fc17..21ef2a8 100644 --- a/nise-replay-viewer/src/utils.ts +++ b/nise-replay-viewer/src/utils.ts @@ -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) {