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 0000000..56d6d34 Binary files /dev/null and b/nise-frontend/src/assets/replay-viewer/approachcircle.png differ diff --git a/nise-frontend/src/assets/replay-viewer/cursor.png b/nise-frontend/src/assets/replay-viewer/cursor.png new file mode 100644 index 0000000..87d9831 Binary files /dev/null and b/nise-frontend/src/assets/replay-viewer/cursor.png differ 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 0000000..8259a97 Binary files /dev/null and b/nise-frontend/src/assets/replay-viewer/hitcircle.png differ 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 0000000..0b47e9f Binary files /dev/null and b/nise-frontend/src/assets/replay-viewer/hitcircleoverlay.png differ diff --git a/nise-frontend/src/assets/replay-viewer/reversearrow.png b/nise-frontend/src/assets/replay-viewer/reversearrow.png new file mode 100644 index 0000000..beaacf3 Binary files /dev/null and b/nise-frontend/src/assets/replay-viewer/reversearrow.png differ 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() {