Integrating new replay viewer
This commit is contained in:
parent
e9f7d29b46
commit
40854f2473
@ -1,6 +1,5 @@
|
|||||||
package com.nisemoe.nise
|
package com.nisemoe.nise
|
||||||
|
|
||||||
import com.nisemoe.nise.integrations.CircleguardService
|
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
|
|
||||||
@ -96,9 +95,9 @@ data class ReplayDataSimilarScore(
|
|||||||
)
|
)
|
||||||
|
|
||||||
data class ReplayViewerData(
|
data class ReplayViewerData(
|
||||||
|
val beatmap: String,
|
||||||
val replay: String,
|
val replay: String,
|
||||||
val beatmap: CircleguardService.BeatmapResponse,
|
val mods: Int
|
||||||
val mods: List<String>
|
|
||||||
)
|
)
|
||||||
|
|
||||||
data class ReplayData(
|
data class ReplayData(
|
||||||
|
|||||||
@ -1,20 +1,38 @@
|
|||||||
package com.nisemoe.nise.config
|
package com.nisemoe.nise.config
|
||||||
|
|
||||||
import jakarta.servlet.*
|
import jakarta.servlet.*
|
||||||
|
import jakarta.servlet.http.HttpServletRequest
|
||||||
import jakarta.servlet.http.HttpServletResponse
|
import jakarta.servlet.http.HttpServletResponse
|
||||||
import org.springframework.beans.factory.annotation.Value
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.security.web.header.HeaderWriterFilter
|
||||||
|
|
||||||
class CustomCorsFilter : Filter {
|
class CustomCorsFilter : Filter {
|
||||||
|
|
||||||
@Value("\${ORIGIN:http://localhost:4200}")
|
@Value("\${ORIGIN:http://localhost:4200}")
|
||||||
private lateinit var origin: String
|
private lateinit var origin: String
|
||||||
|
|
||||||
|
@Value("\${REPLAY_ORIGIN:http://localhost:5173}")
|
||||||
|
private lateinit var replayOrigin: String
|
||||||
|
|
||||||
override fun init(filterConfig: FilterConfig) {
|
override fun init(filterConfig: FilterConfig) {
|
||||||
// We don't really need to do anything special here.
|
// We don't really need to do anything special here.
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun doFilter(servletRequest: ServletRequest, servletResponse: ServletResponse, filterChain: FilterChain) {
|
override fun doFilter(servletRequest: ServletRequest, servletResponse: ServletResponse, filterChain: FilterChain) {
|
||||||
val response = servletResponse as HttpServletResponse
|
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-Origin", origin)
|
||||||
response.setHeader("Access-Control-Allow-Methods", "GET,POST,DELETE,PUT,OPTIONS,PATCH")
|
response.setHeader("Access-Control-Allow-Methods", "GET,POST,DELETE,PUT,OPTIONS,PATCH")
|
||||||
response.setHeader(
|
response.setHeader(
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import com.nisemoe.nise.ReplayPair
|
|||||||
import com.nisemoe.nise.ReplayViewerData
|
import com.nisemoe.nise.ReplayViewerData
|
||||||
import com.nisemoe.nise.database.ScoreService
|
import com.nisemoe.nise.database.ScoreService
|
||||||
import org.springframework.http.ResponseEntity
|
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.GetMapping
|
||||||
import org.springframework.web.bind.annotation.PathVariable
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
import org.springframework.web.bind.annotation.RestController
|
import org.springframework.web.bind.annotation.RestController
|
||||||
@ -15,6 +16,7 @@ class ScoreController(
|
|||||||
private val scoreService: ScoreService
|
private val scoreService: ScoreService
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
@CrossOrigin(origins = ["http://wizardly_nash.local"])
|
||||||
@GetMapping("score/{replayId}/replay")
|
@GetMapping("score/{replayId}/replay")
|
||||||
fun getReplay(@PathVariable replayId: Long): ResponseEntity<ReplayViewerData> {
|
fun getReplay(@PathVariable replayId: Long): ResponseEntity<ReplayViewerData> {
|
||||||
val replay = this.scoreService.getReplayViewerData(replayId)
|
val replay = this.scoreService.getReplayViewerData(replayId)
|
||||||
|
|||||||
@ -55,27 +55,26 @@ class ScoreService(
|
|||||||
.where(SCORES.REPLAY_ID.eq(replayId))
|
.where(SCORES.REPLAY_ID.eq(replayId))
|
||||||
.fetchOne() ?: return null
|
.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 ->
|
val replay = decompressData(replayData)
|
||||||
LZMACompressorInputStream(byteStream).readBytes()
|
|
||||||
}
|
|
||||||
replayData = String(decompressedReplay, Charsets.UTF_8).trimEnd(',')
|
|
||||||
|
|
||||||
if(result.get(BEATMAPS.BEATMAP_FILE, String::class.java) == null) return null
|
if(result.get(BEATMAPS.BEATMAP_FILE, String::class.java) == null) return null
|
||||||
|
|
||||||
val mods = result.get(SCORES.MODS, Int::class.java)
|
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(
|
return ReplayViewerData(
|
||||||
replay = replayData,
|
beatmap = result.get(BEATMAPS.BEATMAP_FILE, String::class.java),
|
||||||
beatmap = beatmapData,
|
replay = String(replay, Charsets.UTF_8).trimEnd(','),
|
||||||
mods = Mod.parseModCombination(mods)
|
mods = mods
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun decompressData(replayString: String): ByteArray =
|
||||||
|
Base64.getDecoder().decode(replayString).inputStream().use { byteStream ->
|
||||||
|
LZMACompressorInputStream(byteStream).readBytes()
|
||||||
|
}
|
||||||
|
|
||||||
fun getReplayData(replayId: Long): ReplayData? {
|
fun getReplayData(replayId: Long): ReplayData? {
|
||||||
val result = dslContext.select(
|
val result = dslContext.select(
|
||||||
SCORES.ID,
|
SCORES.ID,
|
||||||
|
|||||||
@ -106,105 +106,6 @@ class CircleguardService {
|
|||||||
replayResponse.error_skewness = replayResponse.error_skewness?.times(conversionFactor)
|
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> {
|
fun processReplay(replayData: String, beatmapData: String, mods: Int = 0): CompletableFuture<ReplayResponse> {
|
||||||
val requestUri = "$apiUrl/replay"
|
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
|
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
|
def remove_bom_from_first_line(beatmap_file):
|
||||||
class BeatmapRequest:
|
lines = beatmap_file.splitlines()
|
||||||
beatmap_file: str
|
if lines: # Check if there are lines to avoid index errors
|
||||||
mods: int
|
# Remove BOM only from the first line
|
||||||
|
lines[0] = lines[0].replace('\ufeff', '')
|
||||||
@staticmethod
|
# Join the lines back together
|
||||||
def from_dict(data):
|
clean_content = '\n'.join(lines)
|
||||||
try:
|
return clean_content
|
||||||
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
|
@dataclass
|
||||||
@ -261,7 +138,8 @@ def process_replay():
|
|||||||
result_bytes1 = memory_stream1.getvalue()
|
result_bytes1 = memory_stream1.getvalue()
|
||||||
replay1 = ReplayString(result_bytes1)
|
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)
|
ur = cg.ur(replay=replay1, beatmap=cg_beatmap)
|
||||||
adjusted_ur = cg.ur(replay=replay1, beatmap=cg_beatmap, adjusted=True)
|
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 {UserService} from "../corelib/service/user.service";
|
||||||
import {NgIf} from '@angular/common';
|
import {NgIf} from '@angular/common';
|
||||||
import {FormsModule} from '@angular/forms';
|
import {FormsModule} from '@angular/forms';
|
||||||
import {ReplayViewerComponent} from "../corelib/components/replay-viewer/replay-viewer.component";
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
|
|||||||
@ -13,71 +13,6 @@ export interface ReplayDataSimilarScore {
|
|||||||
correlation: number;
|
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 {
|
export interface ReplayData {
|
||||||
replay_id: number;
|
replay_id: number;
|
||||||
user_id: number;
|
user_id: number;
|
||||||
|
|||||||
@ -201,9 +201,4 @@
|
|||||||
class="chart">
|
class="chart">
|
||||||
</canvas>
|
</canvas>
|
||||||
</div>
|
</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>
|
</ng-container>
|
||||||
|
|||||||
@ -6,12 +6,11 @@ import {environment} from "../../environments/environment";
|
|||||||
import {DecimalPipe, JsonPipe, NgForOf, NgIf, NgOptimizedImage} from "@angular/common";
|
import {DecimalPipe, JsonPipe, NgForOf, NgIf, NgOptimizedImage} from "@angular/common";
|
||||||
import {ActivatedRoute, RouterLink} from "@angular/router";
|
import {ActivatedRoute, RouterLink} from "@angular/router";
|
||||||
import {catchError, throwError} from "rxjs";
|
import {catchError, throwError} from "rxjs";
|
||||||
import {DistributionEntry, ReplayData, ReplayViewerData} from "../replays";
|
import {DistributionEntry, ReplayData} from "../replays";
|
||||||
import {calculateAccuracy} from "../format";
|
import {calculateAccuracy} from "../format";
|
||||||
import {Title} from "@angular/platform-browser";
|
import {Title} from "@angular/platform-browser";
|
||||||
import {OsuGradeComponent} from "../../corelib/components/osu-grade/osu-grade.component";
|
import {OsuGradeComponent} from "../../corelib/components/osu-grade/osu-grade.component";
|
||||||
import {ChartComponent} from "../../corelib/components/chart/chart.component";
|
import {ChartComponent} from "../../corelib/components/chart/chart.component";
|
||||||
import {ReplayViewerComponent} from "../../corelib/components/replay-viewer/replay-viewer.component";
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-view-score',
|
selector: 'app-view-score',
|
||||||
@ -25,8 +24,7 @@ import {ReplayViewerComponent} from "../../corelib/components/replay-viewer/repl
|
|||||||
NgOptimizedImage,
|
NgOptimizedImage,
|
||||||
RouterLink,
|
RouterLink,
|
||||||
OsuGradeComponent,
|
OsuGradeComponent,
|
||||||
ChartComponent,
|
ChartComponent
|
||||||
ReplayViewerComponent
|
|
||||||
],
|
],
|
||||||
templateUrl: './view-score.component.html',
|
templateUrl: './view-score.component.html',
|
||||||
styleUrl: './view-score.component.css'
|
styleUrl: './view-score.component.css'
|
||||||
@ -39,7 +37,6 @@ export class ViewScoreComponent implements OnInit {
|
|||||||
isLoading = false;
|
isLoading = false;
|
||||||
error: string | null = null;
|
error: string | null = null;
|
||||||
replayData: ReplayData | null = null;
|
replayData: ReplayData | null = null;
|
||||||
replayViewerData: ReplayViewerData | null = null;
|
|
||||||
replayId: number | null = null;
|
replayId: number | null = null;
|
||||||
|
|
||||||
public barChartLegend = true;
|
public barChartLegend = true;
|
||||||
@ -77,10 +74,6 @@ export class ViewScoreComponent implements OnInit {
|
|||||||
this.replayId = params['replayId'];
|
this.replayId = params['replayId'];
|
||||||
if (this.replayId) {
|
if (this.replayId) {
|
||||||
this.loadScoreData();
|
this.loadScoreData();
|
||||||
|
|
||||||
if(this.activatedRoute.snapshot.queryParams['viewer'] === 'true') {
|
|
||||||
this.loadReplayViewerData();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -99,15 +92,6 @@ export class ViewScoreComponent implements OnInit {
|
|||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
private loadReplayViewerData(): void {
|
|
||||||
this.http.get<ReplayViewerData>(`${environment.apiUrl}/score/${this.replayId}/replay`)
|
|
||||||
.subscribe({
|
|
||||||
next: (response) => {
|
|
||||||
this.replayViewerData = response;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private loadScoreData(): void {
|
private loadScoreData(): void {
|
||||||
this.isLoading = true;
|
this.isLoading = true;
|
||||||
this.http.get<ReplayData>(`${environment.apiUrl}/score/${this.replayId}`).pipe(
|
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