From 6bba41623eb6d95747b96d4f7596d8171c3308b4 Mon Sep 17 00:00:00 2001 From: "nise.moe" Date: Sat, 2 Mar 2024 17:51:54 +0100 Subject: [PATCH] Working on integrating replay system with backend, store beatmap file in backend (w/ migration from file system) --- .../com/nisemoe/generated/tables/Beatmaps.kt | 15 +- .../tables/records/BeatmapsRecord.kt | 30 ++- .../main/kotlin/com/nisemoe/nise/Models.kt | 5 + .../com/nisemoe/nise/config/WebConfig.kt | 15 ++ .../nise/controller/NiseApiInterceptor.kt | 23 +++ .../nise/controller/ScoreController.kt | 9 + .../com/nisemoe/nise/database/ScoreService.kt | 27 +++ .../nise/integrations/CircleguardService.kt | 8 +- .../kotlin/com/nisemoe/nise/osu/OsuApi.kt | 21 ++- .../nisemoe/nise/scheduler/FixMigration.kt | 53 ++++++ .../nisemoe/nise/scheduler/FixOldScores.kt | 22 ++- .../nisemoe/nise/scheduler/ImportScores.kt | 23 ++- .../migration/V0.0.1.026__alter_beatmaps.sql | 2 + nise-circleguard/src/main.py | 20 +- nise-frontend/package-lock.json | 6 - nise-frontend/package.json | 1 - nise-frontend/src/app/replays.ts | 5 + .../app/view-score/view-score.component.html | 10 +- .../app/view-score/view-score.component.ts | 16 +- .../assets/replay-viewer/approachcircle.png | Bin 0 -> 4504 bytes .../src/assets/replay-viewer/cursor.png | Bin 0 -> 22772 bytes .../src/assets/replay-viewer/hitcircle.png | Bin 0 -> 5560 bytes .../assets/replay-viewer/hitcircleoverlay.png | Bin 0 -> 14720 bytes .../src/assets/replay-viewer/reversearrow.png | Bin 0 -> 6211 bytes .../replay-viewer/decode-beatmap.ts | 70 ++++++- .../components/replay-viewer/decode-replay.ts | 45 +---- .../replay-viewer/process-replay.ts | 12 +- .../replay-viewer/replay-service.ts | 178 ++++++++++++------ .../replay-viewer/replay-viewer.component.css | 39 ++++ .../replay-viewer.component.html | 45 ++++- .../replay-viewer/replay-viewer.component.ts | 24 +-- 31 files changed, 566 insertions(+), 158 deletions(-) create mode 100644 nise-backend/src/main/kotlin/com/nisemoe/nise/config/WebConfig.kt create mode 100644 nise-backend/src/main/kotlin/com/nisemoe/nise/controller/NiseApiInterceptor.kt create mode 100644 nise-backend/src/main/kotlin/com/nisemoe/nise/scheduler/FixMigration.kt create mode 100644 nise-backend/src/main/resources/db/migration/V0.0.1.026__alter_beatmaps.sql create mode 100644 nise-frontend/src/assets/replay-viewer/approachcircle.png create mode 100644 nise-frontend/src/assets/replay-viewer/cursor.png create mode 100644 nise-frontend/src/assets/replay-viewer/hitcircle.png create mode 100644 nise-frontend/src/assets/replay-viewer/hitcircleoverlay.png create mode 100644 nise-frontend/src/assets/replay-viewer/reversearrow.png diff --git a/nise-backend/src/main/kotlin/com/nisemoe/generated/tables/Beatmaps.kt b/nise-backend/src/main/kotlin/com/nisemoe/generated/tables/Beatmaps.kt index 86679b8..f9c8062 100644 --- a/nise-backend/src/main/kotlin/com/nisemoe/generated/tables/Beatmaps.kt +++ b/nise-backend/src/main/kotlin/com/nisemoe/generated/tables/Beatmaps.kt @@ -16,7 +16,7 @@ import org.jooq.ForeignKey import org.jooq.Name import org.jooq.Record import org.jooq.Records -import org.jooq.Row10 +import org.jooq.Row11 import org.jooq.Schema import org.jooq.SelectField import org.jooq.Table @@ -112,6 +112,11 @@ open class Beatmaps( */ val LAST_REPLAY_CHECK: TableField = createField(DSL.name("last_replay_check"), SQLDataType.CLOB, this, "") + /** + * The column public.beatmaps.beatmap_file. + */ + val BEATMAP_FILE: TableField = createField(DSL.name("beatmap_file"), SQLDataType.CLOB, this, "") + private constructor(alias: Name, aliased: Table?): this(alias, null, null, aliased, null) private constructor(alias: Name, aliased: Table?, parameters: Array?>?): this(alias, null, null, aliased, parameters) @@ -153,18 +158,18 @@ open class Beatmaps( override fun rename(name: Table<*>): Beatmaps = Beatmaps(name.getQualifiedName(), null) // ------------------------------------------------------------------------- - // Row10 type methods + // Row11 type methods // ------------------------------------------------------------------------- - override fun fieldsRow(): Row10 = super.fieldsRow() as Row10 + override fun fieldsRow(): Row11 = super.fieldsRow() as Row11 /** * Convenience mapping calling {@link SelectField#convertFrom(Function)}. */ - fun mapping(from: (Int?, String?, Int?, String?, String?, Double?, String?, String?, OffsetDateTime?, String?) -> U): SelectField = convertFrom(Records.mapping(from)) + fun mapping(from: (Int?, String?, Int?, String?, String?, Double?, String?, String?, OffsetDateTime?, String?, String?) -> U): SelectField = convertFrom(Records.mapping(from)) /** * Convenience mapping calling {@link SelectField#convertFrom(Class, * Function)}. */ - fun mapping(toType: Class, from: (Int?, String?, Int?, String?, String?, Double?, String?, String?, OffsetDateTime?, String?) -> U): SelectField = convertFrom(toType, Records.mapping(from)) + fun mapping(toType: Class, from: (Int?, String?, Int?, String?, String?, Double?, String?, String?, OffsetDateTime?, String?, String?) -> U): SelectField = convertFrom(toType, Records.mapping(from)) } diff --git a/nise-backend/src/main/kotlin/com/nisemoe/generated/tables/records/BeatmapsRecord.kt b/nise-backend/src/main/kotlin/com/nisemoe/generated/tables/records/BeatmapsRecord.kt index cdca753..b482169 100644 --- a/nise-backend/src/main/kotlin/com/nisemoe/generated/tables/records/BeatmapsRecord.kt +++ b/nise-backend/src/main/kotlin/com/nisemoe/generated/tables/records/BeatmapsRecord.kt @@ -10,8 +10,8 @@ import java.time.OffsetDateTime import org.jooq.Field import org.jooq.Record1 -import org.jooq.Record10 -import org.jooq.Row10 +import org.jooq.Record11 +import org.jooq.Row11 import org.jooq.impl.UpdatableRecordImpl @@ -19,7 +19,7 @@ import org.jooq.impl.UpdatableRecordImpl * This class is generated by jOOQ. */ @Suppress("UNCHECKED_CAST") -open class BeatmapsRecord private constructor() : UpdatableRecordImpl(Beatmaps.BEATMAPS), Record10 { +open class BeatmapsRecord private constructor() : UpdatableRecordImpl(Beatmaps.BEATMAPS), Record11 { open var beatmapId: Int? set(value): Unit = set(0, value) @@ -61,6 +61,10 @@ open class BeatmapsRecord private constructor() : UpdatableRecordImpl = super.key() as Record1 // ------------------------------------------------------------------------- - // Record10 type implementation + // Record11 type implementation // ------------------------------------------------------------------------- - override fun fieldsRow(): Row10 = super.fieldsRow() as Row10 - override fun valuesRow(): Row10 = super.valuesRow() as Row10 + override fun fieldsRow(): Row11 = super.fieldsRow() as Row11 + override fun valuesRow(): Row11 = super.valuesRow() as Row11 override fun field1(): Field = Beatmaps.BEATMAPS.BEATMAP_ID override fun field2(): Field = Beatmaps.BEATMAPS.ARTIST override fun field3(): Field = Beatmaps.BEATMAPS.BEATMAPSET_ID @@ -83,6 +87,7 @@ open class BeatmapsRecord private constructor() : UpdatableRecordImpl = Beatmaps.BEATMAPS.VERSION override fun field9(): Field = Beatmaps.BEATMAPS.SYS_LAST_UPDATE override fun field10(): Field = Beatmaps.BEATMAPS.LAST_REPLAY_CHECK + override fun field11(): Field = Beatmaps.BEATMAPS.BEATMAP_FILE override fun component1(): Int? = beatmapId override fun component2(): String? = artist override fun component3(): Int? = beatmapsetId @@ -93,6 +98,7 @@ open class BeatmapsRecord private constructor() : UpdatableRecordImpl { + val replay = this.scoreService.getReplayViewerData(replayId) + ?: return ResponseEntity.notFound().build() + + return ResponseEntity.ok(replay) + } + @GetMapping("score/{replayId}") fun getScoreDetails(@PathVariable replayId: Long): ResponseEntity { val replayData = this.scoreService.getReplayData(replayId) diff --git a/nise-backend/src/main/kotlin/com/nisemoe/nise/database/ScoreService.kt b/nise-backend/src/main/kotlin/com/nisemoe/nise/database/ScoreService.kt index 6710845..2af2b26 100644 --- a/nise-backend/src/main/kotlin/com/nisemoe/nise/database/ScoreService.kt +++ b/nise-backend/src/main/kotlin/com/nisemoe/nise/database/ScoreService.kt @@ -7,6 +7,7 @@ import com.nisemoe.nise.* import com.nisemoe.nise.osu.Mod import com.nisemoe.nise.service.AuthService import com.nisemoe.nise.service.CompressJudgements +import org.apache.commons.compress.compressors.lzma.LZMACompressorInputStream import org.jooq.Condition import org.jooq.DSLContext import org.jooq.Record @@ -14,6 +15,7 @@ import org.jooq.impl.DSL import org.jooq.impl.DSL.avg import org.springframework.stereotype.Service import java.time.LocalDateTime +import java.util.* import kotlin.math.roundToInt @Service @@ -40,6 +42,31 @@ class ScoreService( .mapNotNull { (scoreType, title) -> db.get(scoreType)?.let { ReplayDataChart(title, it.filterNotNull()) } } } + fun getReplayViewerData(replayId: Long): ReplayViewerData? { + val result = dslContext.select( + SCORES.REPLAY, + BEATMAPS.BEATMAP_FILE + ) + .from(SCORES) + .join(BEATMAPS).on(SCORES.BEATMAP_ID.eq(BEATMAPS.BEATMAP_ID)) + .where(SCORES.REPLAY_ID.eq(replayId)) + .fetchOne() ?: return null + + var 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(',') + + if(result.get(BEATMAPS.BEATMAP_FILE, String::class.java) == null) return null + + return ReplayViewerData( + replay = replayData, + beatmap = result.get(BEATMAPS.BEATMAP_FILE, String::class.java) + ) + } + fun getReplayData(replayId: Long): ReplayData? { val result = dslContext.select( SCORES.ID, diff --git a/nise-backend/src/main/kotlin/com/nisemoe/nise/integrations/CircleguardService.kt b/nise-backend/src/main/kotlin/com/nisemoe/nise/integrations/CircleguardService.kt index 78fbd7b..4360ed5 100644 --- a/nise-backend/src/main/kotlin/com/nisemoe/nise/integrations/CircleguardService.kt +++ b/nise-backend/src/main/kotlin/com/nisemoe/nise/integrations/CircleguardService.kt @@ -28,8 +28,8 @@ class CircleguardService { @Serializable data class ReplayRequest( val replay_data: String, - val mods: Int, - val beatmap_id: Int + val beatmap_data: String, + val mods: Int ) @Serializable @@ -106,13 +106,13 @@ class CircleguardService { replayResponse.error_skewness = replayResponse.error_skewness?.times(conversionFactor) } - fun processReplay(replayData: String, beatmapId: Int, mods: Int = 0): CompletableFuture { + fun processReplay(replayData: String, beatmapData: String, mods: Int = 0): CompletableFuture { val requestUri = "$apiUrl/replay" val request = ReplayRequest( replay_data = replayData, + beatmap_data = beatmapData, mods = mods, - beatmap_id = beatmapId ) // Serialize the request object to JSON diff --git a/nise-backend/src/main/kotlin/com/nisemoe/nise/osu/OsuApi.kt b/nise-backend/src/main/kotlin/com/nisemoe/nise/osu/OsuApi.kt index 514284e..7896a3b 100644 --- a/nise-backend/src/main/kotlin/com/nisemoe/nise/osu/OsuApi.kt +++ b/nise-backend/src/main/kotlin/com/nisemoe/nise/osu/OsuApi.kt @@ -100,7 +100,26 @@ class OsuApi( } /** - * Retrieves the replay data for a given score ID from the Osu API. + * Retrieves the beatmap file for a given beatmap ID from the osu!api + * + * @param beatmapId The ID of the beatmap + */ + fun getBeatmapFile(beatmapId: Int): String? { + val response = this.doRequest("https://osu.ppy.sh/osu/${beatmapId}", emptyMap(), authorized = false) + if(response == null) { + this.logger.info("Error loading beatmap file") + return null + } + + return if (response.statusCode() == 200) { + response.body() + } else { + null + } + } + + /** + * Retrieves the replay data for a given score ID from the osu!api. * Efficiently cycles through the API keys to avoid rate limiting. * It's limited to 10 requests per minute according to @ https://github.com/ppy/osu-api/wiki#get-replay-data * diff --git a/nise-backend/src/main/kotlin/com/nisemoe/nise/scheduler/FixMigration.kt b/nise-backend/src/main/kotlin/com/nisemoe/nise/scheduler/FixMigration.kt new file mode 100644 index 0000000..4e3c63a --- /dev/null +++ b/nise-backend/src/main/kotlin/com/nisemoe/nise/scheduler/FixMigration.kt @@ -0,0 +1,53 @@ +package com.nisemoe.nise.scheduler + +import com.nisemoe.generated.tables.references.BEATMAPS +import org.jooq.DSLContext +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Profile +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Service +import java.io.File + +@Profile("fix:migrate") +@Service +class FixMigration( + private val dslContext: DSLContext +){ + + @Value("\${BEATMAPS_PATH}") + private lateinit var beatmapPath: String + + private val logger = LoggerFactory.getLogger(javaClass) + + @Scheduled(fixedDelay = 120000, initialDelay = 0) + fun fixStuff() { + val dir = File(beatmapPath) + + val regex = Regex("(.+) - (.+) \\((.+)\\)\\[(.+)\\]\\.osu") + + if (dir.exists() && dir.isDirectory) { + dir.listFiles()?.forEach { file -> + val matchResult = regex.matchEntire(file.name) + if (matchResult != null) { + val (artist, title, creator, version) = matchResult.destructured + val content = file.readText() + + val result = dslContext.update(BEATMAPS) + .set(BEATMAPS.BEATMAP_FILE, content) + .where(BEATMAPS.ARTIST.eq(artist)) + .and(BEATMAPS.TITLE.eq(title)) + .and(BEATMAPS.CREATOR.eq(creator)) + .and(BEATMAPS.VERSION.eq(version)) + .execute() + + this.logger.info("Artist: $artist, Title: $title, Creator: $creator, Version: $version") + this.logger.info("Affected rows: $result") + } + } + } else { + this.logger.error("Directory does not exist or is not a directory") + } + } + +} \ No newline at end of file diff --git a/nise-backend/src/main/kotlin/com/nisemoe/nise/scheduler/FixOldScores.kt b/nise-backend/src/main/kotlin/com/nisemoe/nise/scheduler/FixOldScores.kt index 782b83b..6512873 100644 --- a/nise-backend/src/main/kotlin/com/nisemoe/nise/scheduler/FixOldScores.kt +++ b/nise-backend/src/main/kotlin/com/nisemoe/nise/scheduler/FixOldScores.kt @@ -1,8 +1,10 @@ package com.nisemoe.nise.scheduler import com.nisemoe.generated.tables.records.ScoresRecord +import com.nisemoe.generated.tables.references.BEATMAPS import com.nisemoe.generated.tables.references.SCORES import com.nisemoe.nise.integrations.CircleguardService +import com.nisemoe.nise.osu.OsuApi import com.nisemoe.nise.service.CompressJudgements import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.Channel @@ -20,6 +22,7 @@ import org.springframework.stereotype.Service @Service class FixOldScores( private val dslContext: DSLContext, + private val osuApi: OsuApi, private val circleguardService: CircleguardService, private val compressJudgements: CompressJudgements ){ @@ -101,9 +104,26 @@ class FixOldScores( fun processScore(score: ScoresRecord) { + // Fetch the beatmap file from database + var beatmapFile = dslContext.select(BEATMAPS.BEATMAP_FILE) + .from(BEATMAPS) + .where(BEATMAPS.BEATMAP_ID.eq(score.beatmapId)) + .fetchOneInto(String::class.java) + + if(beatmapFile == null) { + this.logger.warn("Failed to fetch beatmap file for beatmap_id = ${score.beatmapId} from database") + + beatmapFile = this.osuApi.getBeatmapFile(beatmapId = score.beatmapId!!) + + if(beatmapFile == null) { + this.logger.error("Failed to fetch beatmap file for beatmap_id = ${score.beatmapId} from osu!api") + return + } + } + val processedReplay: CircleguardService.ReplayResponse? = try { this.circleguardService.processReplay( - replayData = score.replay!!.decodeToString(), beatmapId = score.beatmapId!!, mods = score.mods ?: 0 + replayData = score.replay!!.decodeToString(), beatmapData = beatmapFile, mods = score.mods ?: 0 ).get() } catch (e: Exception) { this.logger.error("Circleguard failed to process replay with score_id: ${score.id}") diff --git a/nise-backend/src/main/kotlin/com/nisemoe/nise/scheduler/ImportScores.kt b/nise-backend/src/main/kotlin/com/nisemoe/nise/scheduler/ImportScores.kt index 4cc40de..d6e0136 100644 --- a/nise-backend/src/main/kotlin/com/nisemoe/nise/scheduler/ImportScores.kt +++ b/nise-backend/src/main/kotlin/com/nisemoe/nise/scheduler/ImportScores.kt @@ -207,6 +207,7 @@ class ImportScores( for(topScore in allUserScores) { val beatmapExists = dslContext.fetchExists(BEATMAPS, BEATMAPS.BEATMAP_ID.eq(topScore.beatmap!!.id)) if (!beatmapExists) { + val beatmapFile = this.osuApi.getBeatmapFile(beatmapId = beatmap.id) dslContext.insertInto(BEATMAPS) .set(BEATMAPS.BEATMAP_ID, topScore.beatmap.id) .set(BEATMAPS.BEATMAPSET_ID, topScore.beatmapset!!.id) @@ -217,6 +218,7 @@ class ImportScores( .set(BEATMAPS.TITLE, topScore.beatmapset.title) .set(BEATMAPS.SOURCE, topScore.beatmapset.source) .set(BEATMAPS.CREATOR, topScore.beatmapset.creator) + .set(BEATMAPS.BEATMAP_FILE, beatmapFile) .execute() this.statistics.beatmapsAddedToDatabase++ } @@ -354,6 +356,7 @@ class ImportScores( } if (!beatmapExists) { + val beatmapFile = this.osuApi.getBeatmapFile(beatmapId = beatmap.id) dslContext.insertInto(BEATMAPS) .set(BEATMAPS.BEATMAP_ID, beatmap.id) .set(BEATMAPS.BEATMAPSET_ID, beatmapset.id.toInt()) @@ -364,6 +367,7 @@ class ImportScores( .set(BEATMAPS.TITLE, beatmapset.title) .set(BEATMAPS.SOURCE, beatmapset.source) .set(BEATMAPS.CREATOR, beatmapset.creator) + .set(BEATMAPS.BEATMAP_FILE, beatmapFile) .execute() this.statistics.beatmapsAddedToDatabase++ } @@ -611,10 +615,27 @@ class ImportScores( return } + // Fetch the beatmap file from database + var beatmapFile = dslContext.select(BEATMAPS.BEATMAP_FILE) + .from(BEATMAPS) + .where(BEATMAPS.BEATMAP_ID.eq(beatmapId)) + .fetchOneInto(String::class.java) + + if(beatmapFile == null) { + this.logger.warn("Failed to fetch beatmap file for beatmap_id = $beatmapId from database") + + beatmapFile = this.osuApi.getBeatmapFile(beatmapId = beatmapId) + + if(beatmapFile == null) { + this.logger.error("Failed to fetch beatmap file for beatmap_id = $beatmapId from osu!api") + return + } + } + // Calculate UR val processedReplay: CircleguardService.ReplayResponse? = try { this.circleguardService.processReplay( - replayData = scoreReplay.content, beatmapId = beatmapId, mods = Mod.combineModStrings(score.mods) + replayData = scoreReplay.content, beatmapData = beatmapFile, mods = Mod.combineModStrings(score.mods) ).get() } catch (e: Exception) { this.logger.error("Circleguard failed to process replay with score_id: ${score.id}") diff --git a/nise-backend/src/main/resources/db/migration/V0.0.1.026__alter_beatmaps.sql b/nise-backend/src/main/resources/db/migration/V0.0.1.026__alter_beatmaps.sql new file mode 100644 index 0000000..c3c87d4 --- /dev/null +++ b/nise-backend/src/main/resources/db/migration/V0.0.1.026__alter_beatmaps.sql @@ -0,0 +1,2 @@ +ALTER TABLE public.beatmaps + ADD COLUMN beatmap_file text; \ No newline at end of file diff --git a/nise-circleguard/src/main.py b/nise-circleguard/src/main.py index ffc8358..64493e7 100644 --- a/nise-circleguard/src/main.py +++ b/nise-circleguard/src/main.py @@ -12,6 +12,7 @@ from brparser import Replay, BeatmapOsu, Mod from circleguard import Circleguard, ReplayString, Hit from circleguard.utils import filter_outliers from flask import Flask, request, jsonify, abort +from slider import Beatmap from src.WriteStreamWrapper import WriteStreamWrapper from src.keypresses import get_kp_sliders @@ -45,16 +46,16 @@ def my_filter_outliers(arr, bias=1.5): @dataclass class ReplayRequest: replay_data: str + beatmap_data: str mods: int - beatmap_id: int @staticmethod def from_dict(data): try: return ReplayRequest( replay_data=data['replay_data'], - mods=data['mods'], - beatmap_id=int(data['beatmap_id']) + beatmap_data=data['beatmap_data'], + mods=data['mods'] ) except (ValueError, KeyError, TypeError) as e: raise ValueError(f"Invalid data format: {e}") @@ -128,7 +129,7 @@ def process_replay(): result_bytes1 = memory_stream1.getvalue() replay1 = ReplayString(result_bytes1) - cg_beatmap = cg.library.lookup_by_id(beatmap_id=replay_request.beatmap_id, download=True, save=True) + cg_beatmap = Beatmap.parse(replay_request.beatmap_data) ur = cg.ur(replay=replay1, beatmap=cg_beatmap) adjusted_ur = cg.ur(replay=replay1, beatmap=cg_beatmap, adjusted=True) @@ -144,14 +145,11 @@ def process_replay(): replay = Replay(decoded_data, pure_lzma=True) replay.mods = Mod(replay_request.mods) - filename = (f'{cg_beatmap.artist} - {cg_beatmap.title} ({cg_beatmap.creator})[{cg_beatmap.version}].osu' - .replace('/', '')) - beatmap_file = f'dbs/{filename}' - if not os.path.exists(beatmap_file): - print(f'Map not found @ {beatmap_file}', flush=True) - return 400, "Map not found" + beatmap = BeatmapOsu(None) + beatmap._process_headers(replay_request.beatmap_data.splitlines()) + beatmap._parse(replay_request.beatmap_data.splitlines()) + beatmap._sort_objects() - beatmap = BeatmapOsu(beatmap_file) kp, se = get_kp_sliders(replay, beatmap) hits: Iterable[Hit] = cg.hits(replay=replay1, beatmap=cg_beatmap) diff --git a/nise-frontend/package-lock.json b/nise-frontend/package-lock.json index 05f2eed..c9fb831 100644 --- a/nise-frontend/package-lock.json +++ b/nise-frontend/package-lock.json @@ -21,7 +21,6 @@ "chart.js": "^4.4.1", "date-fns": "^3.3.1", "lz-string": "^1.5.0", - "lzma-web": "^3.0.1", "ng2-charts": "^5.0.4", "rxjs": "~7.8.0", "tslib": "^2.3.0", @@ -8082,11 +8081,6 @@ "lz-string": "bin/bin.js" } }, - "node_modules/lzma-web": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/lzma-web/-/lzma-web-3.0.1.tgz", - "integrity": "sha512-sb5cdfd+PLNljK/HUgYzvnz4G7r0GFK8sonyGrqJS0FVyUQjFYcnmU2LqTWFi6r48lH1ZBstnxyLWepKM/t7QA==" - }, "node_modules/magic-string": { "version": "0.30.7", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.7.tgz", diff --git a/nise-frontend/package.json b/nise-frontend/package.json index 18c0f8c..b94142f 100644 --- a/nise-frontend/package.json +++ b/nise-frontend/package.json @@ -24,7 +24,6 @@ "chart.js": "^4.4.1", "date-fns": "^3.3.1", "lz-string": "^1.5.0", - "lzma-web": "^3.0.1", "ng2-charts": "^5.0.4", "rxjs": "~7.8.0", "tslib": "^2.3.0", diff --git a/nise-frontend/src/app/replays.ts b/nise-frontend/src/app/replays.ts index e9fdadc..b4913cd 100644 --- a/nise-frontend/src/app/replays.ts +++ b/nise-frontend/src/app/replays.ts @@ -13,6 +13,11 @@ export interface ReplayDataSimilarScore { correlation: number; } +export interface ReplayViewerData { + replay: string; + beatmap: string; +} + export interface ReplayData { replay_id: number; user_id: number; diff --git a/nise-frontend/src/app/view-score/view-score.component.html b/nise-frontend/src/app/view-score/view-score.component.html index f86b403..a9b6426 100644 --- a/nise-frontend/src/app/view-score/view-score.component.html +++ b/nise-frontend/src/app/view-score/view-score.component.html @@ -12,9 +12,6 @@ -
- -
@@ -193,7 +190,7 @@ -
+

# hit distribution

+ +
+

# replay viewer (experimental)

+ +
diff --git a/nise-frontend/src/app/view-score/view-score.component.ts b/nise-frontend/src/app/view-score/view-score.component.ts index fe2e21d..f9cabd6 100644 --- a/nise-frontend/src/app/view-score/view-score.component.ts +++ b/nise-frontend/src/app/view-score/view-score.component.ts @@ -6,7 +6,7 @@ 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} from "../replays"; +import {DistributionEntry, ReplayData, ReplayViewerData} from "../replays"; import {calculateAccuracy} from "../format"; import {Title} from "@angular/platform-browser"; import {OsuGradeComponent} from "../../corelib/components/osu-grade/osu-grade.component"; @@ -39,6 +39,7 @@ 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; @@ -76,6 +77,10 @@ export class ViewScoreComponent implements OnInit { this.replayId = params['replayId']; if (this.replayId) { this.loadScoreData(); + + if(this.activatedRoute.snapshot.queryParams['viewer'] === 'true') { + this.loadReplayViewerData(); + } } }); } @@ -94,6 +99,15 @@ export class ViewScoreComponent implements OnInit { return url; } + private loadReplayViewerData(): void { + this.http.get(`${environment.apiUrl}/score/${this.replayId}/replay`) + .subscribe({ + next: (response) => { + this.replayViewerData = response; + } + }); + } + private loadScoreData(): void { this.isLoading = true; this.http.get(`${environment.apiUrl}/score/${this.replayId}`).pipe( diff --git a/nise-frontend/src/assets/replay-viewer/approachcircle.png b/nise-frontend/src/assets/replay-viewer/approachcircle.png new file mode 100644 index 0000000000000000000000000000000000000000..56d6d34c1abf6ab153c9cef62eecff3016d21ed9 GIT binary patch literal 4504 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
8LRaKg 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 LNkvXXu0mjf2Xb$Z literal 0 HcmV?d00001 diff --git a/nise-frontend/src/assets/replay-viewer/hitcircle.png b/nise-frontend/src/assets/replay-viewer/hitcircle.png new file mode 100644 index 0000000000000000000000000000000000000000..8259a975087d2bfc9e7e8a338a8e659d72246aa7 GIT binary patch literal 5560 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�A~>+KNEX79zN32%>B&h-;P*4 z_d8vvzlrL+rXpCJl&b_tJAD@RggBnE@KN>bXc&YNi*dHhB@U@Z+@6^aD?IA!yUqVK zqVQ*{hdGg}csQJ+m_JRsc+FP1;31E`fk9hktV&S4^5Xqe>MIfHEibumt2>CjEfAAJwV`M|c>3IZQCzP2rw+Qd~(BDu(B5T<*{$ z4(fk(I{WNi$oV%(G?7rhScazh21``Rk?hNet)0YFBTHBxRp{`^BYE{Z40^#14(sX@ z0KuNbkLD;*6Js()^=(^wc9`xnqNhp?=oSBLChPG!mm9-g(d1&EN2|MC5xk7rI>Xm4 zQ%W)771MGQFPTJDHm!tQ>}M$u)U>u{+FDz;x#{JH-cmuAcB>QDRX}_IXAhOlE~zQX z7@sj%-6+`cYk-+K7+TZN&~QC%Zl}N&;nm7GFUnMp0+$Jby+3|O8I9cV6tfIv&h4vBT- zN{Wk$@yL~V#w8~V3=~ekZa1xlT(}#&FQkNg zZs%`nskZ3|lu+^lO&-hToXNwZfOU_nJFX-v1F|(cI~!WRzqa#J5{r}nkmu1n(BqHt|=gb3L3X*rM{ zr_e47eQy0weM&?s^ikW3{mwDh&eyhxI0zfNNyW5v!p7y24Hvo5Q}t)FA~f~$KPkpu zMiVRVg_-cB`VM+?#h^Y-g<*8qi&{2X%Z+{nEn>=y$N!S&x6pqR#7$fO)hrOvJTbAe za`{rwBW@DK-rC&K(z3j;5LJSQT8{IXrQV{bwfKzVmA}X?#)Lx^oHx34)OK+-*#O$w z&@-c_fmaUVOu^L3e7bpM_vpl}_70E#T*fbQgdDy68H|^o64~*hfsOrEMT135B2*gb z0gu3Hz!9tNo}PBA%Ri%QNFK6iv|H`PzIRq2N4_}YX|gTUqxBq`Y(|{e)>^dBFSJH6 zpr>n5WY`-cZPN(KV=hSqlcBgNSfz>WH)^YrFaZaRsZX==gtU1506*?LzB>(;5-U)7 z+OJ(~bZm4v2}LDuRv3-Qqc;#vezC~DNgIK*(awe}(+t6OL-6QL=bm2xtPhV}788^? zr-N$`ha-G}l4-^C$JL%JRkpvi4}1wopQ*;+ff0=duc(ykd5h0$jmk{gEEPQ-<>us& zDFF(5sDDGF1Yuoe;1KpJXV;=g7PzHmvf~ruO{~Jw0=XHg9rdz^aVtokd3JXdK|_@ zvPMirdzt?$oQ*Cvo*!*SSP!{js>S9pp+dVs)-)T8Ko7N~;qP&l(|Y;L~RhxUCM za4rq&b25RXfKBGe)mPERb;9UwL_-+c%#p&Y#RqwGAac=FpFV!uTIy39cp_kTAIQa5 zv;Vd_a0GXIncf+9{bwWzple8Q_y62wobjb6c=CDeAE?RX<*hXaT59@;DplmG{{hNV BVFmyI literal 0 HcmV?d00001 diff --git a/nise-frontend/src/assets/replay-viewer/hitcircleoverlay.png b/nise-frontend/src/assets/replay-viewer/hitcircleoverlay.png new file mode 100644 index 0000000000000000000000000000000000000000..0b47e9f6c5638af6fa0a93be783b2a22e29e6fca GIT binary patch literal 14720 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 h0Mw?C&ZhtX002ovPDHLkV1g&$no0lw literal 0 HcmV?d00001 diff --git a/nise-frontend/src/corelib/components/replay-viewer/decode-beatmap.ts b/nise-frontend/src/corelib/components/replay-viewer/decode-beatmap.ts index 750d0da..a1c881a 100644 --- a/nise-frontend/src/corelib/components/replay-viewer/decode-beatmap.ts +++ b/nise-frontend/src/corelib/components/replay-viewer/decode-beatmap.ts @@ -12,12 +12,66 @@ export type HitObject = { hitSound: number; objectParams: string; hitSample: string; + currentCombo: number; }; +export interface BeatmapDifficulty { + hpDrainRate: number; + circleSize: number; + overralDifficulty: number; + approachRate: number; + sliderMultiplier: number; + sliderTickRate: number; +} + +export function parseBeatmapDifficulty(beatmap: string): BeatmapDifficulty { + const lines = beatmap.split('\n'); + let recording = false; + const difficulty: Partial = {}; + + for (const line of lines) { + if (line.trim() === '[Difficulty]') { + recording = true; + continue; + } + + if (!recording) continue; + + if (line.startsWith('[')) break; + + const parts = line.split(':'); + if (parts.length < 2) continue; + + switch (parts[0]) { + case 'HPDrainRate': + difficulty.hpDrainRate = parseFloat(parts[1]); + break; + case 'CircleSize': + difficulty.circleSize = parseFloat(parts[1]); + break; + case 'OverallDifficulty': + difficulty.overralDifficulty = parseFloat(parts[1]); + break; + case 'ApproachRate': + difficulty.approachRate = parseFloat(parts[1]); + break; + case 'SliderMultiplier': + difficulty.sliderMultiplier = parseFloat(parts[1]); + break; + case 'SliderTickRate': + difficulty.sliderTickRate = parseFloat(parts[1]); + break; + } + } + + return difficulty as BeatmapDifficulty; +} + export function parseHitObjects(beatmap: string): any[] { const lines = beatmap.split('\n'); let recording = false; const hitObjects = []; + let currentCombo = 1; for (const line of lines) { if (line.trim() === '[HitObjects]') { @@ -33,6 +87,14 @@ export function parseHitObjects(beatmap: string): any[] { if (parts.length < 5) continue; const type = parseInt(parts[3], 10); + const isNewCombo = type & (1 << 2); // Bit at index 2 for new combo + if (isNewCombo) { + currentCombo = 1; // Reset combo + } else { + // If not the start of a new combo, increment the current combo + currentCombo++; + } + const hitObject = { x: parseInt(parts[0], 10), y: parseInt(parts[1], 10), @@ -40,9 +102,15 @@ export function parseHitObjects(beatmap: string): any[] { type: getTypeFromFlag(type), hitSound: parseInt(parts[4], 10), objectParams: parts[5], - hitSample: parts.length > 6 ? parts[6] : '0:0:0:0:' + hitSample: parts.length > 6 ? parts[6] : '0:0:0:0:', + currentCombo: currentCombo }; + if (isNewCombo) { + // Reset currentCombo after assigning to hitObject if it's the start of a new combo + currentCombo = 1; + } + hitObjects.push(hitObject); } diff --git a/nise-frontend/src/corelib/components/replay-viewer/decode-replay.ts b/nise-frontend/src/corelib/components/replay-viewer/decode-replay.ts index ce8136f..f93d6c1 100644 --- a/nise-frontend/src/corelib/components/replay-viewer/decode-replay.ts +++ b/nise-frontend/src/corelib/components/replay-viewer/decode-replay.ts @@ -1,10 +1,7 @@ import {KeyPress, ReplayEvent} from "./replay-viewer.component"; -import LZMA from 'lzma-web' -export async function getEvents(replayString: string): Promise { - const decompressedData = await decompressData(replayString); - const replayDataStr = new TextDecoder("utf-8").decode(decompressedData); - const trimmedReplayDataStr = replayDataStr.endsWith(',') ? replayDataStr.slice(0, -1) : replayDataStr; +export function getEvents(replayString: string): ReplayEvent[] { + const trimmedReplayDataStr = replayString.endsWith(',') ? replayString.slice(0, -1) : replayString; return processEvents(trimmedReplayDataStr); } @@ -19,7 +16,6 @@ function processEvents(replayDataStr: string): ReplayEvent[] { } 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]); @@ -32,39 +28,18 @@ function createReplayEvent(eventParts: string[], index: number, totalEvents: num return null; } - // Safely cast the integer value to the KeyPress enum - let keyPress = KeyPress[rawKey as unknown as keyof typeof KeyPress]; - - if (keyPress === undefined) { - // TODO: Fix - console.error("Unknown key press:", rawKey); - keyPress = KeyPress.Smoke; - } + 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, - key: keyPress + keys }; } - -async function decompressData(base64Data: string): Promise { - const lzma = new LZMA(); - - const binaryString = atob(base64Data); - const len = binaryString.length; - const bytes = new Uint8Array(len); - for (let i = 0; i < len; i++) { - bytes[i] = binaryString.charCodeAt(i); - } - - const result = await lzma.decompress(bytes); - - if (typeof result === 'string') { - return new TextEncoder().encode(result); - } else { - return result; - } -} - diff --git a/nise-frontend/src/corelib/components/replay-viewer/process-replay.ts b/nise-frontend/src/corelib/components/replay-viewer/process-replay.ts index d8a75f5..309dd15 100644 --- a/nise-frontend/src/corelib/components/replay-viewer/process-replay.ts +++ b/nise-frontend/src/corelib/components/replay-viewer/process-replay.ts @@ -3,14 +3,14 @@ import {KeyPress, ReplayEvent} from "./replay-viewer.component"; export class ReplayEventProcessed { x: number; y: number; - t: number; // Time - key: KeyPress; + t: number; + keys: KeyPress[]; - constructor(x: number, y: number, t: number, key: KeyPress) { + constructor(x: number, y: number, t: number, keys: KeyPress[]) { this.x = x; this.y = y; this.t = t; - this.key = key; + this.keys = keys; } } @@ -47,7 +47,7 @@ export function processReplay(events: ReplayEvent[]): ReplayEventProcessed[] { 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.key)); + pEvents.push(new ReplayEventProcessed(interpolatedX, interpolatedY, lastPositiveTime, lastPositiveFrame.keys)); } wasInNegativeSection = false; } @@ -55,7 +55,7 @@ export function processReplay(events: ReplayEvent[]): ReplayEventProcessed[] { wasInNegativeSection = isInNegativeSection; if (!isInNegativeSection) { - pEvents.push(new ReplayEventProcessed(currentFrame.x, currentFrame.y, cumulativeTimeDelta, currentFrame.key)); + pEvents.push(new ReplayEventProcessed(currentFrame.x, currentFrame.y, cumulativeTimeDelta, currentFrame.keys)); } if (!isInNegativeSection) { diff --git a/nise-frontend/src/corelib/components/replay-viewer/replay-service.ts b/nise-frontend/src/corelib/components/replay-viewer/replay-service.ts index 445b6ec..2ffa407 100644 --- a/nise-frontend/src/corelib/components/replay-viewer/replay-service.ts +++ b/nise-frontend/src/corelib/components/replay-viewer/replay-service.ts @@ -1,6 +1,8 @@ import { ElementRef, Injectable } from '@angular/core'; -import {HitObject, HitObjectType} from "./decode-beatmap"; -import {ReplayEventProcessed} from "./process-replay"; +import {BeatmapDifficulty, HitObject, HitObjectType, parseBeatmapDifficulty, parseHitObjects} from "./decode-beatmap"; +import {processReplay, ReplayEventProcessed} from "./process-replay"; +import {ReplayViewerData} from "../../../app/replays"; +import {getEvents} from "./decode-replay"; @Injectable({ providedIn: 'root', @@ -9,8 +11,11 @@ export class ReplayService { private hitObjects: HitObject[] = []; private replayEvents: ReplayEventProcessed[] = []; + private difficulty: BeatmapDifficulty | null = null; currentTime = 0; + speedFactor: number = 1; + private totalDuration = 0; private lastRenderTime = 0; @@ -20,7 +25,17 @@ export class ReplayService { private replayCanvas: ElementRef | null = null; private ctx: CanvasRenderingContext2D | null = null; - constructor() {} + private hitCircleImage = new Image(); + private hitCircleOverlay = new Image(); + private approachCircleImage = new Image(); + private cursorImage = new Image(); + + constructor() { + 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'; + } setCanvasElement(canvas: ElementRef) { this.replayCanvas = canvas; @@ -30,15 +45,13 @@ export class ReplayService { this.ctx = ctx; } - setHitObjects(hitObjects: HitObject[]) { - this.hitObjects = hitObjects; - console.log('Hit objects:', this.hitObjects); - } + loadReplay(replayViewerData: ReplayViewerData): void { + this.replayEvents = processReplay(getEvents(replayViewerData.replay)) - setEvents(events: ReplayEventProcessed[]) { - this.replayEvents = events; + this.hitObjects = parseHitObjects(replayViewerData.beatmap) this.calculateTotalDuration(); - console.log('Replay events:', this.replayEvents); + + this.difficulty = parseBeatmapDifficulty(replayViewerData.beatmap) } private calculateTotalDuration() { @@ -72,7 +85,6 @@ export class ReplayService { this.isPlaying = true; this.animate(); this.isPlaying = false; - console.log('Seeking to:', this.currentTime); } private animate(currentTimestamp: number = 0) { @@ -82,6 +94,11 @@ export class ReplayService { 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) @@ -92,7 +109,7 @@ export class ReplayService { } // Assuming elapsedTime is sufficient for 1 frame, update currentTime for real-time playback - this.currentTime += elapsedTime; + this.currentTime += elapsedTime * this.speedFactor; if (this.currentTime > this.totalDuration) { this.currentTime = this.totalDuration; @@ -100,9 +117,10 @@ export class ReplayService { return; } - this.drawCurrentEventPosition(); + this.ctx.clearRect(0, 0, this.replayCanvas.nativeElement.width, this.replayCanvas.nativeElement.height); this.drawHitCircles(); this.drawSliders(); + this.drawCursor(); this.lastRenderTime = currentTimestamp; @@ -122,10 +140,15 @@ export class ReplayService { return currentEvent; } - private drawCurrentEventPosition() { + private drawCursor() { const currentEvent = this.getCurrentReplayEvent(); if (currentEvent) { - this.updateCanvas(currentEvent.x, currentEvent.y); + 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); } } @@ -143,10 +166,93 @@ export class ReplayService { visibleHitCircles.forEach(hitCircle => { const opacity = this.calculateOpacity(hitCircle.time); - this.drawHitCircle(hitCircle.x, hitCircle.y, opacity); + this.drawHitCircle(hitCircle.x, hitCircle.y, opacity, hitCircle.currentCombo); + this.drawApproachCircle(hitCircle.x, hitCircle.y, 1, hitCircle.time) }); } + private drawApproachCircle(x: number, y: number, opacity: number, hitTime: number) { + let {fadeIn, totalDisplayTime} = this.calculatePreempt(); + let baseSize = 54.4 - 4.48 * this.difficulty!.circleSize; + + // 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.difficulty!.approachRate; + 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) { + this.ctx!.fillStyle = `rgba(255, 255, 255, ${opacity})`; // Set opacity for fade-in effect + let radius = 54.4 - 4.48 * this.difficulty!.circleSize; + + 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 drawSlider(x: number, y: number, opacity: number, combo: number) { + this.ctx!.fillStyle = `rgba(255, 255, 255, ${opacity})`; // Set opacity for fade-in effect + let radius = 54.4 - 4.48 * this.difficulty!.circleSize; + + 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'); @@ -161,47 +267,11 @@ export class ReplayService { visibleSliders.forEach(slider => { const opacity = this.calculateOpacity(slider.time); - this.drawSlider(slider, opacity); + this.drawSlider(slider.x, slider.y, opacity, slider.currentCombo); + this.drawApproachCircle(slider.x, slider.y, 1, slider.time) }); } - private drawSlider(slider: HitObject, opacity: number) { - this.ctx!.fillStyle = `rgba(255, 255, 255, ${opacity})`; // Set opacity for fade-in effect - this.ctx!.beginPath(); - this.ctx!.arc(slider.x, slider.y, 25, 0, 2 * Math.PI); // Assuming a radius of 5px - this.ctx!.fill(); - } - - private calculateOpacity(circleTime: number): number { - const timeDifference = circleTime - this.currentTime; - if (timeDifference < 0) { - return 0; // Circle time has passed - } - - // Calculate fade-in effect (0 to 1 over 200ms) - return Math.min(1, (200 - timeDifference) / 200); - } - - private drawHitCircle(x: number, y: number, opacity: number) { - this.ctx!.fillStyle = `rgba(255, 255, 255, ${opacity})`; // Set opacity for fade-in effect - this.ctx!.beginPath(); - this.ctx!.arc(x, y, 25, 0, 2 * Math.PI); // Assuming a radius of 50px - this.ctx!.fill(); - } - - private updateCanvas(x: number, y: number) { - if (!this.ctx || !this.replayCanvas) { - console.error('Canvas context not initialized'); - return; - } - - this.ctx.clearRect(0, 0, this.replayCanvas.nativeElement.width, this.replayCanvas.nativeElement.height); - this.ctx.fillStyle = '#FFFFFF'; - this.ctx.beginPath(); - this.ctx.arc(x, y, 5, 0, 2 * Math.PI); - this.ctx.fill(); - } - getTotalDuration() { return this.totalDuration; } diff --git a/nise-frontend/src/corelib/components/replay-viewer/replay-viewer.component.css b/nise-frontend/src/corelib/components/replay-viewer/replay-viewer.component.css index e69de29..3ad814f 100644 --- a/nise-frontend/src/corelib/components/replay-viewer/replay-viewer.component.css +++ b/nise-frontend/src/corelib/components/replay-viewer/replay-viewer.component.css @@ -0,0 +1,39 @@ +.row { + display: flex; + flex-direction: row; + flex-wrap: wrap; + width: 100%; +} + +.column { + display: flex; + flex-direction: column; + flex-basis: 100%; + flex: 1; +} + +ul { + list-style: none; +} + +/* Flex container */ +.flex-container { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 20px; /* Adjust the gap between items as needed */ +} + +/* Flex items - default to full width to stack on smaller screens */ +.flex-container > div { + flex: 0 0 100%; + box-sizing: border-box; /* To include padding and border in the element's total width and height */ +} + +/* Responsive columns */ +@media (min-width: 768px) { /* Adjust the breakpoint as needed */ + .flex-container > div { + flex: 0 0 40%; + max-width: 50%; + } +} diff --git a/nise-frontend/src/corelib/components/replay-viewer/replay-viewer.component.html b/nise-frontend/src/corelib/components/replay-viewer/replay-viewer.component.html index 5c3c08e..0b9f4a9 100644 --- a/nise-frontend/src/corelib/components/replay-viewer/replay-viewer.component.html +++ b/nise-frontend/src/corelib/components/replay-viewer/replay-viewer.component.html @@ -1,5 +1,40 @@ - -
Current Time: {{ replayService.currentTime | number: '1.0-0' }}
-
Total Duration: {{ replayService.getTotalDuration() | number: '1.0-0' }}
- - +
+ +
+ + +
+ +
+ +
+
+ +
+ +
+ +
+ +
+
+ +
+ +
+ +
+ +
+ +
+ +
+ + +
+ +
+
+ + diff --git a/nise-frontend/src/corelib/components/replay-viewer/replay-viewer.component.ts b/nise-frontend/src/corelib/components/replay-viewer/replay-viewer.component.ts index b006a2b..5a55c9d 100644 --- a/nise-frontend/src/corelib/components/replay-viewer/replay-viewer.component.ts +++ b/nise-frontend/src/corelib/components/replay-viewer/replay-viewer.component.ts @@ -1,11 +1,11 @@ -import {AfterViewInit, Component, ElementRef, OnInit, ViewChild} from '@angular/core'; +import {AfterViewInit, Component, ElementRef, Input, OnInit, ViewChild} from '@angular/core'; import {getEvents} from "./decode-replay"; -import {DecimalPipe, JsonPipe} from "@angular/common"; -import {beatmapData, replayData} from "./sample-replay"; +import {DecimalPipe, JsonPipe, NgForOf} from "@angular/common"; import {ReplayService} from "./replay-service"; import {FormsModule} from "@angular/forms"; import {parseHitObjects} from "./decode-beatmap"; import {processReplay} from "./process-replay"; +import {ReplayViewerData} from "../../../app/replays"; export enum KeyPress { M1 = 1, @@ -32,9 +32,9 @@ export interface ReplayEvent { y: number; /** - * Key being pressed. + * Keys being pressed. */ - key: KeyPress; + keys: KeyPress[]; } @@ -44,7 +44,8 @@ export interface ReplayEvent { imports: [ JsonPipe, FormsModule, - DecimalPipe + DecimalPipe, + NgForOf ], templateUrl: './replay-viewer.component.html', styleUrl: './replay-viewer.component.css' @@ -54,15 +55,14 @@ export class ReplayViewerComponent implements OnInit, AfterViewInit { @ViewChild('replayCanvas') replayCanvas!: ElementRef; private ctx!: CanvasRenderingContext2D; + @Input() replayViewerData!: ReplayViewerData; + // TODO: Calculate AudioLeadIn - // TODO: Calculate circle size (CS) // 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: Way to obtain replay+beatmap info from the backend - // TODO: UR bar // Todo: Customizable speed // TODO: Customizable zoom @@ -75,11 +75,7 @@ export class ReplayViewerComponent implements OnInit, AfterViewInit { constructor(public replayService: ReplayService) { } ngOnInit() { - // Assume getEvents() method returns a promise of ReplayEvent[] - getEvents(replayData).then(events => { - this.replayService.setEvents(processReplay(events)); - this.replayService.setHitObjects(parseHitObjects(beatmapData)) - }); + this.replayService.loadReplay(this.replayViewerData); } ngAfterViewInit() {