Working on integrating replay system with backend, store beatmap file in backend (w/ migration from file system)

This commit is contained in:
nise.moe 2024-03-02 17:51:54 +01:00
parent 1b390969c0
commit 6bba41623e
31 changed files with 566 additions and 158 deletions

View File

@ -16,7 +16,7 @@ import org.jooq.ForeignKey
import org.jooq.Name import org.jooq.Name
import org.jooq.Record import org.jooq.Record
import org.jooq.Records import org.jooq.Records
import org.jooq.Row10 import org.jooq.Row11
import org.jooq.Schema import org.jooq.Schema
import org.jooq.SelectField import org.jooq.SelectField
import org.jooq.Table import org.jooq.Table
@ -112,6 +112,11 @@ open class Beatmaps(
*/ */
val LAST_REPLAY_CHECK: TableField<BeatmapsRecord, String?> = createField(DSL.name("last_replay_check"), SQLDataType.CLOB, this, "") val LAST_REPLAY_CHECK: TableField<BeatmapsRecord, String?> = createField(DSL.name("last_replay_check"), SQLDataType.CLOB, this, "")
/**
* The column <code>public.beatmaps.beatmap_file</code>.
*/
val BEATMAP_FILE: TableField<BeatmapsRecord, String?> = createField(DSL.name("beatmap_file"), SQLDataType.CLOB, this, "")
private constructor(alias: Name, aliased: Table<BeatmapsRecord>?): this(alias, null, null, aliased, null) private constructor(alias: Name, aliased: Table<BeatmapsRecord>?): this(alias, null, null, aliased, null)
private constructor(alias: Name, aliased: Table<BeatmapsRecord>?, parameters: Array<Field<*>?>?): this(alias, null, null, aliased, parameters) private constructor(alias: Name, aliased: Table<BeatmapsRecord>?, parameters: Array<Field<*>?>?): this(alias, null, null, aliased, parameters)
@ -153,18 +158,18 @@ open class Beatmaps(
override fun rename(name: Table<*>): Beatmaps = Beatmaps(name.getQualifiedName(), null) override fun rename(name: Table<*>): Beatmaps = Beatmaps(name.getQualifiedName(), null)
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Row10 type methods // Row11 type methods
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
override fun fieldsRow(): Row10<Int?, String?, Int?, String?, String?, Double?, String?, String?, OffsetDateTime?, String?> = super.fieldsRow() as Row10<Int?, String?, Int?, String?, String?, Double?, String?, String?, OffsetDateTime?, String?> override fun fieldsRow(): Row11<Int?, String?, Int?, String?, String?, Double?, String?, String?, OffsetDateTime?, String?, String?> = super.fieldsRow() as Row11<Int?, String?, Int?, String?, String?, Double?, String?, String?, OffsetDateTime?, String?, String?>
/** /**
* Convenience mapping calling {@link SelectField#convertFrom(Function)}. * Convenience mapping calling {@link SelectField#convertFrom(Function)}.
*/ */
fun <U> mapping(from: (Int?, String?, Int?, String?, String?, Double?, String?, String?, OffsetDateTime?, String?) -> U): SelectField<U> = convertFrom(Records.mapping(from)) fun <U> mapping(from: (Int?, String?, Int?, String?, String?, Double?, String?, String?, OffsetDateTime?, String?, String?) -> U): SelectField<U> = convertFrom(Records.mapping(from))
/** /**
* Convenience mapping calling {@link SelectField#convertFrom(Class, * Convenience mapping calling {@link SelectField#convertFrom(Class,
* Function)}. * Function)}.
*/ */
fun <U> mapping(toType: Class<U>, from: (Int?, String?, Int?, String?, String?, Double?, String?, String?, OffsetDateTime?, String?) -> U): SelectField<U> = convertFrom(toType, Records.mapping(from)) fun <U> mapping(toType: Class<U>, from: (Int?, String?, Int?, String?, String?, Double?, String?, String?, OffsetDateTime?, String?, String?) -> U): SelectField<U> = convertFrom(toType, Records.mapping(from))
} }

View File

@ -10,8 +10,8 @@ import java.time.OffsetDateTime
import org.jooq.Field import org.jooq.Field
import org.jooq.Record1 import org.jooq.Record1
import org.jooq.Record10 import org.jooq.Record11
import org.jooq.Row10 import org.jooq.Row11
import org.jooq.impl.UpdatableRecordImpl import org.jooq.impl.UpdatableRecordImpl
@ -19,7 +19,7 @@ import org.jooq.impl.UpdatableRecordImpl
* This class is generated by jOOQ. * This class is generated by jOOQ.
*/ */
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
open class BeatmapsRecord private constructor() : UpdatableRecordImpl<BeatmapsRecord>(Beatmaps.BEATMAPS), Record10<Int?, String?, Int?, String?, String?, Double?, String?, String?, OffsetDateTime?, String?> { open class BeatmapsRecord private constructor() : UpdatableRecordImpl<BeatmapsRecord>(Beatmaps.BEATMAPS), Record11<Int?, String?, Int?, String?, String?, Double?, String?, String?, OffsetDateTime?, String?, String?> {
open var beatmapId: Int? open var beatmapId: Int?
set(value): Unit = set(0, value) set(value): Unit = set(0, value)
@ -61,6 +61,10 @@ open class BeatmapsRecord private constructor() : UpdatableRecordImpl<BeatmapsRe
set(value): Unit = set(9, value) set(value): Unit = set(9, value)
get(): String? = get(9) as String? get(): String? = get(9) as String?
open var beatmapFile: String?
set(value): Unit = set(10, value)
get(): String? = get(10) as String?
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Primary key information // Primary key information
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@ -68,11 +72,11 @@ open class BeatmapsRecord private constructor() : UpdatableRecordImpl<BeatmapsRe
override fun key(): Record1<Int?> = super.key() as Record1<Int?> override fun key(): Record1<Int?> = super.key() as Record1<Int?>
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Record10 type implementation // Record11 type implementation
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
override fun fieldsRow(): Row10<Int?, String?, Int?, String?, String?, Double?, String?, String?, OffsetDateTime?, String?> = super.fieldsRow() as Row10<Int?, String?, Int?, String?, String?, Double?, String?, String?, OffsetDateTime?, String?> override fun fieldsRow(): Row11<Int?, String?, Int?, String?, String?, Double?, String?, String?, OffsetDateTime?, String?, String?> = super.fieldsRow() as Row11<Int?, String?, Int?, String?, String?, Double?, String?, String?, OffsetDateTime?, String?, String?>
override fun valuesRow(): Row10<Int?, String?, Int?, String?, String?, Double?, String?, String?, OffsetDateTime?, String?> = super.valuesRow() as Row10<Int?, String?, Int?, String?, String?, Double?, String?, String?, OffsetDateTime?, String?> override fun valuesRow(): Row11<Int?, String?, Int?, String?, String?, Double?, String?, String?, OffsetDateTime?, String?, String?> = super.valuesRow() as Row11<Int?, String?, Int?, String?, String?, Double?, String?, String?, OffsetDateTime?, String?, String?>
override fun field1(): Field<Int?> = Beatmaps.BEATMAPS.BEATMAP_ID override fun field1(): Field<Int?> = Beatmaps.BEATMAPS.BEATMAP_ID
override fun field2(): Field<String?> = Beatmaps.BEATMAPS.ARTIST override fun field2(): Field<String?> = Beatmaps.BEATMAPS.ARTIST
override fun field3(): Field<Int?> = Beatmaps.BEATMAPS.BEATMAPSET_ID override fun field3(): Field<Int?> = Beatmaps.BEATMAPS.BEATMAPSET_ID
@ -83,6 +87,7 @@ open class BeatmapsRecord private constructor() : UpdatableRecordImpl<BeatmapsRe
override fun field8(): Field<String?> = Beatmaps.BEATMAPS.VERSION override fun field8(): Field<String?> = Beatmaps.BEATMAPS.VERSION
override fun field9(): Field<OffsetDateTime?> = Beatmaps.BEATMAPS.SYS_LAST_UPDATE override fun field9(): Field<OffsetDateTime?> = Beatmaps.BEATMAPS.SYS_LAST_UPDATE
override fun field10(): Field<String?> = Beatmaps.BEATMAPS.LAST_REPLAY_CHECK override fun field10(): Field<String?> = Beatmaps.BEATMAPS.LAST_REPLAY_CHECK
override fun field11(): Field<String?> = Beatmaps.BEATMAPS.BEATMAP_FILE
override fun component1(): Int? = beatmapId override fun component1(): Int? = beatmapId
override fun component2(): String? = artist override fun component2(): String? = artist
override fun component3(): Int? = beatmapsetId override fun component3(): Int? = beatmapsetId
@ -93,6 +98,7 @@ open class BeatmapsRecord private constructor() : UpdatableRecordImpl<BeatmapsRe
override fun component8(): String? = version override fun component8(): String? = version
override fun component9(): OffsetDateTime? = sysLastUpdate override fun component9(): OffsetDateTime? = sysLastUpdate
override fun component10(): String? = lastReplayCheck override fun component10(): String? = lastReplayCheck
override fun component11(): String? = beatmapFile
override fun value1(): Int? = beatmapId override fun value1(): Int? = beatmapId
override fun value2(): String? = artist override fun value2(): String? = artist
override fun value3(): Int? = beatmapsetId override fun value3(): Int? = beatmapsetId
@ -103,6 +109,7 @@ open class BeatmapsRecord private constructor() : UpdatableRecordImpl<BeatmapsRe
override fun value8(): String? = version override fun value8(): String? = version
override fun value9(): OffsetDateTime? = sysLastUpdate override fun value9(): OffsetDateTime? = sysLastUpdate
override fun value10(): String? = lastReplayCheck override fun value10(): String? = lastReplayCheck
override fun value11(): String? = beatmapFile
override fun value1(value: Int?): BeatmapsRecord { override fun value1(value: Int?): BeatmapsRecord {
set(0, value) set(0, value)
@ -154,7 +161,12 @@ open class BeatmapsRecord private constructor() : UpdatableRecordImpl<BeatmapsRe
return this return this
} }
override fun values(value1: Int?, value2: String?, value3: Int?, value4: String?, value5: String?, value6: Double?, value7: String?, value8: String?, value9: OffsetDateTime?, value10: String?): BeatmapsRecord { override fun value11(value: String?): BeatmapsRecord {
set(10, value)
return this
}
override fun values(value1: Int?, value2: String?, value3: Int?, value4: String?, value5: String?, value6: Double?, value7: String?, value8: String?, value9: OffsetDateTime?, value10: String?, value11: String?): BeatmapsRecord {
this.value1(value1) this.value1(value1)
this.value2(value2) this.value2(value2)
this.value3(value3) this.value3(value3)
@ -165,13 +177,14 @@ open class BeatmapsRecord private constructor() : UpdatableRecordImpl<BeatmapsRe
this.value8(value8) this.value8(value8)
this.value9(value9) this.value9(value9)
this.value10(value10) this.value10(value10)
this.value11(value11)
return this return this
} }
/** /**
* Create a detached, initialised BeatmapsRecord * Create a detached, initialised BeatmapsRecord
*/ */
constructor(beatmapId: Int? = null, artist: String? = null, beatmapsetId: Int? = null, creator: String? = null, source: String? = null, starRating: Double? = null, title: String? = null, version: String? = null, sysLastUpdate: OffsetDateTime? = null, lastReplayCheck: String? = null): this() { constructor(beatmapId: Int? = null, artist: String? = null, beatmapsetId: Int? = null, creator: String? = null, source: String? = null, starRating: Double? = null, title: String? = null, version: String? = null, sysLastUpdate: OffsetDateTime? = null, lastReplayCheck: String? = null, beatmapFile: String? = null): this() {
this.beatmapId = beatmapId this.beatmapId = beatmapId
this.artist = artist this.artist = artist
this.beatmapsetId = beatmapsetId this.beatmapsetId = beatmapsetId
@ -182,6 +195,7 @@ open class BeatmapsRecord private constructor() : UpdatableRecordImpl<BeatmapsRe
this.version = version this.version = version
this.sysLastUpdate = sysLastUpdate this.sysLastUpdate = sysLastUpdate
this.lastReplayCheck = lastReplayCheck this.lastReplayCheck = lastReplayCheck
this.beatmapFile = beatmapFile
resetChangedOnNotNull() resetChangedOnNotNull()
} }
} }

View File

@ -94,6 +94,11 @@ data class ReplayDataSimilarScore(
val correlation: Double val correlation: Double
) )
data class ReplayViewerData(
val replay: String,
val beatmap: String,
)
data class ReplayData( data class ReplayData(
val replay_id: Long, val replay_id: Long,
val user_id: Int, val user_id: Int,

View File

@ -0,0 +1,15 @@
package com.nisemoe.nise.config
import com.nisemoe.nise.controller.NiseApiInterceptor
import org.springframework.context.annotation.Configuration
import org.springframework.web.servlet.config.annotation.InterceptorRegistry
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
@Configuration
class WebConfig : WebMvcConfigurer {
override fun addInterceptors(registry: InterceptorRegistry) {
registry.addInterceptor(NiseApiInterceptor())
}
}

View File

@ -0,0 +1,23 @@
package com.nisemoe.nise.controller
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.stereotype.Component
import org.springframework.web.servlet.HandlerInterceptor
@Component
class NiseApiInterceptor : HandlerInterceptor {
override fun preHandle(request: HttpServletRequest, response: HttpServletResponse, handler: Any): Boolean {
request.getHeader("X-NISE-API")?.let {
if(it.toInt() < 20240218) {
response.sendError(403, "Forbidden")
return false
}
}
return true
}
}

View File

@ -3,6 +3,7 @@ package com.nisemoe.nise.controller
import com.nisemoe.nise.Format import com.nisemoe.nise.Format
import com.nisemoe.nise.ReplayData import com.nisemoe.nise.ReplayData
import com.nisemoe.nise.ReplayPair import com.nisemoe.nise.ReplayPair
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.GetMapping import org.springframework.web.bind.annotation.GetMapping
@ -14,6 +15,14 @@ class ScoreController(
private val scoreService: ScoreService private val scoreService: ScoreService
) { ) {
@GetMapping("score/{replayId}/replay")
fun getReplay(@PathVariable replayId: Long): ResponseEntity<ReplayViewerData> {
val replay = this.scoreService.getReplayViewerData(replayId)
?: return ResponseEntity.notFound().build()
return ResponseEntity.ok(replay)
}
@GetMapping("score/{replayId}") @GetMapping("score/{replayId}")
fun getScoreDetails(@PathVariable replayId: Long): ResponseEntity<ReplayData> { fun getScoreDetails(@PathVariable replayId: Long): ResponseEntity<ReplayData> {
val replayData = this.scoreService.getReplayData(replayId) val replayData = this.scoreService.getReplayData(replayId)

View File

@ -7,6 +7,7 @@ import com.nisemoe.nise.*
import com.nisemoe.nise.osu.Mod import com.nisemoe.nise.osu.Mod
import com.nisemoe.nise.service.AuthService import com.nisemoe.nise.service.AuthService
import com.nisemoe.nise.service.CompressJudgements import com.nisemoe.nise.service.CompressJudgements
import org.apache.commons.compress.compressors.lzma.LZMACompressorInputStream
import org.jooq.Condition import org.jooq.Condition
import org.jooq.DSLContext import org.jooq.DSLContext
import org.jooq.Record import org.jooq.Record
@ -14,6 +15,7 @@ import org.jooq.impl.DSL
import org.jooq.impl.DSL.avg import org.jooq.impl.DSL.avg
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import java.time.LocalDateTime import java.time.LocalDateTime
import java.util.*
import kotlin.math.roundToInt import kotlin.math.roundToInt
@Service @Service
@ -40,6 +42,31 @@ class ScoreService(
.mapNotNull { (scoreType, title) -> db.get(scoreType)?.let { ReplayDataChart(title, it.filterNotNull()) } } .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? { fun getReplayData(replayId: Long): ReplayData? {
val result = dslContext.select( val result = dslContext.select(
SCORES.ID, SCORES.ID,

View File

@ -28,8 +28,8 @@ class CircleguardService {
@Serializable @Serializable
data class ReplayRequest( data class ReplayRequest(
val replay_data: String, val replay_data: String,
val mods: Int, val beatmap_data: String,
val beatmap_id: Int val mods: Int
) )
@Serializable @Serializable
@ -106,13 +106,13 @@ class CircleguardService {
replayResponse.error_skewness = replayResponse.error_skewness?.times(conversionFactor) replayResponse.error_skewness = replayResponse.error_skewness?.times(conversionFactor)
} }
fun processReplay(replayData: String, beatmapId: Int, mods: Int = 0): CompletableFuture<ReplayResponse> { fun processReplay(replayData: String, beatmapData: String, mods: Int = 0): CompletableFuture<ReplayResponse> {
val requestUri = "$apiUrl/replay" val requestUri = "$apiUrl/replay"
val request = ReplayRequest( val request = ReplayRequest(
replay_data = replayData, replay_data = replayData,
beatmap_data = beatmapData,
mods = mods, mods = mods,
beatmap_id = beatmapId
) )
// Serialize the request object to JSON // Serialize the request object to JSON

View File

@ -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. * 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 * It's limited to 10 requests per minute according to @ https://github.com/ppy/osu-api/wiki#get-replay-data
* *

View File

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

View File

@ -1,8 +1,10 @@
package com.nisemoe.nise.scheduler package com.nisemoe.nise.scheduler
import com.nisemoe.generated.tables.records.ScoresRecord import com.nisemoe.generated.tables.records.ScoresRecord
import com.nisemoe.generated.tables.references.BEATMAPS
import com.nisemoe.generated.tables.references.SCORES import com.nisemoe.generated.tables.references.SCORES
import com.nisemoe.nise.integrations.CircleguardService import com.nisemoe.nise.integrations.CircleguardService
import com.nisemoe.nise.osu.OsuApi
import com.nisemoe.nise.service.CompressJudgements import com.nisemoe.nise.service.CompressJudgements
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
@ -20,6 +22,7 @@ import org.springframework.stereotype.Service
@Service @Service
class FixOldScores( class FixOldScores(
private val dslContext: DSLContext, private val dslContext: DSLContext,
private val osuApi: OsuApi,
private val circleguardService: CircleguardService, private val circleguardService: CircleguardService,
private val compressJudgements: CompressJudgements private val compressJudgements: CompressJudgements
){ ){
@ -101,9 +104,26 @@ class FixOldScores(
fun processScore(score: ScoresRecord) { 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 { val processedReplay: CircleguardService.ReplayResponse? = try {
this.circleguardService.processReplay( 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() ).get()
} catch (e: Exception) { } catch (e: Exception) {
this.logger.error("Circleguard failed to process replay with score_id: ${score.id}") this.logger.error("Circleguard failed to process replay with score_id: ${score.id}")

View File

@ -207,6 +207,7 @@ class ImportScores(
for(topScore in allUserScores) { for(topScore in allUserScores) {
val beatmapExists = dslContext.fetchExists(BEATMAPS, BEATMAPS.BEATMAP_ID.eq(topScore.beatmap!!.id)) val beatmapExists = dslContext.fetchExists(BEATMAPS, BEATMAPS.BEATMAP_ID.eq(topScore.beatmap!!.id))
if (!beatmapExists) { if (!beatmapExists) {
val beatmapFile = this.osuApi.getBeatmapFile(beatmapId = beatmap.id)
dslContext.insertInto(BEATMAPS) dslContext.insertInto(BEATMAPS)
.set(BEATMAPS.BEATMAP_ID, topScore.beatmap.id) .set(BEATMAPS.BEATMAP_ID, topScore.beatmap.id)
.set(BEATMAPS.BEATMAPSET_ID, topScore.beatmapset!!.id) .set(BEATMAPS.BEATMAPSET_ID, topScore.beatmapset!!.id)
@ -217,6 +218,7 @@ class ImportScores(
.set(BEATMAPS.TITLE, topScore.beatmapset.title) .set(BEATMAPS.TITLE, topScore.beatmapset.title)
.set(BEATMAPS.SOURCE, topScore.beatmapset.source) .set(BEATMAPS.SOURCE, topScore.beatmapset.source)
.set(BEATMAPS.CREATOR, topScore.beatmapset.creator) .set(BEATMAPS.CREATOR, topScore.beatmapset.creator)
.set(BEATMAPS.BEATMAP_FILE, beatmapFile)
.execute() .execute()
this.statistics.beatmapsAddedToDatabase++ this.statistics.beatmapsAddedToDatabase++
} }
@ -354,6 +356,7 @@ class ImportScores(
} }
if (!beatmapExists) { if (!beatmapExists) {
val beatmapFile = this.osuApi.getBeatmapFile(beatmapId = beatmap.id)
dslContext.insertInto(BEATMAPS) dslContext.insertInto(BEATMAPS)
.set(BEATMAPS.BEATMAP_ID, beatmap.id) .set(BEATMAPS.BEATMAP_ID, beatmap.id)
.set(BEATMAPS.BEATMAPSET_ID, beatmapset.id.toInt()) .set(BEATMAPS.BEATMAPSET_ID, beatmapset.id.toInt())
@ -364,6 +367,7 @@ class ImportScores(
.set(BEATMAPS.TITLE, beatmapset.title) .set(BEATMAPS.TITLE, beatmapset.title)
.set(BEATMAPS.SOURCE, beatmapset.source) .set(BEATMAPS.SOURCE, beatmapset.source)
.set(BEATMAPS.CREATOR, beatmapset.creator) .set(BEATMAPS.CREATOR, beatmapset.creator)
.set(BEATMAPS.BEATMAP_FILE, beatmapFile)
.execute() .execute()
this.statistics.beatmapsAddedToDatabase++ this.statistics.beatmapsAddedToDatabase++
} }
@ -611,10 +615,27 @@ class ImportScores(
return 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 // Calculate UR
val processedReplay: CircleguardService.ReplayResponse? = try { val processedReplay: CircleguardService.ReplayResponse? = try {
this.circleguardService.processReplay( this.circleguardService.processReplay(
replayData = scoreReplay.content, beatmapId = beatmapId, mods = Mod.combineModStrings(score.mods) replayData = scoreReplay.content, beatmapData = beatmapFile, mods = Mod.combineModStrings(score.mods)
).get() ).get()
} catch (e: Exception) { } catch (e: Exception) {
this.logger.error("Circleguard failed to process replay with score_id: ${score.id}") this.logger.error("Circleguard failed to process replay with score_id: ${score.id}")

View File

@ -0,0 +1,2 @@
ALTER TABLE public.beatmaps
ADD COLUMN beatmap_file text;

View File

@ -12,6 +12,7 @@ from brparser import Replay, BeatmapOsu, Mod
from circleguard import Circleguard, ReplayString, Hit from circleguard import Circleguard, ReplayString, Hit
from circleguard.utils import filter_outliers from circleguard.utils import filter_outliers
from flask import Flask, request, jsonify, abort from flask import Flask, request, jsonify, abort
from slider import Beatmap
from src.WriteStreamWrapper import WriteStreamWrapper from src.WriteStreamWrapper import WriteStreamWrapper
from src.keypresses import get_kp_sliders from src.keypresses import get_kp_sliders
@ -45,16 +46,16 @@ def my_filter_outliers(arr, bias=1.5):
@dataclass @dataclass
class ReplayRequest: class ReplayRequest:
replay_data: str replay_data: str
beatmap_data: str
mods: int mods: int
beatmap_id: int
@staticmethod @staticmethod
def from_dict(data): def from_dict(data):
try: try:
return ReplayRequest( return ReplayRequest(
replay_data=data['replay_data'], replay_data=data['replay_data'],
mods=data['mods'], beatmap_data=data['beatmap_data'],
beatmap_id=int(data['beatmap_id']) mods=data['mods']
) )
except (ValueError, KeyError, TypeError) as e: except (ValueError, KeyError, TypeError) as e:
raise ValueError(f"Invalid data format: {e}") raise ValueError(f"Invalid data format: {e}")
@ -128,7 +129,7 @@ def process_replay():
result_bytes1 = memory_stream1.getvalue() result_bytes1 = memory_stream1.getvalue()
replay1 = ReplayString(result_bytes1) 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) 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)
@ -144,14 +145,11 @@ def process_replay():
replay = Replay(decoded_data, pure_lzma=True) replay = Replay(decoded_data, pure_lzma=True)
replay.mods = Mod(replay_request.mods) replay.mods = Mod(replay_request.mods)
filename = (f'{cg_beatmap.artist} - {cg_beatmap.title} ({cg_beatmap.creator})[{cg_beatmap.version}].osu' beatmap = BeatmapOsu(None)
.replace('/', '')) beatmap._process_headers(replay_request.beatmap_data.splitlines())
beatmap_file = f'dbs/{filename}' beatmap._parse(replay_request.beatmap_data.splitlines())
if not os.path.exists(beatmap_file): beatmap._sort_objects()
print(f'Map not found @ {beatmap_file}', flush=True)
return 400, "Map not found"
beatmap = BeatmapOsu(beatmap_file)
kp, se = get_kp_sliders(replay, beatmap) kp, se = get_kp_sliders(replay, beatmap)
hits: Iterable[Hit] = cg.hits(replay=replay1, beatmap=cg_beatmap) hits: Iterable[Hit] = cg.hits(replay=replay1, beatmap=cg_beatmap)

View File

@ -21,7 +21,6 @@
"chart.js": "^4.4.1", "chart.js": "^4.4.1",
"date-fns": "^3.3.1", "date-fns": "^3.3.1",
"lz-string": "^1.5.0", "lz-string": "^1.5.0",
"lzma-web": "^3.0.1",
"ng2-charts": "^5.0.4", "ng2-charts": "^5.0.4",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
"tslib": "^2.3.0", "tslib": "^2.3.0",
@ -8082,11 +8081,6 @@
"lz-string": "bin/bin.js" "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": { "node_modules/magic-string": {
"version": "0.30.7", "version": "0.30.7",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.7.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.7.tgz",

View File

@ -24,7 +24,6 @@
"chart.js": "^4.4.1", "chart.js": "^4.4.1",
"date-fns": "^3.3.1", "date-fns": "^3.3.1",
"lz-string": "^1.5.0", "lz-string": "^1.5.0",
"lzma-web": "^3.0.1",
"ng2-charts": "^5.0.4", "ng2-charts": "^5.0.4",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
"tslib": "^2.3.0", "tslib": "^2.3.0",

View File

@ -13,6 +13,11 @@ export interface ReplayDataSimilarScore {
correlation: number; correlation: number;
} }
export interface ReplayViewerData {
replay: string;
beatmap: string;
}
export interface ReplayData { export interface ReplayData {
replay_id: number; replay_id: number;
user_id: number; user_id: number;

View File

@ -12,9 +12,6 @@
</div> </div>
</div> </div>
</ng-container> </ng-container>
<div class="main term mb-2">
<app-replay-viewer></app-replay-viewer>
</div>
<ng-container *ngIf="this.replayData && !this.isLoading && !this.error"> <ng-container *ngIf="this.replayData && !this.isLoading && !this.error">
<div class="main term mb-2"> <div class="main term mb-2">
<div class="fade-stuff"> <div class="fade-stuff">
@ -193,7 +190,7 @@
<app-chart [title]="chart.title" [data]="chart.data"></app-chart> <app-chart [title]="chart.title" [data]="chart.data"></app-chart>
</ng-container> </ng-container>
<div class="main term" *ngIf="this.replayData.error_distribution && Object.keys(this.replayData.error_distribution).length > 0"> <div class="main term mb-2" *ngIf="this.replayData.error_distribution && Object.keys(this.replayData.error_distribution).length > 0">
<h1># hit distribution</h1> <h1># hit distribution</h1>
<canvas baseChart <canvas baseChart
[data]="barChartData" [data]="barChartData"
@ -204,4 +201,9 @@
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>

View File

@ -6,7 +6,7 @@ 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} from "../replays"; import {DistributionEntry, ReplayData, ReplayViewerData} 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";
@ -39,6 +39,7 @@ 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;
@ -76,6 +77,10 @@ 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();
}
} }
}); });
} }
@ -94,6 +99,15 @@ 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.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

View File

@ -12,12 +12,66 @@ export type HitObject = {
hitSound: number; hitSound: number;
objectParams: string; objectParams: string;
hitSample: 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<BeatmapDifficulty> = {};
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[] { export function parseHitObjects(beatmap: string): any[] {
const lines = beatmap.split('\n'); const lines = beatmap.split('\n');
let recording = false; let recording = false;
const hitObjects = []; const hitObjects = [];
let currentCombo = 1;
for (const line of lines) { for (const line of lines) {
if (line.trim() === '[HitObjects]') { if (line.trim() === '[HitObjects]') {
@ -33,6 +87,14 @@ export function parseHitObjects(beatmap: string): any[] {
if (parts.length < 5) continue; if (parts.length < 5) continue;
const type = parseInt(parts[3], 10); 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 = { const hitObject = {
x: parseInt(parts[0], 10), x: parseInt(parts[0], 10),
y: parseInt(parts[1], 10), y: parseInt(parts[1], 10),
@ -40,9 +102,15 @@ export function parseHitObjects(beatmap: string): any[] {
type: getTypeFromFlag(type), type: getTypeFromFlag(type),
hitSound: parseInt(parts[4], 10), hitSound: parseInt(parts[4], 10),
objectParams: parts[5], 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); hitObjects.push(hitObject);
} }

View File

@ -1,10 +1,7 @@
import {KeyPress, ReplayEvent} from "./replay-viewer.component"; import {KeyPress, ReplayEvent} from "./replay-viewer.component";
import LZMA from 'lzma-web'
export async function getEvents(replayString: string): Promise<ReplayEvent[]> { export function getEvents(replayString: string): ReplayEvent[] {
const decompressedData = await decompressData(replayString); const trimmedReplayDataStr = replayString.endsWith(',') ? replayString.slice(0, -1) : replayString;
const replayDataStr = new TextDecoder("utf-8").decode(decompressedData);
const trimmedReplayDataStr = replayDataStr.endsWith(',') ? replayDataStr.slice(0, -1) : replayDataStr;
return processEvents(trimmedReplayDataStr); return processEvents(trimmedReplayDataStr);
} }
@ -19,7 +16,6 @@ function processEvents(replayDataStr: string): ReplayEvent[] {
} }
function createReplayEvent(eventParts: string[], index: number, totalEvents: number): ReplayEvent | null { function createReplayEvent(eventParts: string[], index: number, totalEvents: number): ReplayEvent | null {
const timeDelta = parseInt(eventParts[0], 10); const timeDelta = parseInt(eventParts[0], 10);
const x = parseFloat(eventParts[1]); const x = parseFloat(eventParts[1]);
const y = parseFloat(eventParts[2]); const y = parseFloat(eventParts[2]);
@ -32,39 +28,18 @@ function createReplayEvent(eventParts: string[], index: number, totalEvents: num
return null; return null;
} }
// Safely cast the integer value to the KeyPress enum let keys: KeyPress[] = [];
let keyPress = KeyPress[rawKey as unknown as keyof typeof KeyPress]; Object.keys(KeyPress).forEach(key => {
const keyPress = KeyPress[key as keyof typeof KeyPress];
if (keyPress === undefined) { if ((rawKey & keyPress) === keyPress) {
// TODO: Fix keys.push(keyPress);
console.error("Unknown key press:", rawKey); }
keyPress = KeyPress.Smoke; });
}
return { return {
timeDelta, timeDelta,
x, x,
y, y,
key: keyPress keys
}; };
} }
async function decompressData(base64Data: string): Promise<Uint8Array> {
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;
}
}

View File

@ -3,14 +3,14 @@ import {KeyPress, ReplayEvent} from "./replay-viewer.component";
export class ReplayEventProcessed { export class ReplayEventProcessed {
x: number; x: number;
y: number; y: number;
t: number; // Time t: number;
key: KeyPress; keys: KeyPress[];
constructor(x: number, y: number, t: number, key: KeyPress) { constructor(x: number, y: number, t: number, keys: KeyPress[]) {
this.x = x; this.x = x;
this.y = y; this.y = y;
this.t = t; 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 interpolatedX = lastPositiveFrame.x + ratio * (currentFrame.x - lastPositiveFrame.x);
const interpolatedY = lastPositiveFrame.y + ratio * (currentFrame.y - lastPositiveFrame.y); 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; wasInNegativeSection = false;
} }
@ -55,7 +55,7 @@ export function processReplay(events: ReplayEvent[]): ReplayEventProcessed[] {
wasInNegativeSection = isInNegativeSection; wasInNegativeSection = isInNegativeSection;
if (!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) { if (!isInNegativeSection) {

View File

@ -1,6 +1,8 @@
import { ElementRef, Injectable } from '@angular/core'; import { ElementRef, Injectable } from '@angular/core';
import {HitObject, HitObjectType} from "./decode-beatmap"; import {BeatmapDifficulty, HitObject, HitObjectType, parseBeatmapDifficulty, parseHitObjects} from "./decode-beatmap";
import {ReplayEventProcessed} from "./process-replay"; import {processReplay, ReplayEventProcessed} from "./process-replay";
import {ReplayViewerData} from "../../../app/replays";
import {getEvents} from "./decode-replay";
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
@ -9,8 +11,11 @@ export class ReplayService {
private hitObjects: HitObject[] = []; private hitObjects: HitObject[] = [];
private replayEvents: ReplayEventProcessed[] = []; private replayEvents: ReplayEventProcessed[] = [];
private difficulty: BeatmapDifficulty | null = null;
currentTime = 0; currentTime = 0;
speedFactor: number = 1;
private totalDuration = 0; private totalDuration = 0;
private lastRenderTime = 0; private lastRenderTime = 0;
@ -20,7 +25,17 @@ export class ReplayService {
private replayCanvas: ElementRef<HTMLCanvasElement> | null = null; private replayCanvas: ElementRef<HTMLCanvasElement> | null = null;
private ctx: CanvasRenderingContext2D | 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<HTMLCanvasElement>) { setCanvasElement(canvas: ElementRef<HTMLCanvasElement>) {
this.replayCanvas = canvas; this.replayCanvas = canvas;
@ -30,15 +45,13 @@ export class ReplayService {
this.ctx = ctx; this.ctx = ctx;
} }
setHitObjects(hitObjects: HitObject[]) { loadReplay(replayViewerData: ReplayViewerData): void {
this.hitObjects = hitObjects; this.replayEvents = processReplay(getEvents(replayViewerData.replay))
console.log('Hit objects:', this.hitObjects);
}
setEvents(events: ReplayEventProcessed[]) { this.hitObjects = parseHitObjects(replayViewerData.beatmap)
this.replayEvents = events;
this.calculateTotalDuration(); this.calculateTotalDuration();
console.log('Replay events:', this.replayEvents);
this.difficulty = parseBeatmapDifficulty(replayViewerData.beatmap)
} }
private calculateTotalDuration() { private calculateTotalDuration() {
@ -72,7 +85,6 @@ export class ReplayService {
this.isPlaying = true; this.isPlaying = true;
this.animate(); this.animate();
this.isPlaying = false; this.isPlaying = false;
console.log('Seeking to:', this.currentTime);
} }
private animate(currentTimestamp: number = 0) { private animate(currentTimestamp: number = 0) {
@ -82,6 +94,11 @@ export class ReplayService {
this.lastRenderTime = currentTimestamp; this.lastRenderTime = currentTimestamp;
} }
if(!this.ctx || !this.replayCanvas) {
console.error('Canvas context not initialized');
return;
}
const elapsedTime = currentTimestamp - this.lastRenderTime; const elapsedTime = currentTimestamp - this.lastRenderTime;
// Check if enough time has passed for the next frame (approximately 16.67ms for 60 FPS) // 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 // 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) { if (this.currentTime > this.totalDuration) {
this.currentTime = this.totalDuration; this.currentTime = this.totalDuration;
@ -100,9 +117,10 @@ export class ReplayService {
return; return;
} }
this.drawCurrentEventPosition(); this.ctx.clearRect(0, 0, this.replayCanvas.nativeElement.width, this.replayCanvas.nativeElement.height);
this.drawHitCircles(); this.drawHitCircles();
this.drawSliders(); this.drawSliders();
this.drawCursor();
this.lastRenderTime = currentTimestamp; this.lastRenderTime = currentTimestamp;
@ -122,10 +140,15 @@ export class ReplayService {
return currentEvent; return currentEvent;
} }
private drawCurrentEventPosition() { private drawCursor() {
const currentEvent = this.getCurrentReplayEvent(); const currentEvent = this.getCurrentReplayEvent();
if (currentEvent) { 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 => { visibleHitCircles.forEach(hitCircle => {
const opacity = this.calculateOpacity(hitCircle.time); 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() { private drawSliders() {
if (!this.ctx || !this.replayCanvas) { if (!this.ctx || !this.replayCanvas) {
console.error('Canvas context not initialized'); console.error('Canvas context not initialized');
@ -161,47 +267,11 @@ export class ReplayService {
visibleSliders.forEach(slider => { visibleSliders.forEach(slider => {
const opacity = this.calculateOpacity(slider.time); 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() { getTotalDuration() {
return this.totalDuration; return this.totalDuration;
} }

View File

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

View File

@ -1,5 +1,40 @@
<canvas #replayCanvas width="600" height="400"></canvas> <div class="text-center">
<div>Current Time: {{ replayService.currentTime | number: '1.0-0' }}</div> <canvas #replayCanvas width="600" height="400"></canvas>
<div>Total Duration: {{ replayService.getTotalDuration() | number: '1.0-0' }}</div> </div>
<button (click)="togglePlayPause()">{{ replayService.getIsPlaying() ? 'Pause' : 'Play' }}</button>
<input type="range" min="0" [max]="replayService.getTotalDuration()" [(ngModel)]="replayService.currentTime" (input)="seek(replayService.currentTime)">
<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>

View File

@ -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 {getEvents} from "./decode-replay";
import {DecimalPipe, JsonPipe} from "@angular/common"; import {DecimalPipe, JsonPipe, NgForOf} from "@angular/common";
import {beatmapData, replayData} from "./sample-replay";
import {ReplayService} from "./replay-service"; import {ReplayService} from "./replay-service";
import {FormsModule} from "@angular/forms"; import {FormsModule} from "@angular/forms";
import {parseHitObjects} from "./decode-beatmap"; import {parseHitObjects} from "./decode-beatmap";
import {processReplay} from "./process-replay"; import {processReplay} from "./process-replay";
import {ReplayViewerData} from "../../../app/replays";
export enum KeyPress { export enum KeyPress {
M1 = 1, M1 = 1,
@ -32,9 +32,9 @@ export interface ReplayEvent {
y: number; y: number;
/** /**
* Key being pressed. * Keys being pressed.
*/ */
key: KeyPress; keys: KeyPress[];
} }
@ -44,7 +44,8 @@ export interface ReplayEvent {
imports: [ imports: [
JsonPipe, JsonPipe,
FormsModule, FormsModule,
DecimalPipe DecimalPipe,
NgForOf
], ],
templateUrl: './replay-viewer.component.html', templateUrl: './replay-viewer.component.html',
styleUrl: './replay-viewer.component.css' styleUrl: './replay-viewer.component.css'
@ -54,15 +55,14 @@ export class ReplayViewerComponent implements OnInit, AfterViewInit {
@ViewChild('replayCanvas') replayCanvas!: ElementRef<HTMLCanvasElement>; @ViewChild('replayCanvas') replayCanvas!: ElementRef<HTMLCanvasElement>;
private ctx!: CanvasRenderingContext2D; private ctx!: CanvasRenderingContext2D;
@Input() replayViewerData!: ReplayViewerData;
// TODO: Calculate AudioLeadIn // TODO: Calculate AudioLeadIn
// TODO: Calculate circle size (CS)
// TODO: Hard-Rock, DT, Easy // TODO: Hard-Rock, DT, Easy
// TODO: Cursor trail and where keys are pressed // TODO: Cursor trail and where keys are pressed
// TODO: Button for -100 ms, +100 ms, etc (precise seeking) (or keyboard shortcuts) // 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: UR bar
// Todo: Customizable speed // Todo: Customizable speed
// TODO: Customizable zoom // TODO: Customizable zoom
@ -75,11 +75,7 @@ export class ReplayViewerComponent implements OnInit, AfterViewInit {
constructor(public replayService: ReplayService) { } constructor(public replayService: ReplayService) { }
ngOnInit() { ngOnInit() {
// Assume getEvents() method returns a promise of ReplayEvent[] this.replayService.loadReplay(this.replayViewerData);
getEvents(replayData).then(events => {
this.replayService.setEvents(processReplay(events));
this.replayService.setHitObjects(parseHitObjects(beatmapData))
});
} }
ngAfterViewInit() { ngAfterViewInit() {