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.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))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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.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)
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
*
|
*
|
||||||
|
|||||||
@ -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
|
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}")
|
||||||
|
|||||||
@ -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}")
|
||||||
|
|||||||
@ -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 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)
|
||||||
|
|||||||
6
nise-frontend/package-lock.json
generated
6
nise-frontend/package-lock.json
generated
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
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;
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 @@
|
|||||||
|
<div class="text-center">
|
||||||
<canvas #replayCanvas width="600" height="400"></canvas>
|
<canvas #replayCanvas width="600" height="400"></canvas>
|
||||||
<div>Current Time: {{ replayService.currentTime | number: '1.0-0' }}</div>
|
</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 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 {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() {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user