Working on integrating replay system with backend, store beatmap file in backend (w/ migration from file system)
This commit is contained in:
parent
1b390969c0
commit
6bba41623e
@ -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<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>?, 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)
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 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)}.
|
||||
*/
|
||||
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,
|
||||
* 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))
|
||||
}
|
||||
|
||||
@ -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<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?
|
||||
set(value): Unit = set(0, value)
|
||||
@ -61,6 +61,10 @@ open class BeatmapsRecord private constructor() : UpdatableRecordImpl<BeatmapsRe
|
||||
set(value): Unit = set(9, value)
|
||||
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
|
||||
// -------------------------------------------------------------------------
|
||||
@ -68,11 +72,11 @@ open class BeatmapsRecord private constructor() : UpdatableRecordImpl<BeatmapsRe
|
||||
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 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 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(): 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 field2(): Field<String?> = Beatmaps.BEATMAPS.ARTIST
|
||||
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 field9(): Field<OffsetDateTime?> = Beatmaps.BEATMAPS.SYS_LAST_UPDATE
|
||||
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 component2(): String? = artist
|
||||
override fun component3(): Int? = beatmapsetId
|
||||
@ -93,6 +98,7 @@ open class BeatmapsRecord private constructor() : UpdatableRecordImpl<BeatmapsRe
|
||||
override fun component8(): String? = version
|
||||
override fun component9(): OffsetDateTime? = sysLastUpdate
|
||||
override fun component10(): String? = lastReplayCheck
|
||||
override fun component11(): String? = beatmapFile
|
||||
override fun value1(): Int? = beatmapId
|
||||
override fun value2(): String? = artist
|
||||
override fun value3(): Int? = beatmapsetId
|
||||
@ -103,6 +109,7 @@ open class BeatmapsRecord private constructor() : UpdatableRecordImpl<BeatmapsRe
|
||||
override fun value8(): String? = version
|
||||
override fun value9(): OffsetDateTime? = sysLastUpdate
|
||||
override fun value10(): String? = lastReplayCheck
|
||||
override fun value11(): String? = beatmapFile
|
||||
|
||||
override fun value1(value: Int?): BeatmapsRecord {
|
||||
set(0, value)
|
||||
@ -154,7 +161,12 @@ open class BeatmapsRecord private constructor() : UpdatableRecordImpl<BeatmapsRe
|
||||
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.value2(value2)
|
||||
this.value3(value3)
|
||||
@ -165,13 +177,14 @@ open class BeatmapsRecord private constructor() : UpdatableRecordImpl<BeatmapsRe
|
||||
this.value8(value8)
|
||||
this.value9(value9)
|
||||
this.value10(value10)
|
||||
this.value11(value11)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.artist = artist
|
||||
this.beatmapsetId = beatmapsetId
|
||||
@ -182,6 +195,7 @@ open class BeatmapsRecord private constructor() : UpdatableRecordImpl<BeatmapsRe
|
||||
this.version = version
|
||||
this.sysLastUpdate = sysLastUpdate
|
||||
this.lastReplayCheck = lastReplayCheck
|
||||
this.beatmapFile = beatmapFile
|
||||
resetChangedOnNotNull()
|
||||
}
|
||||
}
|
||||
|
||||
@ -94,6 +94,11 @@ data class ReplayDataSimilarScore(
|
||||
val correlation: Double
|
||||
)
|
||||
|
||||
data class ReplayViewerData(
|
||||
val replay: String,
|
||||
val beatmap: String,
|
||||
)
|
||||
|
||||
data class ReplayData(
|
||||
val replay_id: Long,
|
||||
val user_id: Int,
|
||||
|
||||
@ -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())
|
||||
}
|
||||
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
@ -3,6 +3,7 @@ package com.nisemoe.nise.controller
|
||||
import com.nisemoe.nise.Format
|
||||
import com.nisemoe.nise.ReplayData
|
||||
import com.nisemoe.nise.ReplayPair
|
||||
import com.nisemoe.nise.ReplayViewerData
|
||||
import com.nisemoe.nise.database.ScoreService
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
@ -14,6 +15,14 @@ class ScoreController(
|
||||
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}")
|
||||
fun getScoreDetails(@PathVariable replayId: Long): ResponseEntity<ReplayData> {
|
||||
val replayData = this.scoreService.getReplayData(replayId)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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<ReplayResponse> {
|
||||
fun processReplay(replayData: String, beatmapData: String, mods: Int = 0): CompletableFuture<ReplayResponse> {
|
||||
val requestUri = "$apiUrl/replay"
|
||||
|
||||
val request = ReplayRequest(
|
||||
replay_data = replayData,
|
||||
beatmap_data = beatmapData,
|
||||
mods = mods,
|
||||
beatmap_id = beatmapId
|
||||
)
|
||||
|
||||
// Serialize the request object to JSON
|
||||
|
||||
@ -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
|
||||
*
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -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}")
|
||||
|
||||
@ -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}")
|
||||
|
||||
@ -0,0 +1,2 @@
|
||||
ALTER TABLE public.beatmaps
|
||||
ADD COLUMN beatmap_file text;
|
||||
@ -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)
|
||||
|
||||
6
nise-frontend/package-lock.json
generated
6
nise-frontend/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -12,9 +12,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
<div class="main term mb-2">
|
||||
<app-replay-viewer></app-replay-viewer>
|
||||
</div>
|
||||
<ng-container *ngIf="this.replayData && !this.isLoading && !this.error">
|
||||
<div class="main term mb-2">
|
||||
<div class="fade-stuff">
|
||||
@ -193,7 +190,7 @@
|
||||
<app-chart [title]="chart.title" [data]="chart.data"></app-chart>
|
||||
</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>
|
||||
<canvas baseChart
|
||||
[data]="barChartData"
|
||||
@ -204,4 +201,9 @@
|
||||
class="chart">
|
||||
</canvas>
|
||||
</div>
|
||||
|
||||
<div class="main term mb-2" *ngIf="this.replayViewerData">
|
||||
<h1># replay viewer <small>(experimental)</small></h1>
|
||||
<app-replay-viewer [replayViewerData]="this.replayViewerData"></app-replay-viewer>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
@ -6,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<ReplayViewerData>(`${environment.apiUrl}/score/${this.replayId}/replay`)
|
||||
.subscribe({
|
||||
next: (response) => {
|
||||
this.replayViewerData = response;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private loadScoreData(): void {
|
||||
this.isLoading = true;
|
||||
this.http.get<ReplayData>(`${environment.apiUrl}/score/${this.replayId}`).pipe(
|
||||
|
||||
BIN
nise-frontend/src/assets/replay-viewer/approachcircle.png
Normal file
BIN
nise-frontend/src/assets/replay-viewer/approachcircle.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.4 KiB |
BIN
nise-frontend/src/assets/replay-viewer/cursor.png
Normal file
BIN
nise-frontend/src/assets/replay-viewer/cursor.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
BIN
nise-frontend/src/assets/replay-viewer/hitcircle.png
Normal file
BIN
nise-frontend/src/assets/replay-viewer/hitcircle.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.4 KiB |
BIN
nise-frontend/src/assets/replay-viewer/hitcircleoverlay.png
Normal file
BIN
nise-frontend/src/assets/replay-viewer/hitcircleoverlay.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
BIN
nise-frontend/src/assets/replay-viewer/reversearrow.png
Normal file
BIN
nise-frontend/src/assets/replay-viewer/reversearrow.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.1 KiB |
@ -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<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[] {
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@ -1,10 +1,7 @@
|
||||
import {KeyPress, ReplayEvent} from "./replay-viewer.component";
|
||||
import LZMA from 'lzma-web'
|
||||
|
||||
export async function getEvents(replayString: string): Promise<ReplayEvent[]> {
|
||||
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<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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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<HTMLCanvasElement> | 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>) {
|
||||
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;
|
||||
}
|
||||
|
||||
@ -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%;
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,40 @@
|
||||
<canvas #replayCanvas width="600" height="400"></canvas>
|
||||
<div>Current Time: {{ replayService.currentTime | number: '1.0-0' }}</div>
|
||||
<div>Total Duration: {{ replayService.getTotalDuration() | number: '1.0-0' }}</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">
|
||||
<canvas #replayCanvas width="600" height="400"></canvas>
|
||||
</div>
|
||||
|
||||
|
||||
<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>
|
||||
|
||||
|
||||
|
||||
@ -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<HTMLCanvasElement>;
|
||||
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() {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user