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 a986d04..c0ecfe9 100644 --- a/nise-backend/src/main/kotlin/com/nisemoe/nise/Models.kt +++ b/nise-backend/src/main/kotlin/com/nisemoe/nise/Models.kt @@ -1,5 +1,6 @@ package com.nisemoe.nise +import com.nisemoe.nise.integrations.CircleguardService import kotlinx.serialization.Serializable import java.time.OffsetDateTime @@ -97,7 +98,8 @@ data class ReplayDataSimilarScore( data class ReplayViewerData( val beatmap: String, val replay: String, - val mods: Int + val mods: Int, + val judgements: List ) data class ReplayData( 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 b929f02..3fd5581 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 @@ -16,7 +16,6 @@ class ScoreController( private val scoreService: ScoreService ) { - @CrossOrigin(origins = ["http://wizardly_nash.local"]) @GetMapping("score/{replayId}/replay") fun getReplay(@PathVariable replayId: Long): ResponseEntity { val replay = this.scoreService.getReplayViewerData(replayId) 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 ef5ad07..063043a 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 @@ -22,7 +22,6 @@ import kotlin.math.roundToInt @Service class ScoreService( private val dslContext: DSLContext, - private val circleguardService: CircleguardService, private val beatmapService: BeatmapService, private val authService: AuthService, private val compressJudgements: CompressJudgements @@ -66,6 +65,7 @@ class ScoreService( return ReplayViewerData( beatmap = result.get(BEATMAPS.BEATMAP_FILE, String::class.java), replay = String(replay, Charsets.UTF_8).trimEnd(','), + judgements = getJudgements(replayId), mods = mods ) } @@ -440,6 +440,37 @@ class ScoreService( replayData.comparable_error_skewness = otherScores.get("avg_error_skewness", Double::class.java) } + fun getJudgements(replayId: Long): List { + val judgementsRecord = dslContext.select(SCORES.JUDGEMENTS) + .from(SCORES) + .where(SCORES.REPLAY_ID.eq(replayId)) + .fetchOneInto(ScoresRecord::class.java) + + if(judgementsRecord?.judgements == null) { + val scoreId = dslContext.select(SCORES.ID) + .from(SCORES) + .where(SCORES.REPLAY_ID.eq(replayId)) + .fetchOneInto(Int::class.java) + + val judgementRecords = dslContext.selectFrom(SCORES_JUDGEMENTS) + .where(SCORES_JUDGEMENTS.SCORE_ID.eq(scoreId)) + .fetchInto(ScoresJudgementsRecord::class.java) + return judgementRecords.map { + CircleguardService.ScoreJudgement( + x = it.x!!, + y = it.y!!, + error = it.error!!, + distance_center = it.distanceCenter!!, + distance_edge = it.distanceEdge!!, + time = it.time!!, + type = CircleguardService.JudgementType.valueOf(it.type!!.literal), + ) + } + } + + return compressJudgements.deserialize(judgementsRecord.judgements!!) + } + fun getHitDistribution(scoreId: Int): Map { val judgementsRecord = dslContext.select(SCORES.JUDGEMENTS) .from(SCORES) diff --git a/nise-backend/src/main/kotlin/com/nisemoe/nise/scheduler/FixOldScores.kt b/nise-backend/src/main/kotlin/com/nisemoe/nise/scheduler/FixOldScores.kt index 6512873..22dd5b4 100644 --- a/nise-backend/src/main/kotlin/com/nisemoe/nise/scheduler/FixOldScores.kt +++ b/nise-backend/src/main/kotlin/com/nisemoe/nise/scheduler/FixOldScores.kt @@ -18,7 +18,7 @@ import org.springframework.context.annotation.Profile import org.springframework.scheduling.annotation.Scheduled import org.springframework.stereotype.Service -@Profile("old_scores") +@Profile("fix:scores") @Service class FixOldScores( private val dslContext: DSLContext, @@ -29,12 +29,12 @@ class FixOldScores( companion object { - const val CURRENT_VERSION = 6 + const val CURRENT_VERSION = 7 } @Value("\${OLD_SCORES_WORKERS:4}") - private var workers: Int = 4 + private var workers: Int = 6 @Value("\${OLD_SCORES_PAGE_SIZE:5000}") private var pageSize: Int = 5000 diff --git a/nise-backend/src/main/kotlin/com/nisemoe/nise/scheduler/ImportScores.kt b/nise-backend/src/main/kotlin/com/nisemoe/nise/scheduler/ImportScores.kt index 37c6de2..d7cb3e3 100644 --- a/nise-backend/src/main/kotlin/com/nisemoe/nise/scheduler/ImportScores.kt +++ b/nise-backend/src/main/kotlin/com/nisemoe/nise/scheduler/ImportScores.kt @@ -69,7 +69,7 @@ class ImportScores( companion object { - const val CURRENT_VERSION = 6 + const val CURRENT_VERSION = 7 const val SLEEP_AFTER_API_CALL = 500L const val UPDATE_USER_EVERY_DAYS = 7L const val UPDATE_BANNED_USERS_EVERY_DAYS = 3L @@ -207,7 +207,7 @@ class ImportScores( for(topScore in allUserScores) { val beatmapExists = dslContext.fetchExists(BEATMAPS, BEATMAPS.BEATMAP_ID.eq(topScore.beatmap!!.id)) if (!beatmapExists) { - val beatmapFile = this.osuApi.getBeatmapFile(beatmapId = beatmap.id) + val beatmapFile = this.osuApi.getBeatmapFile(beatmapId = topScore.beatmap.id) dslContext.insertInto(BEATMAPS) .set(BEATMAPS.BEATMAP_ID, topScore.beatmap.id) .set(BEATMAPS.BEATMAPSET_ID, topScore.beatmapset!!.id) diff --git a/nise-replay-viewer/.nvmrc b/nise-replay-viewer/.nvmrc new file mode 100644 index 0000000..df5f0bc --- /dev/null +++ b/nise-replay-viewer/.nvmrc @@ -0,0 +1 @@ +18.19 diff --git a/nise-replay-viewer/public/ia-quattro-400-normal.woff2 b/nise-replay-viewer/public/ia-quattro-400-normal.woff2 new file mode 100644 index 0000000..a25cdbc Binary files /dev/null and b/nise-replay-viewer/public/ia-quattro-400-normal.woff2 differ diff --git a/nise-replay-viewer/public/ia-quattro-700-normal.woff2 b/nise-replay-viewer/public/ia-quattro-700-normal.woff2 new file mode 100644 index 0000000..d4c3f63 Binary files /dev/null and b/nise-replay-viewer/public/ia-quattro-700-normal.woff2 differ diff --git a/nise-replay-viewer/public/mouse.svg b/nise-replay-viewer/public/mouse.svg index cacfb58..d6cebc2 100644 --- a/nise-replay-viewer/public/mouse.svg +++ b/nise-replay-viewer/public/mouse.svg @@ -1,6 +1,6 @@ - - + + diff --git a/nise-replay-viewer/src/hooks/canvasControls.ts b/nise-replay-viewer/src/hooks/canvasControls.ts index a5905e5..0d9a8d4 100644 --- a/nise-replay-viewer/src/hooks/canvasControls.ts +++ b/nise-replay-viewer/src/hooks/canvasControls.ts @@ -13,13 +13,13 @@ export class CanvasControlHooks { static setupCanvasControls() { canvasDragStart = p.createVector(0, 0); canvasDragging = false; - canvasMultiplier = p.windowHeight / 384 / 2; - canvasTranslation = p.createVector(512, 384 / 3); + canvasMultiplier = p.windowHeight / 384 / 1.5; + canvasTranslation = p.createVector(512 / 1.5, 384 / 8); } @Hook(Events.mousePressed) static mousePressed() { - if (p.mouseButton === p.CENTER) { + if (p.mouseButton === p.LEFT) { canvasDragging = true; canvasDragStart = p.createVector(p.mouseX, p.mouseY); } diff --git a/nise-replay-viewer/src/interface/App.tsx b/nise-replay-viewer/src/interface/App.tsx index 3c763f2..6808e93 100644 --- a/nise-replay-viewer/src/interface/App.tsx +++ b/nise-replay-viewer/src/interface/App.tsx @@ -1,24 +1,24 @@ import {AboutDialog} from "./composites/about-dialog"; -import {AnalysisSheet} from "./composites/analysis.-sheet"; import {Navbar} from "./composites/Menu"; import {SongSlider} from "./composites/song-slider"; import {Helper} from "./composites/helper"; import {useEffect, useState} from "react"; import {OsuRenderer} from "@/osu/OsuRenderer"; +import {Stats} from "@/interface/composites/stats"; export function App() { - const [replayId, setReplayId] = useState(""); + const [replayId, setReplayId] = useState(0); useEffect(() => { - let pathReplayId = location.pathname.slice(1, location.pathname.length); + 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`); + await OsuRenderer.loadReplayFromUrl(`http://localhost:8080/score/${pathReplayId}/replay`, pathReplayId); return; } - await OsuRenderer.loadReplayFromUrl(`https://nise.moe/api/score/${pathReplayId}/replay`); + await OsuRenderer.loadReplayFromUrl(`https://nise.moe/api/score/${pathReplayId}/replay`, pathReplayId); }; if(replayId !== pathReplayId) { @@ -31,8 +31,8 @@ export function App() { <> - + ); diff --git a/nise-replay-viewer/src/interface/composites/Menu.tsx b/nise-replay-viewer/src/interface/composites/Menu.tsx index ae3a4db..2167058 100644 --- a/nise-replay-viewer/src/interface/composites/Menu.tsx +++ b/nise-replay-viewer/src/interface/composites/Menu.tsx @@ -1,82 +1,62 @@ -import { - Menubar, - MenubarContent, - MenubarItem, - MenubarMenu, - MenubarTrigger, -} from "@/interface/components/ui/menubar"; -import { OsuRenderer } from "@/osu/OsuRenderer"; -import { state } from "@/utils"; +import {Menubar,} from "@/interface/components/ui/menubar"; +import {OsuRenderer} from "@/osu/OsuRenderer"; +import {state} from "@/utils"; export function Navbar() { - const { beatmap, mods } = state(); + const {beatmap, mods} = state(); return ( - -
-
- -

- Replay Viewer -

-
- - - File - - { - state.setState({ aboutDialog: true }); - }} - > - About - - - - - {OsuRenderer.beatmap && ( - <> - {" "} - - Analyzer - - { - state.setState({ dataAnalysisDialog: true }); - }} - > - gRDA - - - - - )} -
- - {beatmap && ( -
- {mods?.map((mod) => { - return ( - - ); - })} -
-

Currently Viewing

- -

- {beatmap?.metadata.artist} - {beatmap?.metadata.title} -

+ +
+
+ +

+ /replay/ +

- + + + + {OsuRenderer.beatmap && ( + <> + {" "} + + View on nise.moe + + + )}
- )} -
+ + {beatmap && ( +
+ {mods?.map((mod) => { + return ( + + ); + })} +
+

Currently Viewing

+ +

+ {beatmap?.metadata.artist} - {beatmap?.metadata.title} +

+
+ +
+ )} + ); } diff --git a/nise-replay-viewer/src/interface/composites/about-dialog.tsx b/nise-replay-viewer/src/interface/composites/about-dialog.tsx index 7c27f8b..64c6c41 100644 --- a/nise-replay-viewer/src/interface/composites/about-dialog.tsx +++ b/nise-replay-viewer/src/interface/composites/about-dialog.tsx @@ -1,7 +1,6 @@ -import { Button } from "@/interface/components/ui/button"; import { Dialog, DialogContent } from "@/interface/components/ui/dialog"; -import { Badge } from "@/interface/components/ui/badge"; import { state } from "@/utils"; + export function AboutDialog() { const { aboutDialog } = state(); return ( diff --git a/nise-replay-viewer/src/interface/composites/analysis.-sheet.tsx b/nise-replay-viewer/src/interface/composites/analysis.-sheet.tsx deleted file mode 100644 index 8e00bae..0000000 --- a/nise-replay-viewer/src/interface/composites/analysis.-sheet.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import { - Sheet, - SheetContent, - SheetDescription, - SheetHeader, - SheetTitle, -} from "@/interface/components/ui/sheet"; -import { Button } from "@/interface/components/ui/button"; -import { BarChart, XAxis, Bar, ResponsiveContainer } from "recharts"; -import { state } from "@/utils"; -import { gRDA } from "@/osu/Analysis"; -import { OsuRenderer } from "@/osu/OsuRenderer"; - -export function AnalysisSheet() { - const { dataAnalysisDialog, grda } = state(); - - return ( - { - state.setState({ dataAnalysisDialog: opened }); - }} - > - - - gRDA - - Most of the information provided here is forensic and can be used to - detect specific types of cheats, such as Timewarp and Relax. This - process might take up to a minute to do and collect all information. - - - - {grda && ( -
-

- Response -

-
-

Frametime Averages:

- - - - - - - -
Normalization rate due to mods {grda.normalizationRate}
- - - - - - -
- -
-

Averages:

-
- Slider Delta Hold Average{" "} - {Math.round( - (grda.sliderDeltaHoldAverage / grda.sliderLength) * 100 - ) / 100} -
-
- Approximated circle hold delta range ={" "} - {grda.circleExtremes.max - grda.circleExtremes.min} -
-
- Approximated slider hold delta range ={" "} - {grda.sliderExtremes.max - grda.sliderExtremes.min} -
-
- -
Circle | Holdtime distribution
- - - - - - - -
Circle | Press time distribution
- - Number(a[0]) - Number(b[0]) - )} - > - - - - -
- )} -
-
- ); -} diff --git a/nise-replay-viewer/src/interface/composites/helper.tsx b/nise-replay-viewer/src/interface/composites/helper.tsx index f878cd6..eaec5be 100644 --- a/nise-replay-viewer/src/interface/composites/helper.tsx +++ b/nise-replay-viewer/src/interface/composites/helper.tsx @@ -1,4 +1,4 @@ -import middlemouse from "/mouse.svg"; +import leftmouse from "/mouse.svg"; import scroll from "/scroll.svg"; import space from "/space.svg"; import { state } from "@/utils"; @@ -12,7 +12,7 @@ export function Helper() {
Drag Playfield - +
Zoom diff --git a/nise-replay-viewer/src/interface/composites/stats.tsx b/nise-replay-viewer/src/interface/composites/stats.tsx new file mode 100644 index 0000000..383f5fb --- /dev/null +++ b/nise-replay-viewer/src/interface/composites/stats.tsx @@ -0,0 +1,17 @@ +import { state } from "@/utils"; +import {OsuRenderer} from "@/osu/OsuRenderer"; + +export function Stats() { + const { replay } = state(); + + if (!replay) return <>; + + return ( +
+
+ cvUR: {OsuRenderer.cvUR} +
+
+ ); + +} diff --git a/nise-replay-viewer/src/osu/OsuRenderer.ts b/nise-replay-viewer/src/osu/OsuRenderer.ts index 207b087..40fa760 100644 --- a/nise-replay-viewer/src/osu/OsuRenderer.ts +++ b/nise-replay-viewer/src/osu/OsuRenderer.ts @@ -24,6 +24,14 @@ export enum OsuRendererEvents { SETTINGS = "SETTINGS", } +interface Judgements { + x: number; + y: number; + time: number; + error: number; + type: string; +} + export interface OsuRendererSettings { showCursorPath: boolean; showFutureCursorPath: boolean; @@ -41,18 +49,27 @@ export class OsuRenderer { private static fadeIn: number; private static lastRender: number = Date.now(); + static playing: boolean = false; static event = new OsuRendererBridge(); + static judgements: Judgements[] = []; static speedMultiplier = 1; static settings: OsuRendererSettings = { showCursorPath: true, - showFutureCursorPath: true, + showFutureCursorPath: false, showKeyPress: true, }; + static cvUR = 0; + static totalMisses = 0; + static totalThreeHundreds = 0; + static totalHundreds = 0; + static totalFifties = 0; + static time: number = 0; + static beatmap: StandardBeatmap; static og_beatmap: StandardBeatmap; static replay: Score; @@ -68,6 +85,40 @@ export class OsuRenderer { this.beatmap = undefined as any; } + /** + * Returns the cvUR of the current replay, rounded to 2 decimals. + */ + static updateStatistics() { + let pastJudgements = this.judgements.filter(j => j.time < this.time); + if(pastJudgements.length === 0) { + this.cvUR = 0; + return; + } + + // Calculate 300x, 100x, 50x, Misses + this.totalMisses = pastJudgements.filter(j => j.type == "MISS").length; + this.totalThreeHundreds = pastJudgements.filter(j => j.type == "THREE_HUNDRED").length; + this.totalHundreds = pastJudgements.filter(j => j.type == "ONE_HUNDRED").length; + this.totalFifties = pastJudgements.filter(j => j.type == "FIFTY").length; + + // UR is equal to the standard deviation of all the hit errors + let hitErrors = pastJudgements.map(j => j.error); + let mean = hitErrors.reduce((a, b) => a + b, 0) / hitErrors.length; + let variance = hitErrors.map(e => Math.pow(e - mean, 2)).reduce((a, b) => a + b, 0) / hitErrors.length; + let UR = Math.sqrt(variance) * 10; + + // To obtain the cvUR, we need to account for mods like DT and HT + if(this.beatmap.mods.bitwise & 64) { + UR /= 1.5; + } + + if(this.beatmap.mods.bitwise & 256) { + UR /= 0.75; + } + + this.cvUR = Math.round(UR * 100) / 100; + } + static render() { if (!this.beatmap || !this.replay) return; @@ -80,6 +131,7 @@ export class OsuRenderer { if (this.playing) { this.setTime(this.time + ((Date.now() - this.lastRender) * this.speedMultiplier)); + this.updateStatistics(); } this.lastRender = Date.now(); @@ -163,7 +215,7 @@ export class OsuRenderer { }); } - static async loadReplayFromUrl(url: string) { + static async loadReplayFromUrl(url: string, replayId: number) { OsuRenderer.purge(); const response = await fetch(url, { @@ -174,19 +226,21 @@ export class OsuRenderer { const data = await response.json(); - const { beatmap, replay, mods } = data; + const { beatmap, replay, mods, judgements } = data; const i_replay = await getReplay(replay); + i_replay.info.id = replayId; i_replay.info.rawMods = mods; i_replay.info.mods = new StandardModCombination(mods); const i_beatmap = await getBeatmap(beatmap, i_replay); - OsuRenderer.setOptions(i_beatmap, i_replay); + OsuRenderer.setOptions(i_beatmap, i_replay, judgements); this.event.emit(OsuRendererEvents.LOAD); } - static setOptions(beatmap: StandardBeatmap, replay: Score) { + static setOptions(beatmap: StandardBeatmap, replay: Score, judgements: Judgements[] ) { + this.judgements = judgements; this.forceHR = undefined; this.replay = replay; this.beatmap = beatmap; @@ -222,7 +276,7 @@ export class OsuRenderer { private static calculateEffects(hitObject: StandardHitObject) { let vEndTime = hitObject.startTime; - if (hitObject instanceof Slider || hitObject instanceof Spinner) { + if (hitObject instanceof Spinner) { vEndTime = hitObject.endTime + 25; } @@ -251,9 +305,6 @@ export class OsuRenderer { this.time > hitObject.startTime - this.preempt && this.time < vEndTime + hitObject.hitWindows.windowFor(HitResult.Meh); - if (hitObject instanceof Slider && this.time > hitObject.endTime) { - opacity -= (this.time - hitObject.endTime) / 25; - } return { opacity, arScale, @@ -261,6 +312,63 @@ export class OsuRenderer { }; } + private static calculateSliderEffects(hitObject: Slider) { + let sliderCircleEndTime = hitObject.startTime; + let vEndTime = hitObject.endTime + 25; + + const sliderCircleFadeOut = Math.max( + 0.0, + (this.time - sliderCircleEndTime) / 375 + ); + + const fadeOut = Math.max( + 0.0, + (this.time - vEndTime) / hitObject.hitWindows.windowFor(HitResult.Meh) + ); + + let sliderCircleOpacity = Math.max( + 0.0, + Math.min( + 1.0, + Math.min( + 1.0, + (this.time - hitObject.startTime + this.preempt) / this.fadeIn + ) - sliderCircleFadeOut + ) + ); + + let opacity = Math.max( + 0.0, + Math.min( + 1.0, + Math.min( + 1.0, + (this.time - hitObject.startTime + this.preempt) / this.fadeIn + ) - fadeOut + ) + ); + + const arScale = Math.max( + 1, + ((hitObject.startTime - this.time) / this.preempt) * 3.0 + 1.0 + ); + + let visible = + this.time > hitObject.startTime - this.preempt && + this.time < vEndTime + hitObject.hitWindows.windowFor(HitResult.Meh); + + if (this.time > hitObject.endTime) { + opacity -= (this.time - hitObject.endTime) / 25; + } + + return { + opacity, + sliderCircleOpacity, + arScale, + visible, + }; + } + private static renderCircle(hitObject: Circle) { if (hitObject.startTime > this.time + 10000) return; const { arScale, opacity, visible } = this.calculateEffects(hitObject); @@ -297,7 +405,7 @@ export class OsuRenderer { private static renderSlider(hitObject: Slider) { if (hitObject.endTime > this.time + 10000) return; - const { arScale, opacity, visible } = this.calculateEffects(hitObject); + const { arScale, opacity, sliderCircleOpacity, visible } = this.calculateSliderEffects(hitObject); if (!visible) return; Drawer.beginDrawing(); @@ -308,7 +416,7 @@ export class OsuRenderer { hitObject.path.path, hitObject.radius ); - Drawer.setDrawingOpacity(opacity); + Drawer.setDrawingOpacity(sliderCircleOpacity); Drawer.drawApproachCircle( hitObject.stackedStartPosition, diff --git a/nise-replay-viewer/src/style.css b/nise-replay-viewer/src/style.css index 6b458e3..9128a9e 100644 --- a/nise-replay-viewer/src/style.css +++ b/nise-replay-viewer/src/style.css @@ -1,3 +1,19 @@ +@font-face { + font-family: 'iA Writer Quattro'; + font-style: normal; + font-display: swap; + font-weight: 400; + src: url(ia-quattro-400-normal.woff2) format('woff2'); +} + +@font-face { + font-family: 'iA Writer Quattro'; + font-style: normal; + font-display: swap; + font-weight: 700; + src: url(ia-quattro-700-normal.woff2) format('woff2'); +} + #app { margin:0; padding: 0; @@ -6,6 +22,7 @@ } #root{ + font-family: 'iA Writer Quattro', sans-serif; } body{ diff --git a/nise-replay-viewer/src/utils.ts b/nise-replay-viewer/src/utils.ts index abf2e37..022fc17 100644 --- a/nise-replay-viewer/src/utils.ts +++ b/nise-replay-viewer/src/utils.ts @@ -75,7 +75,7 @@ export const state = create<{ state.subscribe((newState) => { if (newState.beatmap) { - document.title = `${newState.beatmap.metadata.artist} - ${newState.beatmap.metadata.titleUnicode} | Replay Inspector` + document.title = `Viewing replay #${newState.replay?.info.id}` } })