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 17ab998..5bb495e 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 @@ -96,7 +97,8 @@ data class ReplayDataSimilarScore( data class ReplayViewerData( val replay: String, - val beatmap: String, + val beatmap: CircleguardService.BeatmapResponse, + val mods: List ) data class ReplayData( 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 2af2b26..ef87501 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 @@ -4,6 +4,7 @@ import com.nisemoe.generated.tables.records.ScoresJudgementsRecord import com.nisemoe.generated.tables.records.ScoresRecord import com.nisemoe.generated.tables.references.* import com.nisemoe.nise.* +import com.nisemoe.nise.integrations.CircleguardService import com.nisemoe.nise.osu.Mod import com.nisemoe.nise.service.AuthService import com.nisemoe.nise.service.CompressJudgements @@ -21,6 +22,7 @@ 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 @@ -45,6 +47,7 @@ class ScoreService( fun getReplayViewerData(replayId: Long): ReplayViewerData? { val result = dslContext.select( SCORES.REPLAY, + SCORES.MODS, BEATMAPS.BEATMAP_FILE ) .from(SCORES) @@ -61,9 +64,15 @@ class ScoreService( 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( replay = replayData, - beatmap = result.get(BEATMAPS.BEATMAP_FILE, String::class.java) + beatmap = beatmapData, + mods = Mod.parseModCombination(mods) ) } diff --git a/nise-backend/src/main/kotlin/com/nisemoe/nise/integrations/CircleguardService.kt b/nise-backend/src/main/kotlin/com/nisemoe/nise/integrations/CircleguardService.kt index 4360ed5..7f96637 100644 --- a/nise-backend/src/main/kotlin/com/nisemoe/nise/integrations/CircleguardService.kt +++ b/nise-backend/src/main/kotlin/com/nisemoe/nise/integrations/CircleguardService.kt @@ -106,6 +106,105 @@ class CircleguardService { 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 + ) + + @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, + val sliders: List, + val spinners: List, + val difficulty: Difficulty, + val audio_lead_in: Double, + ) + + fun processBeatmap(beatmapFile: String, mods: Int): CompletableFuture { + 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 -> + 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 { val requestUri = "$apiUrl/replay" diff --git a/nise-circleguard/src/main.py b/nise-circleguard/src/main.py index 64493e7..d798f45 100644 --- a/nise-circleguard/src/main.py +++ b/nise-circleguard/src/main.py @@ -2,17 +2,16 @@ import base64 import io import os from dataclasses import dataclass, asdict -from itertools import combinations -from math import isnan from typing import List, Iterable import numpy as np import scipy from brparser import Replay, BeatmapOsu, Mod from circleguard import Circleguard, ReplayString, Hit -from circleguard.utils import filter_outliers 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.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 +@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)) == "": + return 'Linear' + if str(type(slider.curve)) == "": + return 'Perfect' + elif str(type(slider.curve)) == "": + return 'MultiBezier' + elif str(type(slider.curve)) == "": + 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 class ReplayRequest: replay_data: str diff --git a/nise-frontend/src/app/replays.ts b/nise-frontend/src/app/replays.ts index b4913cd..6c7e416 100644 --- a/nise-frontend/src/app/replays.ts +++ b/nise-frontend/src/app/replays.ts @@ -13,9 +13,69 @@ export interface ReplayDataSimilarScore { 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 { replay: string; - beatmap: string; + beatmap: BeatmapResponse; + mods: string[]; } export interface ReplayData { diff --git a/nise-frontend/src/assets/replay-viewer/sliderball.png b/nise-frontend/src/assets/replay-viewer/sliderball.png new file mode 100644 index 0000000..316d52c Binary files /dev/null and b/nise-frontend/src/assets/replay-viewer/sliderball.png differ diff --git a/nise-frontend/src/corelib/components/replay-viewer/decode-beatmap.ts b/nise-frontend/src/corelib/components/replay-viewer/decode-beatmap.ts deleted file mode 100644 index a1c881a..0000000 --- a/nise-frontend/src/corelib/components/replay-viewer/decode-beatmap.ts +++ /dev/null @@ -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 = {}; - - 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; -} diff --git a/nise-frontend/src/corelib/components/replay-viewer/process-replay.ts b/nise-frontend/src/corelib/components/replay-viewer/process-replay.ts index 309dd15..cbd9031 100644 --- a/nise-frontend/src/corelib/components/replay-viewer/process-replay.ts +++ b/nise-frontend/src/corelib/components/replay-viewer/process-replay.ts @@ -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[0].timeDelta === 0 && events.length > 1) events.shift(); const pEvents: ReplayEventProcessed[] = []; - let cumulativeTimeDelta = events[0].timeDelta; + let cumulativeTimeDelta = events[0].timeDelta + audioLeadIn; let highestTimeDelta = Number.NEGATIVE_INFINITY; let lastPositiveFrame: ReplayEvent | null = null; let wasInNegativeSection = false; const lastPositiveFrameData: [number, [number, number]][] = []; + const timeModifier = mods.includes("DT") ? 2 / 3 : mods.includes("HT") ? 4 / 3 : 1; + events.slice(1).forEach((currentFrame, index) => { + currentFrame.timeDelta *= timeModifier; + if(mods.includes("HR")) { + currentFrame.y = 384 - currentFrame.y; + } + const previousCumulativeTime = cumulativeTimeDelta; cumulativeTimeDelta += currentFrame.timeDelta; diff --git a/nise-frontend/src/corelib/components/replay-viewer/replay-service.ts b/nise-frontend/src/corelib/components/replay-viewer/replay-service.ts index 2ffa407..341dda5 100644 --- a/nise-frontend/src/corelib/components/replay-viewer/replay-service.ts +++ b/nise-frontend/src/corelib/components/replay-viewer/replay-service.ts @@ -1,17 +1,12 @@ -import { ElementRef, Injectable } from '@angular/core'; -import {BeatmapDifficulty, HitObject, HitObjectType, parseBeatmapDifficulty, parseHitObjects} from "./decode-beatmap"; +import { ElementRef } from '@angular/core'; import {processReplay, ReplayEventProcessed} from "./process-replay"; -import {ReplayViewerData} from "../../../app/replays"; +import {ReplayViewerData, Slider} from "../../../app/replays"; import {getEvents} from "./decode-replay"; -@Injectable({ - providedIn: 'root', -}) export class ReplayService { - private hitObjects: HitObject[] = []; - private replayEvents: ReplayEventProcessed[] = []; - private difficulty: BeatmapDifficulty | null = null; + private replayViewerData: ReplayViewerData; + private readonly replayEvents: ReplayEventProcessed[] = []; currentTime = 0; speedFactor: number = 1; @@ -29,12 +24,20 @@ export class ReplayService { private hitCircleOverlay = new Image(); private approachCircleImage = 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.hitCircleOverlay.src = 'assets/replay-viewer/hitcircleoverlay.png'; this.approachCircleImage.src = 'assets/replay-viewer/approachcircle.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) { @@ -45,15 +48,6 @@ export class ReplayService { 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() { if (this.replayEvents.length === 0) { this.totalDuration = 0; @@ -158,22 +152,21 @@ export class ReplayService { return; } - const visibleHitCircles = this.hitObjects.filter(obj => - obj.type === HitObjectType.HitCircle && - this.currentTime >= obj.time - 200 && // Start showing 200ms before - this.currentTime <= obj.time // Hide after its time + const visibleHitCircles = this.replayViewerData!.beatmap.circles.filter(obj => + this.currentTime >= obj.time - 200 && + this.currentTime <= obj.time ); visibleHitCircles.forEach(hitCircle => { 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) }); } private drawApproachCircle(x: number, y: number, opacity: number, hitTime: number) { 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 let scale = Math.max(1, ((hitTime - this.currentTime) / totalDisplayTime) * 3 + 1); @@ -203,7 +196,7 @@ export class ReplayService { } private calculatePreempt() { - const AR = this.difficulty!.approachRate; + const AR = this.replayViewerData.beatmap.difficulty.approach_rate; let fadeIn = 0; if (AR === 5) { @@ -227,9 +220,9 @@ export class ReplayService { 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 - 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.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); } - 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 - 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.hitCircleOverlay, x - radius, y - radius, radius * 2, radius * 2); @@ -259,19 +252,240 @@ export class ReplayService { return; } - const visibleSliders = this.hitObjects.filter(obj => - obj.type === HitObjectType.Slider && - this.currentTime >= obj.time - 200 && // Start showing 200ms before - this.currentTime <= obj.time // Hide after its time - ); + const visibleSliders = this.replayViewerData.beatmap.sliders.filter(obj => { + return this.currentTime >= obj.time - 200 && // Start showing 200ms before + this.currentTime <= obj.end_time; // Hide after its time + }); visibleSliders.forEach(slider => { 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) }); } + 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 && (arcStartAnglearcEndAngle))) {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() { return this.totalDuration; } diff --git a/nise-frontend/src/corelib/components/replay-viewer/replay-viewer.component.html b/nise-frontend/src/corelib/components/replay-viewer/replay-viewer.component.html index 0b9f4a9..0c77a50 100644 --- a/nise-frontend/src/corelib/components/replay-viewer/replay-viewer.component.html +++ b/nise-frontend/src/corelib/components/replay-viewer/replay-viewer.component.html @@ -2,39 +2,42 @@ - -
- -
- -
-
- -
- -
- -
- -
-
- -
- -
- -
- -
- -
- -
- - -
- + +
+
-
+ +
+
+ +
+ +
+ +
+ +
+
+ +
+ +
+ +
+ +
+ +
+ +
+ + +
+ +
+
+ + + diff --git a/nise-frontend/src/corelib/components/replay-viewer/replay-viewer.component.ts b/nise-frontend/src/corelib/components/replay-viewer/replay-viewer.component.ts index 5a55c9d..0f50505 100644 --- a/nise-frontend/src/corelib/components/replay-viewer/replay-viewer.component.ts +++ b/nise-frontend/src/corelib/components/replay-viewer/replay-viewer.component.ts @@ -1,10 +1,7 @@ import {AfterViewInit, Component, ElementRef, Input, OnInit, ViewChild} from '@angular/core'; -import {getEvents} from "./decode-replay"; -import {DecimalPipe, JsonPipe, NgForOf} from "@angular/common"; +import {DecimalPipe, JsonPipe, NgForOf, NgIf} from "@angular/common"; import {ReplayService} from "./replay-service"; import {FormsModule} from "@angular/forms"; -import {parseHitObjects} from "./decode-beatmap"; -import {processReplay} from "./process-replay"; import {ReplayViewerData} from "../../../app/replays"; export enum KeyPress { @@ -45,18 +42,21 @@ export interface ReplayEvent { JsonPipe, FormsModule, DecimalPipe, - NgForOf + NgForOf, + NgIf ], templateUrl: './replay-viewer.component.html', styleUrl: './replay-viewer.component.css' }) -export class ReplayViewerComponent implements OnInit, AfterViewInit { +export class ReplayViewerComponent implements AfterViewInit { @ViewChild('replayCanvas') replayCanvas!: ElementRef; private ctx!: CanvasRenderingContext2D; @Input() replayViewerData!: ReplayViewerData; + public replayService!: ReplayService | null; + // TODO: Calculate AudioLeadIn // TODO: Hard-Rock, DT, Easy @@ -72,13 +72,12 @@ export class ReplayViewerComponent implements OnInit, AfterViewInit { // TODO: Compare two replays for similarity (different cursor color) - constructor(public replayService: ReplayService) { } + constructor() { - ngOnInit() { - this.replayService.loadReplay(this.replayViewerData); } ngAfterViewInit() { + this.replayService = new ReplayService(this.replayViewerData); this.ctx = this.replayCanvas.nativeElement.getContext('2d')!; this.replayService.setCanvasElement(this.replayCanvas); @@ -88,6 +87,8 @@ export class ReplayViewerComponent implements OnInit, AfterViewInit { } togglePlayPause() { + if(!this.replayService) return; + if (this.replayService.getIsPlaying()) { this.replayService.pause(); } else { @@ -96,6 +97,8 @@ export class ReplayViewerComponent implements OnInit, AfterViewInit { } seek(time: number) { + if(!this.replayService) return; + this.replayService.seek(time); if (!this.replayService.getIsPlaying()) { // Redraw the canvas for the new current time without resuming playback