Integrating new replay viewer
This commit is contained in:
parent
e9f7d29b46
commit
40854f2473
@ -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(
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 |
@ -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
|
||||
};
|
||||
}
|
||||
@ -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
|
||||
))
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@ -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%;
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
|
||||
|
||||
|
||||
|
||||
@ -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
Loading…
Reference in New Issue
Block a user