This commit is contained in:
nise.moe 2024-03-03 23:27:54 +01:00
parent 9c07baadce
commit 01828a57f1
19 changed files with 264 additions and 231 deletions

View File

@ -1,5 +1,6 @@
package com.nisemoe.nise package com.nisemoe.nise
import com.nisemoe.nise.integrations.CircleguardService
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import java.time.OffsetDateTime import java.time.OffsetDateTime
@ -97,7 +98,8 @@ data class ReplayDataSimilarScore(
data class ReplayViewerData( data class ReplayViewerData(
val beatmap: String, val beatmap: String,
val replay: String, val replay: String,
val mods: Int val mods: Int,
val judgements: List<CircleguardService.ScoreJudgement>
) )
data class ReplayData( data class ReplayData(

View File

@ -16,7 +16,6 @@ class ScoreController(
private val scoreService: ScoreService private val scoreService: ScoreService
) { ) {
@CrossOrigin(origins = ["http://wizardly_nash.local"])
@GetMapping("score/{replayId}/replay") @GetMapping("score/{replayId}/replay")
fun getReplay(@PathVariable replayId: Long): ResponseEntity<ReplayViewerData> { fun getReplay(@PathVariable replayId: Long): ResponseEntity<ReplayViewerData> {
val replay = this.scoreService.getReplayViewerData(replayId) val replay = this.scoreService.getReplayViewerData(replayId)

View File

@ -22,7 +22,6 @@ import kotlin.math.roundToInt
@Service @Service
class ScoreService( class ScoreService(
private val dslContext: DSLContext, private val dslContext: DSLContext,
private val circleguardService: CircleguardService,
private val beatmapService: BeatmapService, private val beatmapService: BeatmapService,
private val authService: AuthService, private val authService: AuthService,
private val compressJudgements: CompressJudgements private val compressJudgements: CompressJudgements
@ -66,6 +65,7 @@ class ScoreService(
return ReplayViewerData( return ReplayViewerData(
beatmap = result.get(BEATMAPS.BEATMAP_FILE, String::class.java), beatmap = result.get(BEATMAPS.BEATMAP_FILE, String::class.java),
replay = String(replay, Charsets.UTF_8).trimEnd(','), replay = String(replay, Charsets.UTF_8).trimEnd(','),
judgements = getJudgements(replayId),
mods = mods mods = mods
) )
} }
@ -440,6 +440,37 @@ class ScoreService(
replayData.comparable_error_skewness = otherScores.get("avg_error_skewness", Double::class.java) replayData.comparable_error_skewness = otherScores.get("avg_error_skewness", Double::class.java)
} }
fun getJudgements(replayId: Long): List<CircleguardService.ScoreJudgement> {
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<Int, DistributionEntry> { fun getHitDistribution(scoreId: Int): Map<Int, DistributionEntry> {
val judgementsRecord = dslContext.select(SCORES.JUDGEMENTS) val judgementsRecord = dslContext.select(SCORES.JUDGEMENTS)
.from(SCORES) .from(SCORES)

View File

@ -18,7 +18,7 @@ import org.springframework.context.annotation.Profile
import org.springframework.scheduling.annotation.Scheduled import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
@Profile("old_scores") @Profile("fix:scores")
@Service @Service
class FixOldScores( class FixOldScores(
private val dslContext: DSLContext, private val dslContext: DSLContext,
@ -29,12 +29,12 @@ class FixOldScores(
companion object { companion object {
const val CURRENT_VERSION = 6 const val CURRENT_VERSION = 7
} }
@Value("\${OLD_SCORES_WORKERS:4}") @Value("\${OLD_SCORES_WORKERS:4}")
private var workers: Int = 4 private var workers: Int = 6
@Value("\${OLD_SCORES_PAGE_SIZE:5000}") @Value("\${OLD_SCORES_PAGE_SIZE:5000}")
private var pageSize: Int = 5000 private var pageSize: Int = 5000

View File

@ -69,7 +69,7 @@ class ImportScores(
companion object { companion object {
const val CURRENT_VERSION = 6 const val CURRENT_VERSION = 7
const val SLEEP_AFTER_API_CALL = 500L const val SLEEP_AFTER_API_CALL = 500L
const val UPDATE_USER_EVERY_DAYS = 7L const val UPDATE_USER_EVERY_DAYS = 7L
const val UPDATE_BANNED_USERS_EVERY_DAYS = 3L const val UPDATE_BANNED_USERS_EVERY_DAYS = 3L
@ -207,7 +207,7 @@ class ImportScores(
for(topScore in allUserScores) { for(topScore in allUserScores) {
val beatmapExists = dslContext.fetchExists(BEATMAPS, BEATMAPS.BEATMAP_ID.eq(topScore.beatmap!!.id)) val beatmapExists = dslContext.fetchExists(BEATMAPS, BEATMAPS.BEATMAP_ID.eq(topScore.beatmap!!.id))
if (!beatmapExists) { if (!beatmapExists) {
val beatmapFile = this.osuApi.getBeatmapFile(beatmapId = beatmap.id) val beatmapFile = this.osuApi.getBeatmapFile(beatmapId = topScore.beatmap.id)
dslContext.insertInto(BEATMAPS) dslContext.insertInto(BEATMAPS)
.set(BEATMAPS.BEATMAP_ID, topScore.beatmap.id) .set(BEATMAPS.BEATMAP_ID, topScore.beatmap.id)
.set(BEATMAPS.BEATMAPSET_ID, topScore.beatmapset!!.id) .set(BEATMAPS.BEATMAPSET_ID, topScore.beatmapset!!.id)

View File

@ -0,0 +1 @@
18.19

Binary file not shown.

Binary file not shown.

View File

@ -1,6 +1,6 @@
<svg width="16" height="26" viewBox="0 0 16 26" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="16" height="26" viewBox="0 0 16 26" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.52637 7.48637V9.63586C6.52637 10.4498 7.18613 11.1095 8.00004 11.1095C8.81395 11.1095 9.47371 10.4498 9.47371 9.63586V7.48637C9.47371 6.67246 8.81395 6.0127 8.00004 6.0127C7.18613 6.0127 6.52637 6.67246 6.52637 7.48637Z" fill="white"/> <path d="M6.52637 7.48637V9.63586C6.52637 10.4498 7.18613 11.1095 8.00004 11.1095C8.81395 11.1095 9.47371 10.4498 9.47371 9.63586V7.48637C9.47371 6.67246 8.81395 6.0127 8.00004 6.0127C7.18613 6.0127 6.52637 6.67246 6.52637 7.48637Z" fill="#373737"/>
<path d="M5.05306 7.4854C5.05306 6.11459 5.9938 4.95933 7.26357 4.63134V0.442383C3.19678 0.815565 0.000976562 4.24509 0.000976562 8.40757V8.52409H5.05311V7.4854H5.05306Z" fill="#373737"/> <path d="M5.05306 7.4854C5.05306 6.11459 5.9938 4.95933 7.26357 4.63134V0.442383C3.19678 0.815565 0.000976562 4.24509 0.000976562 8.40757V8.52409H5.05311V7.4854H5.05306Z" fill="white"/>
<path d="M8.7373 0.442383V4.63129C10.0071 4.95933 10.9478 6.11454 10.9478 7.48535V8.52409H15.9999V8.40757C15.9999 4.24509 12.8041 0.815565 8.7373 0.442383V0.442383Z" fill="#373737"/> <path d="M8.7373 0.442383V4.63129C10.0071 4.95933 10.9478 6.11454 10.9478 7.48535V8.52409H15.9999V8.40757C15.9999 4.24509 12.8041 0.815565 8.7373 0.442383V0.442383Z" fill="#373737"/>
<path d="M8.0004 12.5825C6.49819 12.5825 5.25574 11.4525 5.0762 9.99805H0.000976562V17.5595C0.000976562 21.9704 3.58951 25.559 8.00045 25.559C12.4114 25.559 15.9999 21.9705 15.9999 17.5595V9.99805H10.9246C10.7451 11.4525 9.50266 12.5825 8.0004 12.5825Z" fill="#373737"/> <path d="M8.0004 12.5825C6.49819 12.5825 5.25574 11.4525 5.0762 9.99805H0.000976562V17.5595C0.000976562 21.9704 3.58951 25.559 8.00045 25.559C12.4114 25.559 15.9999 21.9705 15.9999 17.5595V9.99805H10.9246C10.7451 11.4525 9.50266 12.5825 8.0004 12.5825Z" fill="#373737"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 993 B

After

Width:  |  Height:  |  Size: 993 B

View File

@ -13,13 +13,13 @@ export class CanvasControlHooks {
static setupCanvasControls() { static setupCanvasControls() {
canvasDragStart = p.createVector(0, 0); canvasDragStart = p.createVector(0, 0);
canvasDragging = false; canvasDragging = false;
canvasMultiplier = p.windowHeight / 384 / 2; canvasMultiplier = p.windowHeight / 384 / 1.5;
canvasTranslation = p.createVector(512, 384 / 3); canvasTranslation = p.createVector(512 / 1.5, 384 / 8);
} }
@Hook(Events.mousePressed) @Hook(Events.mousePressed)
static mousePressed() { static mousePressed() {
if (p.mouseButton === p.CENTER) { if (p.mouseButton === p.LEFT) {
canvasDragging = true; canvasDragging = true;
canvasDragStart = p.createVector(p.mouseX, p.mouseY); canvasDragStart = p.createVector(p.mouseX, p.mouseY);
} }

View File

@ -1,24 +1,24 @@
import {AboutDialog} from "./composites/about-dialog"; import {AboutDialog} from "./composites/about-dialog";
import {AnalysisSheet} from "./composites/analysis.-sheet";
import {Navbar} from "./composites/Menu"; import {Navbar} from "./composites/Menu";
import {SongSlider} from "./composites/song-slider"; import {SongSlider} from "./composites/song-slider";
import {Helper} from "./composites/helper"; import {Helper} from "./composites/helper";
import {useEffect, useState} from "react"; import {useEffect, useState} from "react";
import {OsuRenderer} from "@/osu/OsuRenderer"; import {OsuRenderer} from "@/osu/OsuRenderer";
import {Stats} from "@/interface/composites/stats";
export function App() { export function App() {
const [replayId, setReplayId] = useState<string>(""); const [replayId, setReplayId] = useState<number>(0);
useEffect(() => { useEffect(() => {
let pathReplayId = location.pathname.slice(1, location.pathname.length); let pathReplayId = Number.parseInt(location.pathname.slice(1, location.pathname.length));
const loadReplay = async () => { const loadReplay = async () => {
if(document.location.hostname === "localhost") { 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; 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) { if(replayId !== pathReplayId) {
@ -31,8 +31,8 @@ export function App() {
<> <>
<Navbar/> <Navbar/>
<AboutDialog/> <AboutDialog/>
<AnalysisSheet/>
<SongSlider/> <SongSlider/>
<Stats/>
<Helper/> <Helper/>
</> </>
); );

View File

@ -1,10 +1,4 @@
import { import {Menubar,} from "@/interface/components/ui/menubar";
Menubar,
MenubarContent,
MenubarItem,
MenubarMenu,
MenubarTrigger,
} from "@/interface/components/ui/menubar";
import {OsuRenderer} from "@/osu/OsuRenderer"; import {OsuRenderer} from "@/osu/OsuRenderer";
import {state} from "@/utils"; import {state} from "@/utils";
@ -16,38 +10,24 @@ export function Navbar() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<img src={"https://nise.moe/assets/keisatsu-chan.png"} width={48}/> <img src={"https://nise.moe/assets/keisatsu-chan.png"} width={48}/>
<h3 className="scroll-m-20 text-lg font-semibold tracking-tight"> <h3 className="scroll-m-20 text-lg font-semibold tracking-tight">
Replay Viewer /replay/
</h3> </h3>
</div> </div>
<MenubarMenu> <button onClick={() => {
<MenubarTrigger>File</MenubarTrigger>
<MenubarContent>
<MenubarItem
onClick={() => {
state.setState({aboutDialog: true}); state.setState({aboutDialog: true});
}} }}
> className="flex items-center rounded-sm px-3 py-1.5 text-sm font-medium outline-none focus:bg-accent hover:bg-accent">
About About
</MenubarItem> </button>
</MenubarContent>
</MenubarMenu>
{OsuRenderer.beatmap && ( {OsuRenderer.beatmap && (
<> <>
{" "} {" "}
<MenubarMenu> <a href={"https://nise.moe/s/" + OsuRenderer.replay.info.id} target="_blank"
<MenubarTrigger>Analyzer</MenubarTrigger> className="flex items-center rounded-sm px-3 py-1.5 text-sm font-medium outline-none focus:bg-accent hover:bg-accent">
<MenubarContent> View on nise.moe
<MenubarItem </a>
onClick={() => {
state.setState({ dataAnalysisDialog: true });
}}
>
gRDA
</MenubarItem>
</MenubarContent>
</MenubarMenu>
</> </>
)} )}
</div> </div>

View File

@ -1,7 +1,6 @@
import { Button } from "@/interface/components/ui/button";
import { Dialog, DialogContent } from "@/interface/components/ui/dialog"; import { Dialog, DialogContent } from "@/interface/components/ui/dialog";
import { Badge } from "@/interface/components/ui/badge";
import { state } from "@/utils"; import { state } from "@/utils";
export function AboutDialog() { export function AboutDialog() {
const { aboutDialog } = state(); const { aboutDialog } = state();
return ( return (

View File

@ -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 (
<Sheet
open={dataAnalysisDialog}
onOpenChange={(opened: boolean) => {
state.setState({ dataAnalysisDialog: opened });
}}
>
<SheetContent
side="left"
className="min-w-[600px] overflow-scroll overflow-x-hidden SCROLL"
>
<SheetHeader>
<SheetTitle>gRDA</SheetTitle>
<SheetDescription>
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.
</SheetDescription>
</SheetHeader>
<Button
variant="outline"
className="mt-4"
onClick={() => {
state.setState({
grda: gRDA(OsuRenderer.replay, OsuRenderer.beatmap),
});
}}
>
Analyze Replay
</Button>
{grda && (
<div>
<h3 className="text-xl font-semibold tracking-tight flex items-center gap-3 mt-6">
<span>Response</span>
</h3>
<div className="opacity-75">
<h3 className="text-sm">Frametime Averages:</h3>
<ResponsiveContainer height={200} width="100%">
<BarChart height={200} data={Object.entries(grda.frameTimes)}>
<XAxis dataKey="0" />
<Bar dataKey="1" fill="#8884d8" />
</BarChart>
</ResponsiveContainer>
<div>Normalization rate due to mods {grda.normalizationRate}</div>
<ResponsiveContainer height={200} width="100%">
<BarChart
height={200}
data={Object.entries(grda.moddedFrameTimes)}
>
<XAxis dataKey="0" />
<Bar dataKey="1" fill="#8884d8" />
</BarChart>
</ResponsiveContainer>
</div>
<div className="opacity-75">
<h3 className="text-sm">Averages:</h3>
<div>
Slider Delta Hold Average{" "}
{Math.round(
(grda.sliderDeltaHoldAverage / grda.sliderLength) * 100
) / 100}
</div>
<div>
Approximated circle hold delta range ={" "}
{grda.circleExtremes.max - grda.circleExtremes.min}
</div>
<div>
Approximated slider hold delta range ={" "}
{grda.sliderExtremes.max - grda.sliderExtremes.min}
</div>
</div>
<div>Circle | Holdtime distribution</div>
<ResponsiveContainer height={400} width="100%">
<BarChart
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
height={400}
data={Object.entries(grda.holdCircleDistributionGraph)}
>
<XAxis dataKey="0" />
<Bar dataKey="1" fill="#8884d8" />
</BarChart>
</ResponsiveContainer>
<div>Circle | Press time distribution</div>
<ResponsiveContainer height={400} width="100%">
<BarChart
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
height={400}
data={Object.entries(grda.pressCircleDistributionGraph).sort(
(a, b) => Number(a[0]) - Number(b[0])
)}
>
<XAxis dataKey="0" />
<Bar dataKey="1" fill="#8884d8" />
</BarChart>
</ResponsiveContainer>
</div>
)}
</SheetContent>
</Sheet>
);
}

View File

@ -1,4 +1,4 @@
import middlemouse from "/mouse.svg"; import leftmouse from "/mouse.svg";
import scroll from "/scroll.svg"; import scroll from "/scroll.svg";
import space from "/space.svg"; import space from "/space.svg";
import { state } from "@/utils"; import { state } from "@/utils";
@ -12,7 +12,7 @@ export function Helper() {
<div className="flex flex-col absolute right-10 top-16 gap-2 items-end"> <div className="flex flex-col absolute right-10 top-16 gap-2 items-end">
<div className="flex gap-2 text-xs items-center"> <div className="flex gap-2 text-xs items-center">
Drag Playfield Drag Playfield
<img src={middlemouse}/> <img src={leftmouse}/>
</div> </div>
<div className="flex gap-2 text-xs items-center"> <div className="flex gap-2 text-xs items-center">
Zoom Zoom

View File

@ -0,0 +1,17 @@
import { state } from "@/utils";
import {OsuRenderer} from "@/osu/OsuRenderer";
export function Stats() {
const { replay } = state();
if (!replay) return <></>;
return (
<div className="flex flex-col absolute left-10 top-16 gap-2">
<div className="flex gap-2 text-xs">
cvUR: {OsuRenderer.cvUR}
</div>
</div>
);
}

View File

@ -24,6 +24,14 @@ export enum OsuRendererEvents {
SETTINGS = "SETTINGS", SETTINGS = "SETTINGS",
} }
interface Judgements {
x: number;
y: number;
time: number;
error: number;
type: string;
}
export interface OsuRendererSettings { export interface OsuRendererSettings {
showCursorPath: boolean; showCursorPath: boolean;
showFutureCursorPath: boolean; showFutureCursorPath: boolean;
@ -41,18 +49,27 @@ export class OsuRenderer {
private static fadeIn: number; private static fadeIn: number;
private static lastRender: number = Date.now(); private static lastRender: number = Date.now();
static playing: boolean = false; static playing: boolean = false;
static event = new OsuRendererBridge(); static event = new OsuRendererBridge();
static judgements: Judgements[] = [];
static speedMultiplier = 1; static speedMultiplier = 1;
static settings: OsuRendererSettings = { static settings: OsuRendererSettings = {
showCursorPath: true, showCursorPath: true,
showFutureCursorPath: true, showFutureCursorPath: false,
showKeyPress: true, showKeyPress: true,
}; };
static cvUR = 0;
static totalMisses = 0;
static totalThreeHundreds = 0;
static totalHundreds = 0;
static totalFifties = 0;
static time: number = 0; static time: number = 0;
static beatmap: StandardBeatmap; static beatmap: StandardBeatmap;
static og_beatmap: StandardBeatmap; static og_beatmap: StandardBeatmap;
static replay: Score; static replay: Score;
@ -68,6 +85,40 @@ export class OsuRenderer {
this.beatmap = undefined as any; 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() { static render() {
if (!this.beatmap || !this.replay) return; if (!this.beatmap || !this.replay) return;
@ -80,6 +131,7 @@ export class OsuRenderer {
if (this.playing) { if (this.playing) {
this.setTime(this.time + ((Date.now() - this.lastRender) * this.speedMultiplier)); this.setTime(this.time + ((Date.now() - this.lastRender) * this.speedMultiplier));
this.updateStatistics();
} }
this.lastRender = Date.now(); 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(); OsuRenderer.purge();
const response = await fetch(url, { const response = await fetch(url, {
@ -174,19 +226,21 @@ export class OsuRenderer {
const data = await response.json(); const data = await response.json();
const { beatmap, replay, mods } = data; const { beatmap, replay, mods, judgements } = data;
const i_replay = await getReplay(replay); const i_replay = await getReplay(replay);
i_replay.info.id = replayId;
i_replay.info.rawMods = mods; i_replay.info.rawMods = mods;
i_replay.info.mods = new StandardModCombination(mods); i_replay.info.mods = new StandardModCombination(mods);
const i_beatmap = await getBeatmap(beatmap, i_replay); 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); 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.forceHR = undefined;
this.replay = replay; this.replay = replay;
this.beatmap = beatmap; this.beatmap = beatmap;
@ -222,7 +276,7 @@ export class OsuRenderer {
private static calculateEffects(hitObject: StandardHitObject) { private static calculateEffects(hitObject: StandardHitObject) {
let vEndTime = hitObject.startTime; let vEndTime = hitObject.startTime;
if (hitObject instanceof Slider || hitObject instanceof Spinner) { if (hitObject instanceof Spinner) {
vEndTime = hitObject.endTime + 25; vEndTime = hitObject.endTime + 25;
} }
@ -251,9 +305,6 @@ export class OsuRenderer {
this.time > hitObject.startTime - this.preempt && this.time > hitObject.startTime - this.preempt &&
this.time < vEndTime + hitObject.hitWindows.windowFor(HitResult.Meh); this.time < vEndTime + hitObject.hitWindows.windowFor(HitResult.Meh);
if (hitObject instanceof Slider && this.time > hitObject.endTime) {
opacity -= (this.time - hitObject.endTime) / 25;
}
return { return {
opacity, opacity,
arScale, 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) { private static renderCircle(hitObject: Circle) {
if (hitObject.startTime > this.time + 10000) return; if (hitObject.startTime > this.time + 10000) return;
const { arScale, opacity, visible } = this.calculateEffects(hitObject); const { arScale, opacity, visible } = this.calculateEffects(hitObject);
@ -297,7 +405,7 @@ export class OsuRenderer {
private static renderSlider(hitObject: Slider) { private static renderSlider(hitObject: Slider) {
if (hitObject.endTime > this.time + 10000) return; 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; if (!visible) return;
Drawer.beginDrawing(); Drawer.beginDrawing();
@ -308,7 +416,7 @@ export class OsuRenderer {
hitObject.path.path, hitObject.path.path,
hitObject.radius hitObject.radius
); );
Drawer.setDrawingOpacity(opacity); Drawer.setDrawingOpacity(sliderCircleOpacity);
Drawer.drawApproachCircle( Drawer.drawApproachCircle(
hitObject.stackedStartPosition, hitObject.stackedStartPosition,

View File

@ -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 { #app {
margin:0; margin:0;
padding: 0; padding: 0;
@ -6,6 +22,7 @@
} }
#root{ #root{
font-family: 'iA Writer Quattro', sans-serif;
} }
body{ body{

View File

@ -75,7 +75,7 @@ export const state = create<{
state.subscribe((newState) => { state.subscribe((newState) => {
if (newState.beatmap) { if (newState.beatmap) {
document.title = `${newState.beatmap.metadata.artist} - ${newState.beatmap.metadata.titleUnicode} | Replay Inspector` document.title = `Viewing replay #${newState.replay?.info.id}`
} }
}) })