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 5bb495e..a986d04 100644 --- a/nise-backend/src/main/kotlin/com/nisemoe/nise/Models.kt +++ b/nise-backend/src/main/kotlin/com/nisemoe/nise/Models.kt @@ -1,6 +1,5 @@ package com.nisemoe.nise -import com.nisemoe.nise.integrations.CircleguardService import kotlinx.serialization.Serializable import java.time.OffsetDateTime @@ -96,9 +95,9 @@ data class ReplayDataSimilarScore( ) data class ReplayViewerData( + val beatmap: String, val replay: String, - val beatmap: CircleguardService.BeatmapResponse, - val mods: List + val mods: Int ) data class ReplayData( diff --git a/nise-backend/src/main/kotlin/com/nisemoe/nise/config/CustomCorsFilter.kt b/nise-backend/src/main/kotlin/com/nisemoe/nise/config/CustomCorsFilter.kt index 521f659..d82064b 100644 --- a/nise-backend/src/main/kotlin/com/nisemoe/nise/config/CustomCorsFilter.kt +++ b/nise-backend/src/main/kotlin/com/nisemoe/nise/config/CustomCorsFilter.kt @@ -1,20 +1,38 @@ package com.nisemoe.nise.config import jakarta.servlet.* +import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse import org.springframework.beans.factory.annotation.Value +import org.springframework.security.web.header.HeaderWriterFilter class CustomCorsFilter : Filter { @Value("\${ORIGIN:http://localhost:4200}") private lateinit var origin: String + @Value("\${REPLAY_ORIGIN:http://localhost:5173}") + private lateinit var replayOrigin: String + override fun init(filterConfig: FilterConfig) { // We don't really need to do anything special here. } override fun doFilter(servletRequest: ServletRequest, servletResponse: ServletResponse, filterChain: FilterChain) { val response = servletResponse as HttpServletResponse + val request = servletRequest as HttpServletRequest + + if(response.containsHeader("X-NISE-REPLAY") || request.getHeader("origin") == replayOrigin) { + response.setHeader("Access-Control-Allow-Origin", replayOrigin) + response.setHeader("Access-Control-Allow-Methods", "GET") + response.setHeader( + "Access-Control-Allow-Headers", + "Access-Control-Allow-Headers, Origin,Accept, X-NISE-REPLAY, X-Requested-With, Content-Type, Access-Control-Request-Method, Access-Control-Request-Headers" + ) + filterChain.doFilter(servletRequest, servletResponse) + return + } + response.setHeader("Access-Control-Allow-Origin", origin) response.setHeader("Access-Control-Allow-Methods", "GET,POST,DELETE,PUT,OPTIONS,PATCH") response.setHeader( diff --git a/nise-backend/src/main/kotlin/com/nisemoe/nise/controller/ScoreController.kt b/nise-backend/src/main/kotlin/com/nisemoe/nise/controller/ScoreController.kt index 4e83765..b929f02 100644 --- a/nise-backend/src/main/kotlin/com/nisemoe/nise/controller/ScoreController.kt +++ b/nise-backend/src/main/kotlin/com/nisemoe/nise/controller/ScoreController.kt @@ -6,6 +6,7 @@ import com.nisemoe.nise.ReplayPair import com.nisemoe.nise.ReplayViewerData import com.nisemoe.nise.database.ScoreService import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.CrossOrigin import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.RestController @@ -15,6 +16,7 @@ class ScoreController( private val scoreService: ScoreService ) { + @CrossOrigin(origins = ["http://wizardly_nash.local"]) @GetMapping("score/{replayId}/replay") fun getReplay(@PathVariable replayId: Long): ResponseEntity { val replay = this.scoreService.getReplayViewerData(replayId) diff --git a/nise-backend/src/main/kotlin/com/nisemoe/nise/database/ScoreService.kt b/nise-backend/src/main/kotlin/com/nisemoe/nise/database/ScoreService.kt index ef87501..ef5ad07 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 @@ -55,27 +55,26 @@ class ScoreService( .where(SCORES.REPLAY_ID.eq(replayId)) .fetchOne() ?: return null - var replayData = result.get(SCORES.REPLAY, String::class.java) ?: return null + val replayData = result.get(SCORES.REPLAY, String::class.java) ?: return null - val decompressedReplay = Base64.getDecoder().decode(replayData).inputStream().use { byteStream -> - LZMACompressorInputStream(byteStream).readBytes() - } - replayData = String(decompressedReplay, Charsets.UTF_8).trimEnd(',') + val replay = decompressData(replayData) 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 = beatmapData, - mods = Mod.parseModCombination(mods) + beatmap = result.get(BEATMAPS.BEATMAP_FILE, String::class.java), + replay = String(replay, Charsets.UTF_8).trimEnd(','), + mods = mods ) } + private fun decompressData(replayString: String): ByteArray = + Base64.getDecoder().decode(replayString).inputStream().use { byteStream -> + LZMACompressorInputStream(byteStream).readBytes() + } + fun getReplayData(replayId: Long): ReplayData? { val result = dslContext.select( SCORES.ID, 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 7f96637..4360ed5 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,105 +106,6 @@ 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 d798f45..6c1adae 100644 --- a/nise-circleguard/src/main.py +++ b/nise-circleguard/src/main.py @@ -42,137 +42,14 @@ 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)) +def remove_bom_from_first_line(beatmap_file): + lines = beatmap_file.splitlines() + if lines: # Check if there are lines to avoid index errors + # Remove BOM only from the first line + lines[0] = lines[0].replace('\ufeff', '') + # Join the lines back together + clean_content = '\n'.join(lines) + return clean_content @dataclass @@ -261,7 +138,8 @@ def process_replay(): result_bytes1 = memory_stream1.getvalue() replay1 = ReplayString(result_bytes1) - cg_beatmap = Beatmap.parse(replay_request.beatmap_data) + clean_beatmap_file = remove_bom_from_first_line(replay_request.beatmap_data) + cg_beatmap = Beatmap.parse(clean_beatmap_file) ur = cg.ur(replay=replay1, beatmap=cg_beatmap) adjusted_ur = cg.ur(replay=replay1, beatmap=cg_beatmap, adjusted=True) diff --git a/nise-frontend/src/app/app.component.ts b/nise-frontend/src/app/app.component.ts index 5defd07..4655b96 100644 --- a/nise-frontend/src/app/app.component.ts +++ b/nise-frontend/src/app/app.component.ts @@ -3,7 +3,6 @@ import {Router, RouterLink, RouterOutlet} from "@angular/router"; import {UserService} from "../corelib/service/user.service"; import {NgIf} from '@angular/common'; import {FormsModule} from '@angular/forms'; -import {ReplayViewerComponent} from "../corelib/components/replay-viewer/replay-viewer.component"; @Component({ selector: 'app-root', diff --git a/nise-frontend/src/app/replays.ts b/nise-frontend/src/app/replays.ts index 6c7e416..e9fdadc 100644 --- a/nise-frontend/src/app/replays.ts +++ b/nise-frontend/src/app/replays.ts @@ -13,71 +13,6 @@ 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: BeatmapResponse; - mods: string[]; -} - export interface ReplayData { replay_id: number; user_id: number; diff --git a/nise-frontend/src/app/view-score/view-score.component.html b/nise-frontend/src/app/view-score/view-score.component.html index a9b6426..8ceac98 100644 --- a/nise-frontend/src/app/view-score/view-score.component.html +++ b/nise-frontend/src/app/view-score/view-score.component.html @@ -201,9 +201,4 @@ class="chart"> - -
-

# replay viewer (experimental)

- -
diff --git a/nise-frontend/src/app/view-score/view-score.component.ts b/nise-frontend/src/app/view-score/view-score.component.ts index f9cabd6..9dbe4e4 100644 --- a/nise-frontend/src/app/view-score/view-score.component.ts +++ b/nise-frontend/src/app/view-score/view-score.component.ts @@ -6,12 +6,11 @@ import {environment} from "../../environments/environment"; import {DecimalPipe, JsonPipe, NgForOf, NgIf, NgOptimizedImage} from "@angular/common"; import {ActivatedRoute, RouterLink} from "@angular/router"; import {catchError, throwError} from "rxjs"; -import {DistributionEntry, ReplayData, ReplayViewerData} from "../replays"; +import {DistributionEntry, ReplayData} from "../replays"; import {calculateAccuracy} from "../format"; import {Title} from "@angular/platform-browser"; import {OsuGradeComponent} from "../../corelib/components/osu-grade/osu-grade.component"; import {ChartComponent} from "../../corelib/components/chart/chart.component"; -import {ReplayViewerComponent} from "../../corelib/components/replay-viewer/replay-viewer.component"; @Component({ selector: 'app-view-score', @@ -25,8 +24,7 @@ import {ReplayViewerComponent} from "../../corelib/components/replay-viewer/repl NgOptimizedImage, RouterLink, OsuGradeComponent, - ChartComponent, - ReplayViewerComponent + ChartComponent ], templateUrl: './view-score.component.html', styleUrl: './view-score.component.css' @@ -39,7 +37,6 @@ export class ViewScoreComponent implements OnInit { isLoading = false; error: string | null = null; replayData: ReplayData | null = null; - replayViewerData: ReplayViewerData | null = null; replayId: number | null = null; public barChartLegend = true; @@ -77,10 +74,6 @@ export class ViewScoreComponent implements OnInit { this.replayId = params['replayId']; if (this.replayId) { this.loadScoreData(); - - if(this.activatedRoute.snapshot.queryParams['viewer'] === 'true') { - this.loadReplayViewerData(); - } } }); } @@ -99,15 +92,6 @@ export class ViewScoreComponent implements OnInit { return url; } - private loadReplayViewerData(): void { - this.http.get(`${environment.apiUrl}/score/${this.replayId}/replay`) - .subscribe({ - next: (response) => { - this.replayViewerData = response; - } - }); - } - private loadScoreData(): void { this.isLoading = true; this.http.get(`${environment.apiUrl}/score/${this.replayId}`).pipe( diff --git a/nise-frontend/src/assets/replay-viewer/approachcircle.png b/nise-frontend/src/assets/replay-viewer/approachcircle.png deleted file mode 100644 index 56d6d34..0000000 Binary files a/nise-frontend/src/assets/replay-viewer/approachcircle.png and /dev/null differ diff --git a/nise-frontend/src/assets/replay-viewer/cursor.png b/nise-frontend/src/assets/replay-viewer/cursor.png deleted file mode 100644 index 87d9831..0000000 Binary files a/nise-frontend/src/assets/replay-viewer/cursor.png and /dev/null differ diff --git a/nise-frontend/src/assets/replay-viewer/hitcircle.png b/nise-frontend/src/assets/replay-viewer/hitcircle.png deleted file mode 100644 index 8259a97..0000000 Binary files a/nise-frontend/src/assets/replay-viewer/hitcircle.png and /dev/null differ diff --git a/nise-frontend/src/assets/replay-viewer/hitcircleoverlay.png b/nise-frontend/src/assets/replay-viewer/hitcircleoverlay.png deleted file mode 100644 index 0b47e9f..0000000 Binary files a/nise-frontend/src/assets/replay-viewer/hitcircleoverlay.png and /dev/null differ diff --git a/nise-frontend/src/assets/replay-viewer/reversearrow.png b/nise-frontend/src/assets/replay-viewer/reversearrow.png deleted file mode 100644 index beaacf3..0000000 Binary files a/nise-frontend/src/assets/replay-viewer/reversearrow.png and /dev/null differ diff --git a/nise-frontend/src/assets/replay-viewer/sliderball.png b/nise-frontend/src/assets/replay-viewer/sliderball.png deleted file mode 100644 index 316d52c..0000000 Binary files a/nise-frontend/src/assets/replay-viewer/sliderball.png and /dev/null differ diff --git a/nise-frontend/src/corelib/components/replay-viewer/decode-replay.ts b/nise-frontend/src/corelib/components/replay-viewer/decode-replay.ts deleted file mode 100644 index f93d6c1..0000000 --- a/nise-frontend/src/corelib/components/replay-viewer/decode-replay.ts +++ /dev/null @@ -1,45 +0,0 @@ -import {KeyPress, ReplayEvent} from "./replay-viewer.component"; - -export function getEvents(replayString: string): ReplayEvent[] { - const trimmedReplayDataStr = replayString.endsWith(',') ? replayString.slice(0, -1) : replayString; - return processEvents(trimmedReplayDataStr); -} - -function processEvents(replayDataStr: string): ReplayEvent[] { - const eventStrings = replayDataStr.split(","); - const playData: ReplayEvent[] = []; - eventStrings.forEach((eventStr, index) => { - const event = createReplayEvent(eventStr.split('|'), index, eventStrings.length); - if (event) playData.push(event); - }); - return playData; -} - -function createReplayEvent(eventParts: string[], index: number, totalEvents: number): ReplayEvent | null { - const timeDelta = parseInt(eventParts[0], 10); - const x = parseFloat(eventParts[1]); - const y = parseFloat(eventParts[2]); - const rawKey = parseInt(eventParts[3], 10); - - if (timeDelta == -12345 && index == totalEvents - 1) { - return null; - } - if (index < 2 && x == 256.0 && y == -500.0) { - return null; - } - - let keys: KeyPress[] = []; - Object.keys(KeyPress).forEach(key => { - const keyPress = KeyPress[key as keyof typeof KeyPress]; - if ((rawKey & keyPress) === keyPress) { - keys.push(keyPress); - } - }); - - return { - timeDelta, - x, - y, - keys - }; -} diff --git a/nise-frontend/src/corelib/components/replay-viewer/process-replay.ts b/nise-frontend/src/corelib/components/replay-viewer/process-replay.ts deleted file mode 100644 index cbd9031..0000000 --- a/nise-frontend/src/corelib/components/replay-viewer/process-replay.ts +++ /dev/null @@ -1,81 +0,0 @@ -import {KeyPress, ReplayEvent} from "./replay-viewer.component"; - -export class ReplayEventProcessed { - x: number; - y: number; - t: number; - keys: KeyPress[]; - - constructor(x: number, y: number, t: number, keys: KeyPress[]) { - this.x = x; - this.y = y; - this.t = t; - this.keys = keys; - } -} - -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 + 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; - - highestTimeDelta = Math.max(highestTimeDelta, cumulativeTimeDelta); - - const isInNegativeSection = cumulativeTimeDelta < highestTimeDelta; - if (isInNegativeSection) { - if (!wasInNegativeSection) { - lastPositiveFrame = index > 0 ? events[index] : null; - } - } else { - if (wasInNegativeSection && lastPositiveFrame) { - const lastPositiveTime = lastPositiveFrameData.length > 0 ? lastPositiveFrameData[lastPositiveFrameData.length - 1][0] : previousCumulativeTime; - const ratio = (lastPositiveTime - previousCumulativeTime) / (cumulativeTimeDelta - previousCumulativeTime); - - const interpolatedX = lastPositiveFrame.x + ratio * (currentFrame.x - lastPositiveFrame.x); - const interpolatedY = lastPositiveFrame.y + ratio * (currentFrame.y - lastPositiveFrame.y); - - pEvents.push(new ReplayEventProcessed(interpolatedX, interpolatedY, lastPositiveTime, lastPositiveFrame.keys)); - } - wasInNegativeSection = false; - } - - wasInNegativeSection = isInNegativeSection; - - if (!isInNegativeSection) { - pEvents.push(new ReplayEventProcessed(currentFrame.x, currentFrame.y, cumulativeTimeDelta, currentFrame.keys)); - } - - if (!isInNegativeSection) { - lastPositiveFrameData.push([cumulativeTimeDelta, [currentFrame.x, currentFrame.y]]); - } - }); - - // Ensuring uniqueness based on time to avoid duplicates - return pEvents.filter((event, index, self) => - index === self.findIndex((t) => ( - t.t === event.t - )) - ); -} diff --git a/nise-frontend/src/corelib/components/replay-viewer/replay-service.ts b/nise-frontend/src/corelib/components/replay-viewer/replay-service.ts deleted file mode 100644 index 341dda5..0000000 --- a/nise-frontend/src/corelib/components/replay-viewer/replay-service.ts +++ /dev/null @@ -1,497 +0,0 @@ -import { ElementRef } from '@angular/core'; -import {processReplay, ReplayEventProcessed} from "./process-replay"; -import {ReplayViewerData, Slider} from "../../../app/replays"; -import {getEvents} from "./decode-replay"; - -export class ReplayService { - - private replayViewerData: ReplayViewerData; - private readonly replayEvents: ReplayEventProcessed[] = []; - - currentTime = 0; - speedFactor: number = 1; - - private totalDuration = 0; - private lastRenderTime = 0; - - private isPlaying = false; - private requestId: number | null = null; - - private replayCanvas: ElementRef | null = null; - private ctx: CanvasRenderingContext2D | null = null; - - private hitCircleImage = new Image(); - private hitCircleOverlay = new Image(); - private approachCircleImage = new Image(); - private cursorImage = new Image(); - private sliderBall = new Image(); - private reverseArrow = new Image(); - - 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) { - this.replayCanvas = canvas; - } - - setCanvasContext(ctx: CanvasRenderingContext2D) { - this.ctx = ctx; - } - - private calculateTotalDuration() { - if (this.replayEvents.length === 0) { - this.totalDuration = 0; - return; - } - const lastEvent = this.replayEvents[this.replayEvents.length - 1]; - this.totalDuration = lastEvent.t; - } - - start() { - this.isPlaying = true; - if(this.currentTime >= this.totalDuration) { - this.currentTime = 0; - } - this.animate(); - } - - pause() { - this.isPlaying = false; - if (this.requestId) { - cancelAnimationFrame(this.requestId); - this.requestId = null; - } - } - - seek(time: number) { - this.currentTime = Math.min(Math.max(time, 0), this.totalDuration); - this.lastRenderTime = 0; - this.isPlaying = true; - this.animate(); - this.isPlaying = false; - } - - private animate(currentTimestamp: number = 0) { - if (!this.isPlaying) return; - - if (!this.lastRenderTime) { - this.lastRenderTime = currentTimestamp; - } - - if(!this.ctx || !this.replayCanvas) { - console.error('Canvas context not initialized'); - return; - } - - const elapsedTime = currentTimestamp - this.lastRenderTime; - - // Check if enough time has passed for the next frame (approximately 16.67ms for 60 FPS) - if (elapsedTime < 16.67) { - // Request the next animation frame and return early - this.requestId = requestAnimationFrame(this.animate.bind(this)); - return; - } - - // Assuming elapsedTime is sufficient for 1 frame, update currentTime for real-time playback - this.currentTime += elapsedTime * this.speedFactor; - - if (this.currentTime > this.totalDuration) { - this.currentTime = this.totalDuration; - this.pause(); - return; - } - - this.ctx.clearRect(0, 0, this.replayCanvas.nativeElement.width, this.replayCanvas.nativeElement.height); - this.drawHitCircles(); - this.drawSliders(); - this.drawCursor(); - - this.lastRenderTime = currentTimestamp; - - // Request the next frame - this.requestId = requestAnimationFrame(this.animate.bind(this)); - } - - public getCurrentReplayEvent(): ReplayEventProcessed | null { - let currentEvent: ReplayEventProcessed | null = null; - for (const event of this.replayEvents) { - if (event.t <= this.currentTime) { - currentEvent = event; - } else { - break; // Exit the loop once an event exceeds the current time - } - } - return currentEvent; - } - - private drawCursor() { - const currentEvent = this.getCurrentReplayEvent(); - if (currentEvent) { - if (!this.ctx || !this.replayCanvas) { - console.error('Canvas context not initialized'); - return; - } - - this.ctx.drawImage(this.cursorImage, currentEvent.x - 32, currentEvent.y - 32, 64, 64); - } - } - - private drawHitCircles() { - if (!this.ctx || !this.replayCanvas) { - console.error('Canvas context not initialized'); - return; - } - - 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.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.replayViewerData.beatmap.difficulty.circle_size; - - // Calculate scale using the provided formula - let scale = Math.max(1, ((hitTime - this.currentTime) / totalDisplayTime) * 3 + 1); - - // Adjust baseSize according to the scale - baseSize *= scale; - - this.ctx!.drawImage(this.approachCircleImage, x - baseSize, y - baseSize, baseSize * 2, baseSize * 2); - } - - private calculateOpacity(circleTime: number): number { - const timeDifference = circleTime - this.currentTime; - if (timeDifference < 0) { - return 0; // Circle time has passed, so it should be fully transparent - } - - let {fadeIn, totalDisplayTime} = this.calculatePreempt(); - - // Adjust the opacity calculation based on fadeIn and totalDisplayTime - if (timeDifference > totalDisplayTime) { - return 0; // Circle is not visible yet - } else if (timeDifference > fadeIn) { - return 1; // Circle is fully visible (opaque) - } else { - return (fadeIn - timeDifference) / fadeIn; - } - } - - private calculatePreempt() { - const AR = this.replayViewerData.beatmap.difficulty.approach_rate; - let fadeIn = 0; - - if (AR === 5) { - fadeIn = 800; - } else if (AR > 5) { - fadeIn = 800 - 500 * (AR - 5) / 5; - } else if (AR < 5) { - fadeIn = 800 + 400 * (5 - AR) / 5; - } - - let stayVisibleTime: number; - if (AR > 5) { - stayVisibleTime = 1200 + 600 * (5 - AR) / 5; - } else if (AR < 5) { - stayVisibleTime = 1200 - 750 * (AR - 5) / 5; - } else { - stayVisibleTime = 1200; - } - - let totalDisplayTime = fadeIn + stayVisibleTime; - return {fadeIn, totalDisplayTime}; - } - - 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.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); - - // Draw combo - this.ctx!.font = "32px monospace"; - const measure = this.ctx!.measureText(combo.toString()); - this.ctx!.fillText(combo.toString(), x - measure.width / 2, y + 10); - } - - 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.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); - - // Draw combo - this.ctx!.font = "32px monospace"; - const measure = this.ctx!.measureText(combo.toString()); - this.ctx!.fillText(combo.toString(), x - measure.width / 2, y + 10); - } - - private drawSliders() { - if (!this.ctx || !this.replayCanvas) { - console.error('Canvas context not initialized'); - return; - } - - 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.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; - } - - getIsPlaying() { - return this.isPlaying; - } - -} diff --git a/nise-frontend/src/corelib/components/replay-viewer/replay-viewer.component.css b/nise-frontend/src/corelib/components/replay-viewer/replay-viewer.component.css deleted file mode 100644 index 3ad814f..0000000 --- a/nise-frontend/src/corelib/components/replay-viewer/replay-viewer.component.css +++ /dev/null @@ -1,39 +0,0 @@ -.row { - display: flex; - flex-direction: row; - flex-wrap: wrap; - width: 100%; -} - -.column { - display: flex; - flex-direction: column; - flex-basis: 100%; - flex: 1; -} - -ul { - list-style: none; -} - -/* Flex container */ -.flex-container { - display: flex; - flex-wrap: wrap; - justify-content: center; - gap: 20px; /* Adjust the gap between items as needed */ -} - -/* Flex items - default to full width to stack on smaller screens */ -.flex-container > div { - flex: 0 0 100%; - box-sizing: border-box; /* To include padding and border in the element's total width and height */ -} - -/* Responsive columns */ -@media (min-width: 768px) { /* Adjust the breakpoint as needed */ - .flex-container > div { - flex: 0 0 40%; - max-width: 50%; - } -} 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 deleted file mode 100644 index 0c77a50..0000000 --- a/nise-frontend/src/corelib/components/replay-viewer/replay-viewer.component.html +++ /dev/null @@ -1,43 +0,0 @@ -
- -
- - -
- -
- -
-
- -
- -
- -
- -
-
- -
- -
- -
- -
- -
- -
- - -
- -
-
-
- - - - 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 deleted file mode 100644 index 0f50505..0000000 --- a/nise-frontend/src/corelib/components/replay-viewer/replay-viewer.component.ts +++ /dev/null @@ -1,108 +0,0 @@ -import {AfterViewInit, Component, ElementRef, Input, OnInit, ViewChild} from '@angular/core'; -import {DecimalPipe, JsonPipe, NgForOf, NgIf} from "@angular/common"; -import {ReplayService} from "./replay-service"; -import {FormsModule} from "@angular/forms"; -import {ReplayViewerData} from "../../../app/replays"; - -export enum KeyPress { - M1 = 1, - M2 = 2, - K1 = 5, - K2 = 10, - Smoke = 16, -} - -export interface ReplayEvent { - /** - * Time in milliseconds since the previous action - */ - timeDelta: number; - - /** - * x-coordinate of the cursor from 0 - 512 - */ - x: number; - - /** - * y-coordinate of the cursor from 0 - 384 - */ - y: number; - - /** - * Keys being pressed. - */ - keys: KeyPress[]; - -} - -@Component({ - selector: 'app-replay-viewer', - standalone: true, - imports: [ - JsonPipe, - FormsModule, - DecimalPipe, - NgForOf, - NgIf - ], - templateUrl: './replay-viewer.component.html', - styleUrl: './replay-viewer.component.css' -}) -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 - - // TODO: Cursor trail and where keys are pressed - // TODO: Button for -100 ms, +100 ms, etc (precise seeking) (or keyboard shortcuts) - - // TODO: UR bar - // Todo: Customizable speed - // TODO: Customizable zoom - // TODO: Fullscreen mode - - // TODO: Hit/Miss, Combo, Accuracy - - // TODO: Compare two replays for similarity (different cursor color) - - constructor() { - - } - - ngAfterViewInit() { - this.replayService = new ReplayService(this.replayViewerData); - this.ctx = this.replayCanvas.nativeElement.getContext('2d')!; - - this.replayService.setCanvasElement(this.replayCanvas); - this.replayService.setCanvasContext(this.ctx); - - this.replayService.start(); // Start the animation loop - } - - togglePlayPause() { - if(!this.replayService) return; - - if (this.replayService.getIsPlaying()) { - this.replayService.pause(); - } else { - this.replayService.start(); - } - } - - 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 - } - } - -} diff --git a/nise-frontend/src/corelib/components/replay-viewer/sample-replay.ts b/nise-frontend/src/corelib/components/replay-viewer/sample-replay.ts deleted file mode 100644 index f69ae9c..0000000 --- a/nise-frontend/src/corelib/components/replay-viewer/sample-replay.ts +++ /dev/null @@ -1,224 +0,0 @@ -export let replayData = "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" -export let beatmapData = "osu file format v14\n" + - "\n" + - "[General]\n" + - "AudioFilename: audio.mp3\n" + - "AudioLeadIn: 0\n" + - "PreviewTime: 932\n" + - "Countdown: 0\n" + - "SampleSet: Soft\n" + - "StackLeniency: 0.5\n" + - "Mode: 0\n" + - "LetterboxInBreaks: 0\n" + - "WidescreenStoryboard: 1\n" + - "\n" + - "[Editor]\n" + - "Bookmarks: 1327,11432,21537,31642\n" + - "DistanceSpacing: 0.5\n" + - "BeatDivisor: 4\n" + - "GridSize: 32\n" + - "TimelineZoom: 3.15\n" + - "\n" + - "[Metadata]\n" + - "Title:PADORU / PADORU\n" + - "TitleUnicode:PADORU / PADORU\n" + - "Artist:Turbo\n" + - "ArtistUnicode:Turbo\n" + - "Creator:DeRandom Otaku\n" + - "Version:Rolniczy's Hi-Speed Expert\n" + - "Source:Fate/EXTRA\n" + - "Tags:TurboAutism eurobeats nero claudius ren kowari -_frontier_- anzeigeistraus iljaaz rolniczy fuju smokelind deppyforce xenon- xehn affirmation neoskylove dorsalplum -_light_- xen rolni contagious Serizawa Haruki Jack J_a_c_k electronic japanese jingle bells 丹下桜 Sakura Tange Saber\n" + - "BeatmapID:2223135\n" + - "BeatmapSetID:1061287\n" + - "\n" + - "[Difficulty]\n" + - "HPDrainRate:6\n" + - "CircleSize:3.6\n" + - "OverallDifficulty:8.6\n" + - "ApproachRate:9.5\n" + - "SliderMultiplier:2.1\n" + - "SliderTickRate:1\n" + - "\n" + - "[Events]\n" + - "//Background and Video events\n" + - "0,0,\"bg.jpg\",0,0\n" + - "//Break Periods\n" + - "//Storyboard Layer 0 (Background)\n" + - "//Storyboard Layer 1 (Fail)\n" + - "//Storyboard Layer 2 (Pass)\n" + - "//Storyboard Layer 3 (Foreground)\n" + - "//Storyboard Layer 4 (Overlay)\n" + - "//Storyboard Sound Samples\n" + - "\n" + - "[TimingPoints]\n" + - "64,315.789473684211,4,2,0,60,1,0\n" + - "64,-100,4,2,0,60,0,0\n" + - "1011,-100,4,2,1,45,0,0\n" + - "1327,-100,4,2,1,60,0,0\n" + - "5748,-200,4,2,1,60,0,0\n" + - "8590,-125,4,2,1,65,0,0\n" + - "11432,-66.6666666666667,4,2,1,80,0,0\n" + - "12853,-62.5,4,2,1,80,0,0\n" + - "13327,-71.4285714285714,4,2,1,80,0,0\n" + - "13958,-66.6666666666667,4,2,1,80,0,0\n" + - "14274,-62.5,4,2,1,80,0,0\n" + - "14590,-66.6666666666667,4,2,1,80,0,0\n" + - "15379,-58.8235294117647,4,2,1,80,0,0\n" + - "15853,-66.6666666666667,4,2,1,80,0,0\n" + - "16485,-62.5,4,2,1,80,0,0\n" + - "17906,-55.5555555555556,4,2,1,80,0,0\n" + - "18537,-62.5,4,2,1,80,0,0\n" + - "19011,-58.8235294117647,4,2,1,80,0,0\n" + - "19958,-66.6666666666667,4,2,1,80,0,0\n" + - "20274,-66.6666666666667,4,2,1,70,0,0\n" + - "20748,-111.111111111111,4,2,1,70,0,0\n" + - "21537,-62.5,4,2,1,90,0,0\n" + - "22958,-58.8235294117647,4,2,1,90,0,0\n" + - "24064,-62.5,4,2,1,90,0,0\n" + - "25485,-55.5555555555556,4,2,1,90,0,0\n" + - "26590,-62.5,4,2,1,90,0,0\n" + - "29116,-45.4545454545455,4,2,1,90,0,0\n" + - "29748,-50,4,2,1,90,0,0\n" + - "30379,-50,4,2,1,75,0,0\n" + - "30853,-76.9230769230769,4,2,1,75,0,0\n" + - "31327,-76.9230769230769,4,2,1,70,0,0\n" + - "31642,-76.9230769230769,4,2,1,60,0,0\n" + - "31958,-76.9230769230769,4,2,0,5,0,0\n" + - "\n" + - "\n" + - "[Colours]\n" + - "Combo1 : 72,164,255\n" + - "Combo2 : 128,128,192\n" + - "\n" + - "[HitObjects]\n" + - "256,192,1011,5,8,0:2:0:0:\n" + - "351,323,1327,5,6,1:2:0:0:\n" + - "256,292,1642,1,2,0:2:0:0:\n" + - "256,192,1958,1,2,0:2:0:0:\n" + - "351,161,2274,1,2,0:2:0:0:\n" + - "409,242,2590,1,2,0:2:0:0:\n" + - "409,242,3537,5,2,1:2:0:0:\n" + - "103,142,3853,5,6,1:2:0:0:\n" + - "161,223,4169,1,2,0:2:0:0:\n" + - "256,192,4485,1,2,0:2:0:0:\n" + - "256,92,4800,1,2,0:2:0:0:\n" + - "161,61,5116,1,2,0:2:0:0:\n" + - "161,61,5748,6,0,P|172:112|182:171,1,105,0|2,1:2|0:2,0:0:0:0:\n" + - "355,87,6379,5,6,1:2:0:0:\n" + - "486,143,6695,1,2,0:2:0:0:\n" + - "470,288,7011,1,2,0:2:0:0:\n" + - "328,318,7327,1,2,0:2:0:0:\n" + - "256,192,7642,2,0,L|372:181,2,105,2|0|0,0:2|0:0|0:0,0:0:0:0:\n" + - "0,307,8590,6,0,P|32:281|134:282,1,126,2|0,1:2|0:0,0:0:0:0:\n" + - "148,192,8906,6,0,P|193:239|211:334,1,126,6|0,1:2|0:0,0:0:0:0:\n" + - "148,359,9221,1,2,0:2:0:0:\n" + - "148,359,9379,1,0,0:0:0:0:\n" + - "376,327,9537,2,0,L|268:307,1,84,2|0,0:2|0:0,0:0:0:0:\n" + - "507,196,9853,2,0,L|347:225,1,126,2|0,0:2|0:0,0:0:0:0:\n" + - "352,226,10169,6,0,L|369:125,1,84,2|0,0:2|0:0,0:0:0:0:\n" + - "147,190,10485,2,0,L|130:89,1,84\n" + - "467,28,10800,2,0,P|403:52|369:51,1,84\n" + - "101,24,11116,2,0,B|172:11|172:11|228:87,1,126,2|0,0:2|0:0,0:0:0:0:\n" + - "258,56,11432,6,0,L|241:233,1,157.500006008148,6|0,1:2|3:0,0:0:0:0:\n" + - "157,281,11669,1,0,3:0:0:0:\n" + - "157,281,11748,2,0,B|212:293|252:345|252:345|286:274|386:231,1,236.250009012223,2|0,1:2|0:0,0:0:0:0:\n" + - "510,290,12064,6,0,P|520:328|501:361,1,78.7500030040742,2|0,1:2|0:0,0:0:0:0:\n" + - "424,273,12221,2,0,P|405:306|415:344,1,78.7500030040742\n" + - "354,108,12379,6,0,P|363:71|344:38,1,78.7500030040742,2|0,1:2|0:0,0:0:0:0:\n" + - "266,125,12537,2,0,P|247:93|256:55,1,78.7500030040742\n" + - "75,301,12695,5,2,1:2:0:0:\n" + - "75,301,12853,6,0,L|188:272,1,84,2|0,0:2|0:0,0:0:0:0:\n" + - "266,125,13011,2,0,L|233:237,1,84,2|0,1:2|0:0,0:0:0:0:\n" + - "325,372,13169,2,0,L|244:287,1,84,2|0,0:2|0:0,0:0:0:0:\n" + - "13,160,13327,6,0,L|129:178,1,73.4999977569581,2|0,1:2|0:0,0:0:0:0:\n" + - "130,177,13485,2,0,L|111:13,2,146.999995513916,2|2|0,0:2|1:2|0:0,0:0:0:0:\n" + - "512,200,13958,6,0,B|523:186|511:164|511:164|425:159|396:269,1,157.500006008148,2|0,1:2|3:0,0:0:0:0:\n" + - "364,285,14195,1,0,3:0:0:0:\n" + - "364,285,14274,2,0,L|402:-8,1,252,2|0,1:2|0:0,0:0:0:0:\n" + - "486,13,14590,6,0,P|435:41|386:39,1,78.7500030040742,2|0,1:2|0:0,0:0:0:0:\n" + - "274,67,14748,2,0,P|218:49|185:13,1,78.7500030040742\n" + - "279,241,14906,6,0,P|226:215|200:173,1,78.7500030040742,2|0,1:2|0:0,0:0:0:0:\n" + - "129,176,15064,2,0,P|111:120|122:72,1,78.7500030040742\n" + - "19,264,15221,5,2,1:2:0:0:\n" + - "19,264,15379,6,0,P|44:265|17:225,1,89.2500017023087,2|0,0:2|0:0,0:0:0:0:\n" + - "7,29,15537,2,0,L|25:159,1,89.2500017023087,2|0,1:2|0:0,0:0:0:0:\n" + - "147,348,15695,2,0,L|165:218,1,89.2500017023087,2|0,0:2|0:0,0:0:0:0:\n" + - "274,67,15853,6,0,B|222:82|222:82|230:126,1,78.7500030040742,2|0,1:2|0:0,0:0:0:0:\n" + - "398,275,16011,2,0,B|450:260|450:260|420:142,2,157.500006008148,2|2|0,0:2|1:2|1:0,0:0:0:0:\n" + - "116,6,16485,6,0,P|140:101|155:182,1,168,2|0,1:2|3:0,0:0:0:0:\n" + - "159,259,16721,1,0,3:0:0:0:\n" + - "159,259,16800,2,0,B|221:218|324:240|262:280|365:302|428:262,1,252,2|0,1:2|0:0,0:0:0:0:\n" + - "492,230,17116,6,0,L|477:341,1,84,2|0,1:2|0:0,0:0:0:0:\n" + - "398,275,17274,2,0,L|386:358,1,84\n" + - "141,85,17432,6,0,L|153:169,1,84,2|0,1:2|0:0,0:0:0:0:\n" + - "235,131,17590,2,0,L|246:214,1,84\n" + - "488,46,17748,5,2,1:2:0:0:\n" + - "488,46,17906,6,0,B|457:85|457:85|392:68,1,94.499997116089,2|0,0:2|0:0,0:0:0:0:\n" + - "177,12,18064,2,0,B|221:5|221:5|252:44,1,94.499997116089,2|0,1:2|0:0,0:0:0:0:\n" + - "326,190,18221,2,0,L|310:323,1,94.499997116089,2|0,0:2|0:0,0:0:0:0:\n" + - "46,331,18379,5,2,1:2:0:0:\n" + - "46,331,18458,1,0,0:0:0:0:\n" + - "46,331,18537,2,0,L|234:309,2,168,2|2|0,0:2|1:2|0:0,0:0:0:0:\n" + - "504,224,19011,6,0,P|475:299|397:276,1,178.500003404617,2|0,1:2|3:0,0:0:0:0:\n" + - "326,190,19248,1,0,3:0:0:0:\n" + - "326,190,19327,2,0,L|517:206,1,178.500003404617,2|0,1:2|0:0,0:0:0:0:\n" + - "24,133,19642,2,0,L|316:108,1,267.750005106926,2|0,1:2|0:0,0:0:0:0:\n" + - "323,31,19958,6,0,P|302:50|293:121,1,78.7500030040742,2|0,1:2|0:0,0:0:0:0:\n" + - "402,155,20116,2,0,P|423:136|432:65,1,78.7500030040742\n" + - "189,16,20274,5,2,1:2:0:0:\n" + - "158,122,20432,1,2,0:2:0:0:\n" + - "189,228,20590,1,2,0:2:0:0:\n" + - "158,334,20748,6,0,B|205:303|283:319|236:350|314:367|361:336,1,188.999994232178,2|2,1:2|0:2,0:0:0:0:\n" + - "512,304,21221,2,0,P|476:235|438:282,1,141.749995674133,2|0,1:2|0:0,0:0:0:0:\n" + - "430,287,21537,6,0,L|405:120,1,168,6|0,1:2|3:0,0:0:0:0:\n" + - "393,22,21774,1,0,3:0:0:0:\n" + - "417,14,21853,2,0,B|454:30|459:55|459:55|372:22|247:16,1,252,2|0,1:2|0:0,0:0:0:0:\n" + - "213,89,22169,6,0,B|187:122|187:122|201:175,1,84,2|0,1:2|0:0,0:0:0:0:\n" + - "283,178,22327,2,0,B|308:144|308:144|294:91,1,84\n" + - "46,112,22485,2,0,L|257:82,1,168,2|0,1:2|0:0,0:0:0:0:\n" + - "498,44,22800,5,0,1:2:0:0:\n" + - "498,44,22958,6,0,P|476:67|512:58,1,89.2500017023087,2|0,0:2|0:0,0:0:0:0:\n" + - "283,178,23116,2,0,P|306:199|297:163,1,89.2500017023087,2|0,1:2|0:0,0:0:0:0:\n" + - "416,384,23274,2,0,P|437:360|401:369,1,89.2500017023087,2|0,0:2|0:0,0:0:0:0:\n" + - "462,216,23432,5,2,1:2:0:0:\n" + - "462,216,23511,1,0,0:0:0:0:\n" + - "462,216,23590,1,2,0:2:0:0:\n" + - "197,255,23748,2,0,L|484:212,1,267.750005106926,2|0,1:2|0:0,0:0:0:0:\n" + - "512,289,24064,6,0,B|428:308|428:308|367:221,1,168,2|0,1:2|3:0,0:0:0:0:\n" + - "282,155,24300,1,0,3:0:0:0:\n" + - "282,155,24379,2,0,B|458:118|458:118|443:214,1,252,2|0,1:2|0:0,0:0:0:0:\n" + - "307,323,24695,6,0,P|278:294|289:256,1,84,2|0,1:2|0:0,0:0:0:0:\n" + - "380,240,24853,2,0,P|408:268|397:306,1,84\n" + - "197,255,25011,2,0,L|4:221,1,168,2|0,1:2|0:0,0:0:0:0:\n" + - "298,61,25327,5,0,1:2:0:0:\n" + - "298,61,25485,6,0,L|183:81,1,94.499997116089,2|0,0:2|0:0,0:0:0:0:\n" + - "81,5,25642,2,0,L|101:120,1,94.499997116089,2|0,1:2|0:0,0:0:0:0:\n" + - "31,225,25800,2,0,L|146:205,1,94.499997116089,2|0,0:2|0:0,0:0:0:0:\n" + - "304,290,25958,6,0,L|397:306,1,94.499997116089,2|0,1:2|0:0,0:0:0:0:\n" + - "54,379,26116,2,0,L|241:358,2,188.999994232178,2|2|0,0:2|1:2|1:0,0:0:0:0:\n" + - "500,141,26590,6,0,B|419:170|419:170|360:113,1,168,2|0,1:2|3:0,0:0:0:0:\n" + - "441,58,26827,1,0,3:0:0:0:\n" + - "441,58,26906,2,0,L|390:341,1,252,2|0,1:2|0:0,0:0:0:0:\n" + - "352,332,27221,6,0,B|385:353|385:353|452:341,1,84,2|0,1:2|0:0,0:0:0:0:\n" + - "447,216,27379,2,0,B|414:195|414:195|347:207,1,84\n" + - "123,279,27537,2,0,L|314:261,1,168,2|0,1:2|0:0,0:0:0:0:\n" + - "0,291,27853,5,0,1:2:0:0:\n" + - "0,291,28011,6,0,L|20:387,1,84,2|0,0:2|0:0,0:0:0:0:\n" + - "75,15,28169,2,0,L|48:208,1,168,2|2,1:2|0:2,0:0:0:0:\n" + - "344,10,28485,1,2,1:2:0:0:\n" + - "344,10,28564,1,0,0:0:0:0:\n" + - "344,10,28642,1,2,0:2:0:0:\n" + - "491,316,28800,2,0,B|452:378|452:378|412:167,1,252,2|0,1:2|0:0,0:0:0:0:\n" + - "463,139,29116,6,0,B|415:168|415:168|351:149,1,115.50000352478,2|0,1:2|0:0,0:0:0:0:\n" + - "174,30,29274,1,0,3:0:0:0:\n" + - "164,110,29353,1,0,3:0:0:0:\n" + - "152,190,29432,2,0,B|227:204|227:204|206:72|206:72|34:107,1,346.500010574341,2|0,1:2|0:0,0:0:0:0:\n" + - "37,108,29748,6,0,B|15:164|15:164|44:232,1,105,2|0,1:2|0:0,0:0:0:0:\n" + - "163,347,29906,2,0,L|310:307,1,105\n" + - "477,6,30064,2,0,L|436:240,1,210,2|0,1:2|0:0,0:0:0:0:\n" + - "192,31,30379,5,2,1:2:0:0:\n" + - "337,202,30537,1,2,0:2:0:0:\n" + - "163,347,30695,1,2,0:2:0:0:\n" + - "441,213,30853,6,0,L|127:181,1,272.999987503052,2|2,1:2|0:2,0:0:0:0:\n" + - "11,19,31327,2,0,P|114:22|110:134,1,204.749990627289,2|0,1:2|0:0,0:0:0:0:\n" + - "85,178,31642,5,0,1:0:0:0:"