Work on replay viewer

This commit is contained in:
nise.moe 2024-03-02 23:58:42 +01:00
parent 7d0d3b676f
commit e9f7d29b46
11 changed files with 618 additions and 212 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
@ -96,7 +97,8 @@ data class ReplayDataSimilarScore(
data class ReplayViewerData( data class ReplayViewerData(
val replay: String, val replay: String,
val beatmap: String, val beatmap: CircleguardService.BeatmapResponse,
val mods: List<String>
) )
data class ReplayData( data class ReplayData(

View File

@ -4,6 +4,7 @@ import com.nisemoe.generated.tables.records.ScoresJudgementsRecord
import com.nisemoe.generated.tables.records.ScoresRecord import com.nisemoe.generated.tables.records.ScoresRecord
import com.nisemoe.generated.tables.references.* import com.nisemoe.generated.tables.references.*
import com.nisemoe.nise.* import com.nisemoe.nise.*
import com.nisemoe.nise.integrations.CircleguardService
import com.nisemoe.nise.osu.Mod import com.nisemoe.nise.osu.Mod
import com.nisemoe.nise.service.AuthService import com.nisemoe.nise.service.AuthService
import com.nisemoe.nise.service.CompressJudgements import com.nisemoe.nise.service.CompressJudgements
@ -21,6 +22,7 @@ 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
@ -45,6 +47,7 @@ class ScoreService(
fun getReplayViewerData(replayId: Long): ReplayViewerData? { fun getReplayViewerData(replayId: Long): ReplayViewerData? {
val result = dslContext.select( val result = dslContext.select(
SCORES.REPLAY, SCORES.REPLAY,
SCORES.MODS,
BEATMAPS.BEATMAP_FILE BEATMAPS.BEATMAP_FILE
) )
.from(SCORES) .from(SCORES)
@ -61,9 +64,15 @@ class ScoreService(
if(result.get(BEATMAPS.BEATMAP_FILE, String::class.java) == null) return null if(result.get(BEATMAPS.BEATMAP_FILE, String::class.java) == null) return null
val mods = result.get(SCORES.MODS, Int::class.java)
val beatmapFile = result.get(BEATMAPS.BEATMAP_FILE, String::class.java)
val beatmapData = this.circleguardService.processBeatmap(beatmapFile, mods = mods).get()
return ReplayViewerData( return ReplayViewerData(
replay = replayData, replay = replayData,
beatmap = result.get(BEATMAPS.BEATMAP_FILE, String::class.java) beatmap = beatmapData,
mods = Mod.parseModCombination(mods)
) )
} }

View File

@ -106,6 +106,105 @@ class CircleguardService {
replayResponse.error_skewness = replayResponse.error_skewness?.times(conversionFactor) replayResponse.error_skewness = replayResponse.error_skewness?.times(conversionFactor)
} }
@Serializable
data class BeatmapRequest(
val beatmap_file: String,
val mods: Int
)
@Serializable
data class Circle(
val x: Double,
val y: Double,
val time: Double,
val new_combo: Boolean,
val current_combo: Int,
val combo_color: String
)
@Serializable
data class SliderCurvePoint(
val x: Double,
val y: Double
)
@Serializable
data class SliderCurve(
val type: String,
val points: List<SliderCurvePoint>
)
@Serializable
data class Slider(
val x: Double,
val y: Double,
val time: Double,
val end_time: Double,
val curve: SliderCurve,
val length: Double,
val new_combo: Boolean,
val current_combo: Int,
val combo_color: String,
val repeat: Int
)
@Serializable
data class Spinner(
val x: Double,
val y: Double,
val time: Double,
val end_time: Double,
val new_combo: Boolean,
val current_combo: Int,
val combo_color: String
)
@Serializable
data class Difficulty(
val hp_drain_rate: Double,
val circle_size: Double,
val overral_difficulty: Double,
val approach_rate: Double,
val slider_multiplier: Double,
val slider_tick_rate: Double
)
@Serializable
data class BeatmapResponse(
val circles: List<Circle>,
val sliders: List<Slider>,
val spinners: List<Spinner>,
val difficulty: Difficulty,
val audio_lead_in: Double,
)
fun processBeatmap(beatmapFile: String, mods: Int): CompletableFuture<BeatmapResponse> {
val requestUri = "$apiUrl/beatmap"
val request = BeatmapRequest(
beatmap_file = beatmapFile,
mods = mods
)
// Serialize the request object to JSON
val requestBody = serializer.encodeToString(BeatmapRequest.serializer(), request)
val httpRequest = HttpRequest.newBuilder()
.uri(URI.create(requestUri))
.header("Content-Type", "application/json") // Set content type to application/json
.POST(HttpRequest.BodyPublishers.ofString(requestBody))
.build()
return httpClient.sendAsync(httpRequest, HttpResponse.BodyHandlers.ofString())
.thenApply { response: HttpResponse<String> ->
if (response.statusCode() == 200) {
serializer.decodeFromString(BeatmapResponse.serializer(), response.body())
} else {
throw RuntimeException("Failed to process beatmap: ${response.body()}")
}
}
}
fun processReplay(replayData: String, beatmapData: String, mods: Int = 0): CompletableFuture<ReplayResponse> { fun processReplay(replayData: String, beatmapData: String, mods: Int = 0): CompletableFuture<ReplayResponse> {
val requestUri = "$apiUrl/replay" val requestUri = "$apiUrl/replay"

View File

@ -2,17 +2,16 @@ import base64
import io import io
import os import os
from dataclasses import dataclass, asdict from dataclasses import dataclass, asdict
from itertools import combinations
from math import isnan
from typing import List, Iterable from typing import List, Iterable
import numpy as np import numpy as np
import scipy import scipy
from brparser import Replay, BeatmapOsu, Mod from brparser import Replay, BeatmapOsu, Mod
from circleguard import Circleguard, ReplayString, Hit from circleguard import Circleguard, ReplayString, Hit
from circleguard.utils import filter_outliers
from flask import Flask, request, jsonify, abort from flask import Flask, request, jsonify, abort
from slider import Beatmap from itertools import combinations
from math import isnan
from slider import Beatmap, Circle, Slider, Spinner
from src.WriteStreamWrapper import WriteStreamWrapper from src.WriteStreamWrapper import WriteStreamWrapper
from src.keypresses import get_kp_sliders from src.keypresses import get_kp_sliders
@ -43,6 +42,139 @@ def my_filter_outliers(arr, bias=1.5):
arr_without_outliers = [x for x in arr if lower_limit < x < upper_limit]; return arr if not arr_without_outliers else arr_without_outliers arr_without_outliers = [x for x in arr if lower_limit < x < upper_limit]; return arr if not arr_without_outliers else arr_without_outliers
@dataclass
class BeatmapRequest:
beatmap_file: str
mods: int
@staticmethod
def from_dict(data):
try:
return BeatmapRequest(
beatmap_file=data['beatmap_file'],
mods=data['mods']
)
except (ValueError, KeyError, TypeError) as e:
raise ValueError(f"Invalid data format: {e}")
@app.post("/beatmap")
def process_beatmap():
try:
request_data = request.get_json()
if not request_data:
abort(400, description="Bad Request: No JSON data provided.")
beatmap_request = BeatmapRequest.from_dict(request_data)
cg_beatmap = Beatmap.parse(beatmap_request.beatmap_file)
circles = []
sliders = []
spinners = []
def map_slider_curve_type(slider: Slider):
if str(type(slider.curve)) == "<class 'slider.curve.Linear'>":
return 'Linear'
if str(type(slider.curve)) == "<class 'slider.curve.Perfect'>":
return 'Perfect'
elif str(type(slider.curve)) == "<class 'slider.curve.MultiBezier'>":
return 'MultiBezier'
elif str(type(slider.curve)) == "<class 'slider.curve.CatMull'>":
return 'CatMull'
combo_colors = {
1: "255,81,81", # Combo1
2: "255,128,64", # Combo2
3: "128,64,0", # Combo3
4: "212,212,212" # Combo4
}
current_combo = 1 # Initialize current combo counter
combo_counter = 0 # Keep track of how many combos have been counted
hit_objects = cg_beatmap.hit_objects(
easy=bool(beatmap_request.mods & Mod.Easy.value),
hard_rock=bool(beatmap_request.mods & Mod.HardRock.value),
half_time=bool(beatmap_request.mods & Mod.HalfTime.value),
double_time=bool(beatmap_request.mods & Mod.DoubleTime.value),
)
for obj in hit_objects:
if obj.new_combo:
combo_counter += 1 # Increment combo counter
if combo_counter > 4:
combo_counter = 1 # Reset combo counter after 4
current_combo = 1 # Reset current combo to 1 on new combo
else:
current_combo += 1 # Increment current combo for each hit object
# Assign combo color based on the combo_counter
combo_color = combo_colors[combo_counter]
if obj.type_code & Circle.type_code:
circles.append({
"x": obj.position.x,
"y": obj.position.y,
"time": obj.time.total_seconds() * 1000,
"new_combo": obj.new_combo,
"combo_color": combo_color,
"current_combo": current_combo
})
elif obj.type_code & Slider.type_code:
slider: Slider = obj
sliders.append({
"x": slider.position.x,
"y": slider.position.y,
"time": slider.time.total_seconds() * 1000,
"end_time": slider.end_time.total_seconds() * 1000,
"curve": {
'type': map_slider_curve_type(slider),
'points': [{'x': p.x, 'y': p.y} for p in slider.curve.points]
},
"length": slider.length,
"new_combo": slider.new_combo,
"combo_color": combo_color,
"current_combo": current_combo,
"repeat": slider.repeat,
})
elif obj.type_code & Spinner.type_code:
spinner: Spinner = obj
spinners.append({
"x": spinner.position.x,
"y": spinner.position.y,
"time": spinner.time.total_seconds() * 1000,
"end_time": spinner.end_time.total_seconds() * 1000,
"new_combo": spinner.new_combo,
"combo_color": combo_color,
"current_combo": current_combo
})
# Reset current_combo if new_combo is True
if obj.new_combo:
current_combo = 1
return jsonify(
{
"circles": circles,
"sliders": sliders,
"spinners": spinners,
"difficulty": {
"hp_drain_rate": cg_beatmap.hp(),
"circle_size": cg_beatmap.cs(),
"overral_difficulty": cg_beatmap.od(),
"approach_rate": cg_beatmap.ar(),
"slider_multiplier": cg_beatmap.slider_multiplier,
"slider_tick_rate": cg_beatmap.slider_tick_rate
},
"audio_lead_in": cg_beatmap.audio_lead_in.total_seconds() * 1000,
}
)
except ValueError as e:
abort(400, description=str(e))
@dataclass @dataclass
class ReplayRequest: class ReplayRequest:
replay_data: str replay_data: str

View File

@ -13,9 +13,69 @@ export interface ReplayDataSimilarScore {
correlation: number; correlation: number;
} }
interface Circle {
x: number;
y: number;
time: number;
new_combo: boolean;
current_combo: number;
combo_color: string;
}
interface SliderCurvePoint {
x: number;
y: number;
}
export interface SliderCurve {
type: string;
points: SliderCurvePoint[];
}
export interface Slider {
x: number;
y: number;
time: number;
end_time: number;
curve: SliderCurve;
length: number;
new_combo: boolean;
current_combo: number;
combo_color: string;
repeat: number;
}
interface Spinner {
x: number;
y: number;
time: number;
end_time: number;
new_combo: boolean;
current_combo: number;
combo_color: string;
}
interface Difficulty {
hp_drain_rate: number;
circle_size: number;
overral_difficulty: number;
approach_rate: number;
slider_multiplier: number;
slider_tick_rate: number;
}
interface BeatmapResponse {
circles: Circle[];
sliders: Slider[];
spinners: Spinner[];
difficulty: Difficulty;
audio_lead_in: number;
}
export interface ReplayViewerData { export interface ReplayViewerData {
replay: string; replay: string;
beatmap: string; beatmap: BeatmapResponse;
mods: string[];
} }
export interface ReplayData { export interface ReplayData {

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@ -1,125 +0,0 @@
export enum HitObjectType {
HitCircle = 1,
Slider = 2,
Spinner = 8
}
export type HitObject = {
x: number;
y: number;
time: number;
type: number;
hitSound: number;
objectParams: string;
hitSample: string;
currentCombo: number;
};
export interface BeatmapDifficulty {
hpDrainRate: number;
circleSize: number;
overralDifficulty: number;
approachRate: number;
sliderMultiplier: number;
sliderTickRate: number;
}
export function parseBeatmapDifficulty(beatmap: string): BeatmapDifficulty {
const lines = beatmap.split('\n');
let recording = false;
const difficulty: Partial<BeatmapDifficulty> = {};
for (const line of lines) {
if (line.trim() === '[Difficulty]') {
recording = true;
continue;
}
if (!recording) continue;
if (line.startsWith('[')) break;
const parts = line.split(':');
if (parts.length < 2) continue;
switch (parts[0]) {
case 'HPDrainRate':
difficulty.hpDrainRate = parseFloat(parts[1]);
break;
case 'CircleSize':
difficulty.circleSize = parseFloat(parts[1]);
break;
case 'OverallDifficulty':
difficulty.overralDifficulty = parseFloat(parts[1]);
break;
case 'ApproachRate':
difficulty.approachRate = parseFloat(parts[1]);
break;
case 'SliderMultiplier':
difficulty.sliderMultiplier = parseFloat(parts[1]);
break;
case 'SliderTickRate':
difficulty.sliderTickRate = parseFloat(parts[1]);
break;
}
}
return difficulty as BeatmapDifficulty;
}
export function parseHitObjects(beatmap: string): any[] {
const lines = beatmap.split('\n');
let recording = false;
const hitObjects = [];
let currentCombo = 1;
for (const line of lines) {
if (line.trim() === '[HitObjects]') {
recording = true;
continue;
}
if (!recording) continue;
if (line.startsWith('[')) break;
const parts = line.split(',');
if (parts.length < 5) continue;
const type = parseInt(parts[3], 10);
const isNewCombo = type & (1 << 2); // Bit at index 2 for new combo
if (isNewCombo) {
currentCombo = 1; // Reset combo
} else {
// If not the start of a new combo, increment the current combo
currentCombo++;
}
const hitObject = {
x: parseInt(parts[0], 10),
y: parseInt(parts[1], 10),
time: parseInt(parts[2], 10),
type: getTypeFromFlag(type),
hitSound: parseInt(parts[4], 10),
objectParams: parts[5],
hitSample: parts.length > 6 ? parts[6] : '0:0:0:0:',
currentCombo: currentCombo
};
if (isNewCombo) {
// Reset currentCombo after assigning to hitObject if it's the start of a new combo
currentCombo = 1;
}
hitObjects.push(hitObject);
}
return hitObjects;
}
function getTypeFromFlag(flag: number): HitObjectType | null {
if (flag & HitObjectType.HitCircle) return HitObjectType.HitCircle;
if (flag & HitObjectType.Slider) return HitObjectType.Slider;
if (flag & HitObjectType.Spinner) return HitObjectType.Spinner;
return null;
}

View File

@ -14,21 +14,30 @@ export class ReplayEventProcessed {
} }
} }
export function processReplay(events: ReplayEvent[]): ReplayEventProcessed[] { export function processReplay(events: ReplayEvent[], mods: string[], audioLeadIn: number): ReplayEventProcessed[] {
console.log(events);
if (events.length === 0) throw new Error("This replay's replay data was empty. It indicates a misbehaved replay."); if (events.length === 0) throw new Error("This replay's replay data was empty. It indicates a misbehaved replay.");
if (events[0].timeDelta === 0 && events.length > 1) events.shift(); if (events[0].timeDelta === 0 && events.length > 1) events.shift();
const pEvents: ReplayEventProcessed[] = []; const pEvents: ReplayEventProcessed[] = [];
let cumulativeTimeDelta = events[0].timeDelta; let cumulativeTimeDelta = events[0].timeDelta + audioLeadIn;
let highestTimeDelta = Number.NEGATIVE_INFINITY; let highestTimeDelta = Number.NEGATIVE_INFINITY;
let lastPositiveFrame: ReplayEvent | null = null; let lastPositiveFrame: ReplayEvent | null = null;
let wasInNegativeSection = false; let wasInNegativeSection = false;
const lastPositiveFrameData: [number, [number, number]][] = []; const lastPositiveFrameData: [number, [number, number]][] = [];
const timeModifier = mods.includes("DT") ? 2 / 3 : mods.includes("HT") ? 4 / 3 : 1;
events.slice(1).forEach((currentFrame, index) => { events.slice(1).forEach((currentFrame, index) => {
currentFrame.timeDelta *= timeModifier;
if(mods.includes("HR")) {
currentFrame.y = 384 - currentFrame.y;
}
const previousCumulativeTime = cumulativeTimeDelta; const previousCumulativeTime = cumulativeTimeDelta;
cumulativeTimeDelta += currentFrame.timeDelta; cumulativeTimeDelta += currentFrame.timeDelta;

View File

@ -1,17 +1,12 @@
import { ElementRef, Injectable } from '@angular/core'; import { ElementRef } from '@angular/core';
import {BeatmapDifficulty, HitObject, HitObjectType, parseBeatmapDifficulty, parseHitObjects} from "./decode-beatmap";
import {processReplay, ReplayEventProcessed} from "./process-replay"; import {processReplay, ReplayEventProcessed} from "./process-replay";
import {ReplayViewerData} from "../../../app/replays"; import {ReplayViewerData, Slider} from "../../../app/replays";
import {getEvents} from "./decode-replay"; import {getEvents} from "./decode-replay";
@Injectable({
providedIn: 'root',
})
export class ReplayService { export class ReplayService {
private hitObjects: HitObject[] = []; private replayViewerData: ReplayViewerData;
private replayEvents: ReplayEventProcessed[] = []; private readonly replayEvents: ReplayEventProcessed[] = [];
private difficulty: BeatmapDifficulty | null = null;
currentTime = 0; currentTime = 0;
speedFactor: number = 1; speedFactor: number = 1;
@ -29,12 +24,20 @@ export class ReplayService {
private hitCircleOverlay = new Image(); private hitCircleOverlay = new Image();
private approachCircleImage = new Image(); private approachCircleImage = new Image();
private cursorImage = new Image(); private cursorImage = new Image();
private sliderBall = new Image();
private reverseArrow = new Image();
constructor() { constructor(replayViewerData: ReplayViewerData) {
this.hitCircleImage.src = 'assets/replay-viewer/hitcircle.png'; this.hitCircleImage.src = 'assets/replay-viewer/hitcircle.png';
this.hitCircleOverlay.src = 'assets/replay-viewer/hitcircleoverlay.png'; this.hitCircleOverlay.src = 'assets/replay-viewer/hitcircleoverlay.png';
this.approachCircleImage.src = 'assets/replay-viewer/approachcircle.png'; this.approachCircleImage.src = 'assets/replay-viewer/approachcircle.png';
this.cursorImage.src = 'assets/replay-viewer/cursor.png'; this.cursorImage.src = 'assets/replay-viewer/cursor.png';
this.sliderBall.src = 'assets/replay-viewer/sliderball.png';
this.reverseArrow.src = 'assets/replay-viewer/reversearrow.png';
this.replayViewerData = replayViewerData;
this.replayEvents = processReplay(getEvents(replayViewerData.replay), replayViewerData.mods, replayViewerData.beatmap.audio_lead_in);
this.calculateTotalDuration();
} }
setCanvasElement(canvas: ElementRef<HTMLCanvasElement>) { setCanvasElement(canvas: ElementRef<HTMLCanvasElement>) {
@ -45,15 +48,6 @@ export class ReplayService {
this.ctx = ctx; this.ctx = ctx;
} }
loadReplay(replayViewerData: ReplayViewerData): void {
this.replayEvents = processReplay(getEvents(replayViewerData.replay))
this.hitObjects = parseHitObjects(replayViewerData.beatmap)
this.calculateTotalDuration();
this.difficulty = parseBeatmapDifficulty(replayViewerData.beatmap)
}
private calculateTotalDuration() { private calculateTotalDuration() {
if (this.replayEvents.length === 0) { if (this.replayEvents.length === 0) {
this.totalDuration = 0; this.totalDuration = 0;
@ -158,22 +152,21 @@ export class ReplayService {
return; return;
} }
const visibleHitCircles = this.hitObjects.filter(obj => const visibleHitCircles = this.replayViewerData!.beatmap.circles.filter(obj =>
obj.type === HitObjectType.HitCircle && this.currentTime >= obj.time - 200 &&
this.currentTime >= obj.time - 200 && // Start showing 200ms before this.currentTime <= obj.time
this.currentTime <= obj.time // Hide after its time
); );
visibleHitCircles.forEach(hitCircle => { visibleHitCircles.forEach(hitCircle => {
const opacity = this.calculateOpacity(hitCircle.time); const opacity = this.calculateOpacity(hitCircle.time);
this.drawHitCircle(hitCircle.x, hitCircle.y, opacity, hitCircle.currentCombo); this.drawHitCircle(hitCircle.x, hitCircle.y, opacity, hitCircle.current_combo, hitCircle.combo_color);
this.drawApproachCircle(hitCircle.x, hitCircle.y, 1, hitCircle.time) this.drawApproachCircle(hitCircle.x, hitCircle.y, 1, hitCircle.time)
}); });
} }
private drawApproachCircle(x: number, y: number, opacity: number, hitTime: number) { private drawApproachCircle(x: number, y: number, opacity: number, hitTime: number) {
let {fadeIn, totalDisplayTime} = this.calculatePreempt(); let {fadeIn, totalDisplayTime} = this.calculatePreempt();
let baseSize = 54.4 - 4.48 * this.difficulty!.circleSize; let baseSize = 54.4 - 4.48 * this.replayViewerData.beatmap.difficulty.circle_size;
// Calculate scale using the provided formula // Calculate scale using the provided formula
let scale = Math.max(1, ((hitTime - this.currentTime) / totalDisplayTime) * 3 + 1); let scale = Math.max(1, ((hitTime - this.currentTime) / totalDisplayTime) * 3 + 1);
@ -203,7 +196,7 @@ export class ReplayService {
} }
private calculatePreempt() { private calculatePreempt() {
const AR = this.difficulty!.approachRate; const AR = this.replayViewerData.beatmap.difficulty.approach_rate;
let fadeIn = 0; let fadeIn = 0;
if (AR === 5) { if (AR === 5) {
@ -227,9 +220,9 @@ export class ReplayService {
return {fadeIn, totalDisplayTime}; return {fadeIn, totalDisplayTime};
} }
private drawHitCircle(x: number, y: number, opacity: number, combo: number) { private drawHitCircle(x: number, y: number, opacity: number, combo: number, color: string) {
this.ctx!.fillStyle = `rgba(255, 255, 255, ${opacity})`; // Set opacity for fade-in effect this.ctx!.fillStyle = `rgba(255, 255, 255, ${opacity})`; // Set opacity for fade-in effect
let radius = 54.4 - 4.48 * this.difficulty!.circleSize; let radius = 54.4 - 4.48 * this.replayViewerData.beatmap.difficulty.circle_size;
this.ctx!.drawImage(this.hitCircleImage, x - radius, y - radius, radius * 2, radius * 2); this.ctx!.drawImage(this.hitCircleImage, x - radius, y - radius, radius * 2, radius * 2);
this.ctx!.drawImage(this.hitCircleOverlay, x - radius, y - radius, radius * 2, radius * 2); this.ctx!.drawImage(this.hitCircleOverlay, x - radius, y - radius, radius * 2, radius * 2);
@ -240,9 +233,9 @@ export class ReplayService {
this.ctx!.fillText(combo.toString(), x - measure.width / 2, y + 10); this.ctx!.fillText(combo.toString(), x - measure.width / 2, y + 10);
} }
private drawSlider(x: number, y: number, opacity: number, combo: number) { private drawSliderHitCircle(x: number, y: number, opacity: number, combo: number) {
this.ctx!.fillStyle = `rgba(255, 255, 255, ${opacity})`; // Set opacity for fade-in effect this.ctx!.fillStyle = `rgba(255, 255, 255, ${opacity})`; // Set opacity for fade-in effect
let radius = 54.4 - 4.48 * this.difficulty!.circleSize; let radius = 54.4 - 4.48 * this.replayViewerData.beatmap.difficulty.circle_size;
this.ctx!.drawImage(this.hitCircleImage, x - radius, y - radius, radius * 2, radius * 2); this.ctx!.drawImage(this.hitCircleImage, x - radius, y - radius, radius * 2, radius * 2);
this.ctx!.drawImage(this.hitCircleOverlay, x - radius, y - radius, radius * 2, radius * 2); this.ctx!.drawImage(this.hitCircleOverlay, x - radius, y - radius, radius * 2, radius * 2);
@ -259,19 +252,240 @@ export class ReplayService {
return; return;
} }
const visibleSliders = this.hitObjects.filter(obj => const visibleSliders = this.replayViewerData.beatmap.sliders.filter(obj => {
obj.type === HitObjectType.Slider && return this.currentTime >= obj.time - 200 && // Start showing 200ms before
this.currentTime >= obj.time - 200 && // Start showing 200ms before this.currentTime <= obj.end_time; // Hide after its time
this.currentTime <= obj.time // Hide after its time });
);
visibleSliders.forEach(slider => { visibleSliders.forEach(slider => {
const opacity = this.calculateOpacity(slider.time); const opacity = this.calculateOpacity(slider.time);
this.drawSlider(slider.x, slider.y, opacity, slider.currentCombo); this.drawSliderBody(slider);
this.drawSliderHitCircle(slider.x, slider.y, opacity, slider.current_combo);
this.drawApproachCircle(slider.x, slider.y, 1, slider.time) this.drawApproachCircle(slider.x, slider.y, 1, slider.time)
}); });
} }
drawSliderBody(slider: Slider): void {
let playfieldScale = 1;
let mapCsr = (54.4 - 4.48 * this.replayViewerData.beatmap.difficulty.circle_size) * 2;
let SLIDERSZ = -7 + mapCsr;
let SLIDERBORDERSIZE = 5;
let duration = slider.end_time - slider.time;
let sliderBallsizeX = mapCsr * (this.sliderBall.width / this.sliderBall.width);
let sliderBallsizeY = mapCsr * (this.sliderBall.height / this.sliderBall.width);
// TODO
var slidesDone;
if (this.currentTime - slider.time < 0){
slidesDone = 0;
} else {
slidesDone = Math.floor((this.currentTime - slider.time) / duration);
}
if(slider.curve.type == "Linear") {
this.ctx!.beginPath();
var start = [slider.x, slider.y];
var end = [slider.curve.points.slice(- 1)[0].x, slider.curve.points.slice(- 1)[0].y];
var clength = Math.pow(Math.pow((end[0] - start[0]), 2) + Math.pow((end[1] - start[1]), 2), 0.5);
if (clength < length * playfieldScale){
this.ctx!.lineTo(((end[0] - start[0]) / clength) * length * playfieldScale + start[0], ((end[1] - start[1]) / clength) * length * playfieldScale + start[1]);
clength = length * playfieldScale;
}
this.ctx!.moveTo(start[0], start[1]);
this.ctx!.lineTo(end[0], end[1]);
this.ctx!.lineCap = 'round';
this.ctx!.lineWidth = SLIDERSZ - SLIDERBORDERSIZE;
this.ctx!.strokeStyle = 'rgb(3,3,12,0.5)';//}
this.ctx!.stroke();
this.ctx!.closePath();
this.ctx!.save();
this.ctx!.translate(start[0], start[1]);
if (end[0] > start[0]) { //I don't have enough braincells to figure out why this is a thing
this.ctx!.rotate(Math.atan((end[1] - start[1]) / (end[0] - start[0])) - Math.PI / 2);
} else {
this.ctx!.rotate(Math.atan((end[1] - start[1]) / (end[0] - start[0])) + Math.PI / 2);
}
this.ctx!.beginPath();
this.ctx!.moveTo(- SLIDERSZ / 2, 0);
this.ctx!.lineTo(- SLIDERSZ / 2, clength);
this.ctx!.arc (0, clength, SLIDERSZ / 2, Math.PI, 0, true);
//this.ctx!.moveTo( SLIDERSZ / 2, length*playfieldScale);
this.ctx!.lineTo(SLIDERSZ / 2, 0);
this.ctx!.arc (0, 0, SLIDERSZ / 2, 0, Math.PI, true);
//this.ctx!.moveTo(-SLIDERSZ / 2, 0);
this.ctx!.lineCap = 'butt';
this.ctx!.lineWidth = SLIDERBORDERSIZE;
this.ctx!.strokeStyle = 'rgb(190,190,190)';
//this.ctx!.strokeRect(-SLIDERSZ / 2,0,SLIDERSZ,length*playfieldScale);
this.ctx!.stroke();
this.ctx!.closePath();
//console.log(slidesDone);
if (slidesDone % 2 === 0){
this.ctx!.translate(0, clength);
this.ctx!.rotate(- Math.PI / 2);
} else {
this.ctx!.rotate(Math.PI / 2);
}
//Draw sliderend (if any)
//1 slide is duration long. current-timing is elapsed time. (current-timing)/duration is amount of slides gone thru.
//therefore, amount of slides left is slides-(current-timing)/duration. But current-timing must be gt 0
if (this.currentTime < slider.time){
if (slider.repeat > 1) {
this.ctx!.drawImage(this.reverseArrow, - mapCsr / 2, - mapCsr / 2, mapCsr, mapCsr);
}
} else {
if (slider.repeat - slidesDone > 1) {
this.ctx!.drawImage(this.reverseArrow, - mapCsr / 2, - mapCsr / 2, mapCsr, mapCsr);
}
if ((slider.repeat - slidesDone > 2)) { //TODO, I can't test this right now, but im pretty sure this will render it when it isnt supposed to be
this.ctx!.drawImage(this.reverseArrow, - mapCsr / 2, - mapCsr / 2, mapCsr, mapCsr); //REMEMEBER TO EDIT THIS FOR ALL SLIDERS !!
}
}
this.ctx!.restore();
//Draw sliderball along the path
if (this.currentTime >= slider.time && this.currentTime < slider.time + duration * 1) {
var sliderBall = (this.currentTime - (slider.time + duration * slidesDone)) * (clength / duration);
var sliderBallX; var sliderBallY;
if (slidesDone % 2 === 0){
sliderBallX = ((end[0] - start[0]) / clength) * sliderBall + start[0];
sliderBallY = ((end[1] - start[1]) / clength) * sliderBall + start[1];
} else {
sliderBallX = ((start[0] - end[0]) / clength) * sliderBall + end[0];
sliderBallY = ((start[1] - end[1]) / clength) * sliderBall + end[1];
}
this.ctx!.drawImage(this.sliderBall, sliderBallX - sliderBallsizeX / 2, sliderBallY - sliderBallsizeX / 2, sliderBallsizeX, sliderBallsizeX);
}
}
if (slider.curve.type == "Perfect"){
var start = [slider.x, slider.y];
var mid = [slider.curve.points[1].x, slider.curve.points[1].y];
var end = [slider.curve.points[2].x, slider.curve.points[2].y];
var arcMidpoint = [];
start[0] -= mid[0]; //Translate points so mid is at origin
start[1] -= mid[1];
end[0] -= mid[0];
end[1] -= mid[1];
var D = 2*(start[0]*end[1]-end[0]*start[1]);
var z1 = start[0]*start[0] + start[1]*start[1];
var z2 = end[0]*end[0]+end[1]*end[1];
arcMidpoint[0] = (z1 * end[1] - z2 * start[1]) / D + mid[0];
arcMidpoint[1] = (start[0] * z2 - end[0] * z1) / D + mid[1];
start[0] += mid[0];
start[1] += mid[1];
end[0] += mid[0];
end[1] += mid[1];
var arcRadius = Math.pow(Math.pow(mid[0]-arcMidpoint[0],2)+Math.pow(mid[1]-arcMidpoint[1],2),0.5);
//var arcStartAngle = -arctan((arcMidpoint[1]-start[1])/(arcMidpoint[0]-start[0]));
//var arcEndAngle = -arctan((end[1]-arcMidpoint[1])/(end[0]-arcMidpoint[0]));
var arcStartAngle = Math.atan2(start[1] - arcMidpoint[1], start[0] - arcMidpoint[0]);
var arcEndAngle = Math.atan2(end[1] - arcMidpoint[1], end[0] - arcMidpoint[0]);
var isClockwise = ((end[0] - start[0]) * (mid[1] - start[1]) - (end[1] - start[1]) * (mid[0] - start[0])) >= 0
var clength = Math.abs(arcEndAngle - arcStartAngle) * arcRadius;
if (length > clength) {
arcEndAngle += (length * playfieldScale - clength) / arcRadius;
end = [arcMidpoint[0] + arcRadius * Math.cos(arcEndAngle),arcMidpoint[1] + arcRadius * Math.sin(arcEndAngle)];
}
//console.log(arcMidpoint[0], arcMidpoint[1], arcRadius, arcStartAngle, arcEndAngle)
this.ctx!.beginPath();
this.ctx!.arc(arcMidpoint[0], arcMidpoint[1], arcRadius, arcStartAngle, arcEndAngle, isClockwise);
this.ctx!.lineCap = 'round';
//this.ctx!.lineWidth = 10;
this.ctx!.lineWidth = SLIDERSZ - SLIDERBORDERSIZE;
// this.ctx!.strokeStyle = 'rgb(200,100,100)';
this.ctx!.strokeStyle = 'rgb(3,3,12,0.5)';//}
this.ctx!.stroke();
this.ctx!.closePath();
//Now draw the slider border //This might have just been the most satisfying thing I've made it a while
this.ctx!.beginPath(); //its so nice when the math just works out how you calculated it to
let effectiveRadius = arcRadius - SLIDERSZ/2;
if (effectiveRadius < 0) {
// Option 1: Adjust SLIDERSZ to ensure effectiveRadius is positive
SLIDERSZ = 2 * arcRadius; // This is just an example adjustment
effectiveRadius = arcRadius - SLIDERSZ/2; // Recalculate effectiveRadius
}
this.ctx!.arc(arcMidpoint[0], arcMidpoint[1], arcRadius+SLIDERSZ/2, arcStartAngle, arcEndAngle, isClockwise);
this.ctx!.arc(end[0], end[1], SLIDERSZ/2, arcEndAngle, arcEndAngle+Math.PI,isClockwise);
this.ctx!.arc(arcMidpoint[0], arcMidpoint[1], effectiveRadius, arcEndAngle, arcStartAngle, !isClockwise);
this.ctx!.arc(start[0], start[1], SLIDERSZ/2, arcStartAngle+Math.PI, arcStartAngle,isClockwise);
this.ctx!.lineCap = 'butt';
this.ctx!.lineWidth = SLIDERBORDERSIZE;
this.ctx!.strokeStyle = 'rgb(190,190,190)';
// this.ctx!.strokeStyle = 'rgb(200,100,100)';
this.ctx!.stroke();
this.ctx!.closePath();
//TODO PRIORITY 1 !!!! !!! !!! !!! !!! fix the sliderend, it looks slanted I swear
this.ctx!.save();
// if (slidesDone % 2 === 0){
// this.ctx!.translate(end[0],end[1]);
// //this.ctx!.rotate(arcEndAngle + pi / 2 + (isClockwise ? 0 : pi)); //This was my first theory on how to rotate the slider
// this.ctx!.rotate(arcEndAngle + Math.PI / 2 + (isClockwise ? 0.1 : Math.PI-0.1));
// } else {
// this.ctx!.translate(start[0],start[1]); //TODO cannot test this
// this.ctx!.rotate(arcStartAngle - Math.PI / 2 + (isClockwise ? Math.PI-0.1 : 0.1));
// }
// if (current < timing){
// if (slides > 1) {
// this.ctx!.drawImage(assets['reverseArrow'], - mapCSr / 2, - mapCSr / 2, mapCSr, mapCSr);
// }
// } else {
// if (slides - slidesDone > 1) {
// this.ctx!.drawImage(assets['reverseArrow'], - mapCSr / 2, - mapCSr / 2, mapCSr, mapCSr);
// }
// //if ((slides - slidesDone > 2)) { //TODO, I can't test this right now, but im pretty sure this will render it when it isnt supposed to be
// // this.ctx!.drawImage(assets['reverseArrow'], end[0] - mapCSr / 2, end[1] - mapCSr / 2, mapCSr, mapCSr);
// //}//TODO THIS WONT WORK AAAAA
// }
this.ctx!.restore();
// //Draw sliderball along the path
let timing = slider.time
if (this.currentTime >= timing && this.currentTime < timing + duration * 1) {
var angleDt = Math.abs(arcStartAngle-arcEndAngle);
//This took way too long and I don't even know why it works or if it there is possible bugged behavior
//If anyone can explain what is happening here or the best practice I would be super thankful
if ((isClockwise && (arcStartAngle<arcEndAngle)) || (!isClockwise && (arcStartAngle>arcEndAngle))) {angleDt = 2*Math.PI - angleDt;}
var sliderBall = (isClockwise?-1:1)*(this.currentTime - (timing + duration * slidesDone)) * (angleDt / duration);
var sliderBallX; var sliderBallY;
if (slidesDone % 2 === 0){
sliderBallX = arcMidpoint[0] + arcRadius * Math.cos(arcStartAngle + sliderBall);
sliderBallY = arcMidpoint[1] + arcRadius * Math.sin(arcStartAngle + sliderBall);
} else {
sliderBallX = arcMidpoint[0] + arcRadius * Math.cos(arcEndAngle - sliderBall);
sliderBallY = arcMidpoint[1] + arcRadius * Math.sin(arcEndAngle - sliderBall);
}
this.ctx!.drawImage(this.sliderBall, sliderBallX - sliderBallsizeX / 2, sliderBallY - sliderBallsizeX / 2, sliderBallsizeX, sliderBallsizeX);
}
}
}
getTotalDuration() { getTotalDuration() {
return this.totalDuration; return this.totalDuration;
} }

View File

@ -2,7 +2,7 @@
<canvas #replayCanvas width="600" height="400"></canvas> <canvas #replayCanvas width="600" height="400"></canvas>
</div> </div>
<ng-container *ngIf="replayService">
<div class="text-center mb-2"> <div class="text-center mb-2">
<button style="font-size: 20px" (click)="togglePlayPause()">{{ replayService.getIsPlaying() ? 'Pause' : 'Play' }}</button> <button style="font-size: 20px" (click)="togglePlayPause()">{{ replayService.getIsPlaying() ? 'Pause' : 'Play' }}</button>
</div> </div>
@ -36,5 +36,8 @@
</div> </div>
</div> </div>
</ng-container>

View File

@ -1,10 +1,7 @@
import {AfterViewInit, Component, ElementRef, Input, OnInit, ViewChild} from '@angular/core'; import {AfterViewInit, Component, ElementRef, Input, OnInit, ViewChild} from '@angular/core';
import {getEvents} from "./decode-replay"; import {DecimalPipe, JsonPipe, NgForOf, NgIf} from "@angular/common";
import {DecimalPipe, JsonPipe, NgForOf} from "@angular/common";
import {ReplayService} from "./replay-service"; import {ReplayService} from "./replay-service";
import {FormsModule} from "@angular/forms"; import {FormsModule} from "@angular/forms";
import {parseHitObjects} from "./decode-beatmap";
import {processReplay} from "./process-replay";
import {ReplayViewerData} from "../../../app/replays"; import {ReplayViewerData} from "../../../app/replays";
export enum KeyPress { export enum KeyPress {
@ -45,18 +42,21 @@ export interface ReplayEvent {
JsonPipe, JsonPipe,
FormsModule, FormsModule,
DecimalPipe, DecimalPipe,
NgForOf NgForOf,
NgIf
], ],
templateUrl: './replay-viewer.component.html', templateUrl: './replay-viewer.component.html',
styleUrl: './replay-viewer.component.css' styleUrl: './replay-viewer.component.css'
}) })
export class ReplayViewerComponent implements OnInit, AfterViewInit { export class ReplayViewerComponent implements AfterViewInit {
@ViewChild('replayCanvas') replayCanvas!: ElementRef<HTMLCanvasElement>; @ViewChild('replayCanvas') replayCanvas!: ElementRef<HTMLCanvasElement>;
private ctx!: CanvasRenderingContext2D; private ctx!: CanvasRenderingContext2D;
@Input() replayViewerData!: ReplayViewerData; @Input() replayViewerData!: ReplayViewerData;
public replayService!: ReplayService | null;
// TODO: Calculate AudioLeadIn // TODO: Calculate AudioLeadIn
// TODO: Hard-Rock, DT, Easy // TODO: Hard-Rock, DT, Easy
@ -72,13 +72,12 @@ export class ReplayViewerComponent implements OnInit, AfterViewInit {
// TODO: Compare two replays for similarity (different cursor color) // TODO: Compare two replays for similarity (different cursor color)
constructor(public replayService: ReplayService) { } constructor() {
ngOnInit() {
this.replayService.loadReplay(this.replayViewerData);
} }
ngAfterViewInit() { ngAfterViewInit() {
this.replayService = new ReplayService(this.replayViewerData);
this.ctx = this.replayCanvas.nativeElement.getContext('2d')!; this.ctx = this.replayCanvas.nativeElement.getContext('2d')!;
this.replayService.setCanvasElement(this.replayCanvas); this.replayService.setCanvasElement(this.replayCanvas);
@ -88,6 +87,8 @@ export class ReplayViewerComponent implements OnInit, AfterViewInit {
} }
togglePlayPause() { togglePlayPause() {
if(!this.replayService) return;
if (this.replayService.getIsPlaying()) { if (this.replayService.getIsPlaying()) {
this.replayService.pause(); this.replayService.pause();
} else { } else {
@ -96,6 +97,8 @@ export class ReplayViewerComponent implements OnInit, AfterViewInit {
} }
seek(time: number) { seek(time: number) {
if(!this.replayService) return;
this.replayService.seek(time); this.replayService.seek(time);
if (!this.replayService.getIsPlaying()) { if (!this.replayService.getIsPlaying()) {
// Redraw the canvas for the new current time without resuming playback // Redraw the canvas for the new current time without resuming playback