Integrating new replay viewer

This commit is contained in:
nise.moe 2024-03-03 15:28:26 +01:00
parent e9f7d29b46
commit 40854f2473
23 changed files with 44 additions and 1371 deletions

View File

@ -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<String>
val mods: Int
)
data class ReplayData(

View File

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

View File

@ -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<ReplayViewerData> {
val replay = this.scoreService.getReplayViewerData(replayId)

View File

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

View File

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

@ -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)) == "<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))
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)

View File

@ -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',

View File

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

View File

@ -201,9 +201,4 @@
class="chart">
</canvas>
</div>
<div class="main term mb-2" *ngIf="this.replayViewerData">
<h1># replay viewer <small>(experimental)</small></h1>
<app-replay-viewer [replayViewerData]="this.replayViewerData"></app-replay-viewer>
</div>
</ng-container>

View File

@ -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<ReplayViewerData>(`${environment.apiUrl}/score/${this.replayId}/replay`)
.subscribe({
next: (response) => {
this.replayViewerData = response;
}
});
}
private loadScoreData(): void {
this.isLoading = true;
this.http.get<ReplayData>(`${environment.apiUrl}/score/${this.replayId}`).pipe(

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

View File

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

View File

@ -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
))
);
}

View File

@ -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<HTMLCanvasElement> | 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<HTMLCanvasElement>) {
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 && (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;
}
getIsPlaying() {
return this.isPlaying;
}
}

View File

@ -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%;
}
}

View File

@ -1,43 +0,0 @@
<div class="text-center">
<canvas #replayCanvas width="600" height="400"></canvas>
</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 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,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<HTMLCanvasElement>;
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
}
}
}

File diff suppressed because one or more lines are too long