Work on replay viewer

This commit is contained in:
nise.moe 2024-03-02 23:58:42 +01:00
parent 7d0d3b676f
commit e9f7d29b46
11 changed files with 618 additions and 212 deletions

View File

@ -1,5 +1,6 @@
package com.nisemoe.nise
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<String>
)
data class ReplayData(

View File

@ -4,6 +4,7 @@ import com.nisemoe.generated.tables.records.ScoresJudgementsRecord
import com.nisemoe.generated.tables.records.ScoresRecord
import com.nisemoe.generated.tables.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)
)
}

View File

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

View File

@ -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)) == "<class 'slider.curve.Linear'>":
return 'Linear'
if str(type(slider.curve)) == "<class 'slider.curve.Perfect'>":
return 'Perfect'
elif str(type(slider.curve)) == "<class 'slider.curve.MultiBezier'>":
return 'MultiBezier'
elif str(type(slider.curve)) == "<class 'slider.curve.CatMull'>":
return 'CatMull'
combo_colors = {
1: "255,81,81", # Combo1
2: "255,128,64", # Combo2
3: "128,64,0", # Combo3
4: "212,212,212" # Combo4
}
current_combo = 1 # Initialize current combo counter
combo_counter = 0 # Keep track of how many combos have been counted
hit_objects = cg_beatmap.hit_objects(
easy=bool(beatmap_request.mods & Mod.Easy.value),
hard_rock=bool(beatmap_request.mods & Mod.HardRock.value),
half_time=bool(beatmap_request.mods & Mod.HalfTime.value),
double_time=bool(beatmap_request.mods & Mod.DoubleTime.value),
)
for obj in hit_objects:
if obj.new_combo:
combo_counter += 1 # Increment combo counter
if combo_counter > 4:
combo_counter = 1 # Reset combo counter after 4
current_combo = 1 # Reset current combo to 1 on new combo
else:
current_combo += 1 # Increment current combo for each hit object
# Assign combo color based on the combo_counter
combo_color = combo_colors[combo_counter]
if obj.type_code & Circle.type_code:
circles.append({
"x": obj.position.x,
"y": obj.position.y,
"time": obj.time.total_seconds() * 1000,
"new_combo": obj.new_combo,
"combo_color": combo_color,
"current_combo": current_combo
})
elif obj.type_code & Slider.type_code:
slider: Slider = obj
sliders.append({
"x": slider.position.x,
"y": slider.position.y,
"time": slider.time.total_seconds() * 1000,
"end_time": slider.end_time.total_seconds() * 1000,
"curve": {
'type': map_slider_curve_type(slider),
'points': [{'x': p.x, 'y': p.y} for p in slider.curve.points]
},
"length": slider.length,
"new_combo": slider.new_combo,
"combo_color": combo_color,
"current_combo": current_combo,
"repeat": slider.repeat,
})
elif obj.type_code & Spinner.type_code:
spinner: Spinner = obj
spinners.append({
"x": spinner.position.x,
"y": spinner.position.y,
"time": spinner.time.total_seconds() * 1000,
"end_time": spinner.end_time.total_seconds() * 1000,
"new_combo": spinner.new_combo,
"combo_color": combo_color,
"current_combo": current_combo
})
# Reset current_combo if new_combo is True
if obj.new_combo:
current_combo = 1
return jsonify(
{
"circles": circles,
"sliders": sliders,
"spinners": spinners,
"difficulty": {
"hp_drain_rate": cg_beatmap.hp(),
"circle_size": cg_beatmap.cs(),
"overral_difficulty": cg_beatmap.od(),
"approach_rate": cg_beatmap.ar(),
"slider_multiplier": cg_beatmap.slider_multiplier,
"slider_tick_rate": cg_beatmap.slider_tick_rate
},
"audio_lead_in": cg_beatmap.audio_lead_in.total_seconds() * 1000,
}
)
except ValueError as e:
abort(400, description=str(e))
@dataclass
class ReplayRequest:
replay_data: str

View File

@ -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 {

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

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

View File

@ -14,21 +14,30 @@ export class ReplayEventProcessed {
}
}
export function processReplay(events: ReplayEvent[]): ReplayEventProcessed[] {
export function processReplay(events: ReplayEvent[], mods: string[], audioLeadIn: number): ReplayEventProcessed[] {
console.log(events);
if (events.length === 0) throw new Error("This replay's replay data was empty. It indicates a misbehaved replay.");
if (events[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;

View File

@ -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<HTMLCanvasElement>) {
@ -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 && (arcStartAngle<arcEndAngle)) || (!isClockwise && (arcStartAngle>arcEndAngle))) {angleDt = 2*Math.PI - angleDt;}
var sliderBall = (isClockwise?-1:1)*(this.currentTime - (timing + duration * slidesDone)) * (angleDt / duration);
var sliderBallX; var sliderBallY;
if (slidesDone % 2 === 0){
sliderBallX = arcMidpoint[0] + arcRadius * Math.cos(arcStartAngle + sliderBall);
sliderBallY = arcMidpoint[1] + arcRadius * Math.sin(arcStartAngle + sliderBall);
} else {
sliderBallX = arcMidpoint[0] + arcRadius * Math.cos(arcEndAngle - sliderBall);
sliderBallY = arcMidpoint[1] + arcRadius * Math.sin(arcEndAngle - sliderBall);
}
this.ctx!.drawImage(this.sliderBall, sliderBallX - sliderBallsizeX / 2, sliderBallY - sliderBallsizeX / 2, sliderBallsizeX, sliderBallsizeX);
}
}
}
getTotalDuration() {
return this.totalDuration;
}

View File

@ -2,39 +2,42 @@
<canvas #replayCanvas width="600" height="400"></canvas>
</div>
<div class="text-center mb-2">
<button style="font-size: 20px" (click)="togglePlayPause()">{{ replayService.getIsPlaying() ? 'Pause' : 'Play' }}</button>
</div>
<div class="some-page-wrapper text-center">
<div class="row">
<div class="column">
<fieldset>
<label for="currentTime">Current Time: {{ replayService.currentTime | number: '1.0-0' }}</label>
<div>
<input id="currentTime" type="range" min="0" [max]="replayService.getTotalDuration()" [(ngModel)]="replayService.currentTime" (input)="seek(replayService.currentTime)">
</div>
</fieldset>
</div>
<div class="column">
<fieldset>
<label for="speedFactor">Speed Factor: {{ replayService.speedFactor }}</label>
<div>
<input type="range" min="0.1" max="2" step="0.1" id="speedFactor" [(ngModel)]="replayService.speedFactor">
</div>
</fieldset>
</div>
<ng-container *ngIf="replayService">
<div class="text-center mb-2">
<button style="font-size: 20px" (click)="togglePlayPause()">{{ replayService.getIsPlaying() ? 'Pause' : 'Play' }}</button>
</div>
</div>
<div class="some-page-wrapper text-center">
<div class="row">
<div class="column">
<fieldset>
<label for="currentTime">Current Time: {{ replayService.currentTime | number: '1.0-0' }}</label>
<div>
<input id="currentTime" type="range" min="0" [max]="replayService.getTotalDuration()" [(ngModel)]="replayService.currentTime" (input)="seek(replayService.currentTime)">
</div>
</fieldset>
</div>
<div class="column">
<fieldset>
<label for="speedFactor">Speed Factor: {{ replayService.speedFactor }}</label>
<div>
<input type="range" min="0.1" max="2" step="0.1" id="speedFactor" [(ngModel)]="replayService.speedFactor">
</div>
</fieldset>
</div>
</div>
</div>
</ng-container>

View File

@ -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<HTMLCanvasElement>;
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