From ef495fd7dc189cf7ced6dc9a1b4431593f2240e5 Mon Sep 17 00:00:00 2001 From: Mari Date: Sun, 14 Jul 2024 20:02:56 -0700 Subject: [PATCH] last update before radzathan destruction --- build.gradle.kts | 21 +- src/commonMain/kotlin/model/BattleAction.kt | 25 --- src/commonMain/kotlin/model/BattleEffect.kt | 17 -- src/commonMain/kotlin/model/HistoryEntry.kt | 46 ---- src/commonMain/kotlin/model/TimedEvent.kt | 14 -- .../deliciousreya/predkemon/battle}/Battle.kt | 129 +++++++---- .../predkemon/battle/BattleAction.kt | 95 ++++++++ .../predkemon/battle/BattleEffect.kt | 25 +++ .../predkemon/battle/BattleEvent.kt | 15 ++ .../predkemon/battle/BattleHistoryEntry.kt | 47 ++++ .../predkemon/battle/BattleTime.kt | 173 +++++++++++++++ .../predkemon/battle}/Battler.kt | 210 +++++++++++------- .../predkemon/client/Connection.kt | 13 ++ .../predkemon/client/WebSocketConnection.kt | 57 +++++ .../deliciousreya/predkemon/client}/main.kt | 4 +- .../predkemon/math/SplitMix32Random.kt | 23 ++ .../math/Xoshiro128StarStarRandom.kt | 94 ++++++++ .../predkemon/message/BattleActionMessage.kt | 7 + .../predkemon/message/BattleActionsMessage.kt | 7 + .../predkemon/message/LoadBattleMessage.kt | 7 + .../predkemon/message/Message.kt | 7 + .../deliciousreya/predkemon/message/Player.kt | 12 + .../predkemon/message/QuitMessage.kt | 16 ++ .../predkemon/scenes/BattleScene.kt | 84 +++++++ .../predkemon/scenes/ConnectScene.kt | 64 ++++++ .../predkemon}/views/BattlerView.kt | 38 +++- src/commonMain/kotlin/scenes/BattleScene.kt | 102 --------- src/jvmMain/kotlin/server/GameServer.kt | 108 +++++++++ src/jvmMain/kotlin/server/ServerPlayer.kt | 12 + src/jvmMain/kotlin/server/WebSocketPlayer.kt | 49 ++++ src/jvmMain/kotlin/server/main.kt | 32 +++ 31 files changed, 1209 insertions(+), 344 deletions(-) delete mode 100644 src/commonMain/kotlin/model/BattleAction.kt delete mode 100644 src/commonMain/kotlin/model/BattleEffect.kt delete mode 100644 src/commonMain/kotlin/model/HistoryEntry.kt delete mode 100644 src/commonMain/kotlin/model/TimedEvent.kt rename src/commonMain/kotlin/{model => net/deliciousreya/predkemon/battle}/Battle.kt (61%) create mode 100644 src/commonMain/kotlin/net/deliciousreya/predkemon/battle/BattleAction.kt create mode 100644 src/commonMain/kotlin/net/deliciousreya/predkemon/battle/BattleEffect.kt create mode 100644 src/commonMain/kotlin/net/deliciousreya/predkemon/battle/BattleEvent.kt create mode 100644 src/commonMain/kotlin/net/deliciousreya/predkemon/battle/BattleHistoryEntry.kt create mode 100644 src/commonMain/kotlin/net/deliciousreya/predkemon/battle/BattleTime.kt rename src/commonMain/kotlin/{model => net/deliciousreya/predkemon/battle}/Battler.kt (57%) create mode 100644 src/commonMain/kotlin/net/deliciousreya/predkemon/client/Connection.kt create mode 100644 src/commonMain/kotlin/net/deliciousreya/predkemon/client/WebSocketConnection.kt rename src/commonMain/kotlin/{ => net/deliciousreya/predkemon/client}/main.kt (76%) create mode 100644 src/commonMain/kotlin/net/deliciousreya/predkemon/math/SplitMix32Random.kt create mode 100644 src/commonMain/kotlin/net/deliciousreya/predkemon/math/Xoshiro128StarStarRandom.kt create mode 100644 src/commonMain/kotlin/net/deliciousreya/predkemon/message/BattleActionMessage.kt create mode 100644 src/commonMain/kotlin/net/deliciousreya/predkemon/message/BattleActionsMessage.kt create mode 100644 src/commonMain/kotlin/net/deliciousreya/predkemon/message/LoadBattleMessage.kt create mode 100644 src/commonMain/kotlin/net/deliciousreya/predkemon/message/Message.kt create mode 100644 src/commonMain/kotlin/net/deliciousreya/predkemon/message/Player.kt create mode 100644 src/commonMain/kotlin/net/deliciousreya/predkemon/message/QuitMessage.kt create mode 100644 src/commonMain/kotlin/net/deliciousreya/predkemon/scenes/BattleScene.kt create mode 100644 src/commonMain/kotlin/net/deliciousreya/predkemon/scenes/ConnectScene.kt rename src/commonMain/kotlin/{ => net/deliciousreya/predkemon}/views/BattlerView.kt (66%) delete mode 100644 src/commonMain/kotlin/scenes/BattleScene.kt create mode 100644 src/jvmMain/kotlin/server/GameServer.kt create mode 100644 src/jvmMain/kotlin/server/ServerPlayer.kt create mode 100644 src/jvmMain/kotlin/server/WebSocketPlayer.kt create mode 100644 src/jvmMain/kotlin/server/main.kt diff --git a/build.gradle.kts b/build.gradle.kts index bcc5503..582ee38 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -7,23 +7,16 @@ plugins { korge { id = "net.deliciousreya.predkemon" -// To enable all targets at once - - //targetAll() - -// To enable targets based on properties/environment variables - //targetDefault() + targetAll() + serializationJson() -// To selectively enable targets - - targetJvm() - targetJs() - targetDesktop() - targetIos() - targetAndroid() + gameCategory = GameCategory.ROLE_PLAYING - serializationJson() + entryPoint = "net.deliciousreya.predkemon.client.MainKt.main" + jvmMainClassName = "net.deliciousreya.predkemon.client.MainKt" + entrypoint("server", "server.MainKt") + dependencyMulti("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.0-RC") dependencyMulti("io.github.quillraven.fleks:Fleks:2.5", registerPlugin = false) } diff --git a/src/commonMain/kotlin/model/BattleAction.kt b/src/commonMain/kotlin/model/BattleAction.kt deleted file mode 100644 index 9a9fe2a..0000000 --- a/src/commonMain/kotlin/model/BattleAction.kt +++ /dev/null @@ -1,25 +0,0 @@ -package model - -import korlibs.time.* -import kotlinx.serialization.Serializable - -@Serializable -/** A [BattleAction] represents some kind of input from a player. */ -sealed class BattleAction: TimedEvent() - -@Serializable -data class MultiHitAction( - override val timestamp: TimeSpan, - val target: Battler.Id, - val hits: Int, - val firstHitDelay: TimeSpan, - val subsequentHitDelay: TimeSpan, - val damage: Double, -): BattleAction() { - override fun Battle.EventAPI.execute() { - injectEffect(DamageEffect(timestamp + firstHitDelay, target, damage)) - for (hit in 2..hits) { - injectEffect(DamageEffect(timestamp + firstHitDelay + subsequentHitDelay * (hit - 1), target, damage)) - } - } -} diff --git a/src/commonMain/kotlin/model/BattleEffect.kt b/src/commonMain/kotlin/model/BattleEffect.kt deleted file mode 100644 index 08031f1..0000000 --- a/src/commonMain/kotlin/model/BattleEffect.kt +++ /dev/null @@ -1,17 +0,0 @@ -package model - -import korlibs.time.* -import kotlinx.serialization.Serializable - -@Serializable -/** A [BattleEffect] represents some kind of timed impact on the game as a result of a [BattleAction]. */ -sealed class BattleEffect: TimedEvent() - -@Serializable -data class DamageEffect(override val timestamp: TimeSpan, val id: Battler.Id, val damage: Double): BattleEffect() { - override fun Battle.EventAPI.execute() { - battler(id) { - inflictComposureDamage(damage) - } - } -} diff --git a/src/commonMain/kotlin/model/HistoryEntry.kt b/src/commonMain/kotlin/model/HistoryEntry.kt deleted file mode 100644 index 2cb8e2a..0000000 --- a/src/commonMain/kotlin/model/HistoryEntry.kt +++ /dev/null @@ -1,46 +0,0 @@ -package model - -import korlibs.time.* -import kotlinx.serialization.* - -sealed interface HistoryEntry: Comparable { - val timestamp: TimeSpan - val pendingEffects: List - val battlers: List - - override fun compareTo(other: HistoryEntry): Int { - return timestamp.compareTo(other.timestamp) - } - - @Serializable - @SerialName("load") - data class Load( - override val timestamp: TimeSpan, - override val pendingEffects: List, - override val battlers: List, - ): HistoryEntry { - companion object{ - val ZERO = Load(timestamp = TimeSpan.ZERO, battlers = listOf(), pendingEffects = listOf()) - } - } - - @Serializable - @SerialName("histAction") - data class Action( - val action: BattleAction, - override val pendingEffects: List, - override val battlers: List, - ): HistoryEntry { - override val timestamp: TimeSpan get() = action.timestamp - } - - @Serializable - @SerialName("histFX") - data class Effect( - val effect: BattleEffect, - override val pendingEffects: List, - override val battlers: List, - ): HistoryEntry { - override val timestamp: TimeSpan get() = effect.timestamp - } -} diff --git a/src/commonMain/kotlin/model/TimedEvent.kt b/src/commonMain/kotlin/model/TimedEvent.kt deleted file mode 100644 index fb81692..0000000 --- a/src/commonMain/kotlin/model/TimedEvent.kt +++ /dev/null @@ -1,14 +0,0 @@ -package model - -import korlibs.time.* -import kotlinx.serialization.* - -@Serializable -sealed class TimedEvent: Comparable { - abstract val timestamp: TimeSpan - abstract fun Battle.EventAPI.execute() - - override fun compareTo(other: TimedEvent): Int { - return timestamp.compareTo(other.timestamp) - } -} diff --git a/src/commonMain/kotlin/model/Battle.kt b/src/commonMain/kotlin/net/deliciousreya/predkemon/battle/Battle.kt similarity index 61% rename from src/commonMain/kotlin/model/Battle.kt rename to src/commonMain/kotlin/net/deliciousreya/predkemon/battle/Battle.kt index c73e4a4..21f3b3a 100644 --- a/src/commonMain/kotlin/model/Battle.kt +++ b/src/commonMain/kotlin/net/deliciousreya/predkemon/battle/Battle.kt @@ -1,21 +1,24 @@ -package model +package net.deliciousreya.predkemon.battle import korlibs.datastructure.* import korlibs.datastructure.iterators.* import korlibs.io.lang.* -import korlibs.time.* import kotlinx.serialization.Serializable +import net.deliciousreya.predkemon.math.* +import kotlin.random.* -class Battle(val initializer: HistoryEntry.Load = HistoryEntry.Load.ZERO) { +class Battle(private val initializer: BattleHistoryEntry.Load) { private val _battlers: MutableList = initializer.battlers.map { Battler(it, initializer.timestamp) }.toMutableList() private val _pendingEffects: PriorityQueue = PriorityQueue().apply { addAll(initializer.pendingEffects) } - var currentTime: TimeSpan = initializer.timestamp + private val random: Xoshiro128StarStarRandom = + Xoshiro128StarStarRandom(initializer.randomState) + var currentTime: BattleTime = initializer.timestamp private set /** Full history entries needed for rewinding. Stored from oldest to newest. */ - private val _history: Deque = Deque().also { it.add(initializer) } + private val _history: Deque = Deque().also { it.add(initializer) } /** Actions which will be executed at a later timestamp. */ private val _pendingActions: PriorityQueue = PriorityQueue() /** Abbreviated history entries used only for saving replay data. Stored from oldest to newest. */ @@ -23,13 +26,14 @@ class Battle(val initializer: HistoryEntry.Load = HistoryEntry.Load.ZERO) { val battlers: List get() = _battlers.map { it.asReadonly } val pendingEffects: List get() = _pendingEffects.toList() - val history: List get() = _history.toList() + val history: List get() = _history.toList() val pendingActions: List get() = _pendingActions.toList() val pastHistory: List get() = _pastHistory.toList() - private val eventAPI: EventAPI by lazy { EventAPI(this) } - constructor(initializer: HistoryEntry.Load, actions: List, timestamp: TimeSpan? = null): + private val eventAPI: EventAPI by lazy { EventWritable(this) } + + constructor(initializer: BattleHistoryEntry.Load, actions: List, timestamp: BattleTime? = null): this(initializer) { injectActions(actions) if (timestamp != null) { @@ -37,49 +41,73 @@ class Battle(val initializer: HistoryEntry.Load = HistoryEntry.Load.ZERO) { } } - class EventAPI(private val battle: Battle) { - val battlers: List get() = battle.battlers + interface EventAPI { + val battlers: List + val random: Random + fun addBattler(battler: Battler.Saved) + fun removeBattler(id: Battler.Id) + fun battlers(block: Battler.EventAPI.() -> Unit) + fun battler(id: Battler.Id): Battler.Readonly? + fun battler(id: Battler.Id, block: Battler.EventAPI.() -> Unit) + fun Battler.Readonly.update(block: Battler.EventAPI.() -> Unit) + fun Battler.Readonly.remove() + fun injectEffect(effect: BattleEffect) + fun injectEffects(effects: Iterable) + fun reseedRandom(seed: Xoshiro128StarStarRandom.State) + } + + private class EventWritable(private val battle: Battle) : EventAPI { + override val battlers: List get() = battle.battlers - fun addBattler(battler: Battler.Saved) { + override fun addBattler(battler: Battler.Saved) { if (battle._battlers.any { it.id == battler.id }) { throw IllegalArgumentException("Can't add a battler who's already in the fight!") } battle._battlers.add(Battler(saved = battler, at = battle.currentTime)) } - fun removeBattler(id: Battler.Id) { + override fun removeBattler(id: Battler.Id) { battle._battlers.fastIterateRemove { it.id == id } } - fun battlers(block: Battler.EventAPI.() -> Unit) { + override fun battlers(block: Battler.EventAPI.() -> Unit) { battle._battlers.forEach { it.updateAt(battle.currentTime, block) } } - fun battler(id: Battler.Id): Battler.Readonly? = battle._battlers.find { it.id == id }?.asReadonly + override fun battler(id: Battler.Id): Battler.Readonly? = battle._battlers.find { it.id == id }?.asReadonly - fun battler(id: Battler.Id, block: Battler.EventAPI.() -> Unit) { + override fun battler(id: Battler.Id, block: Battler.EventAPI.() -> Unit) { battle._battlers.find { it.id == id }?.updateAt(battle.currentTime, block) } - fun Battler.Readonly.update(block: Battler.EventAPI.() -> Unit) { + override fun Battler.Readonly.update(block: Battler.EventAPI.() -> Unit) { battler(id, block) } - fun Battler.Readonly.remove() { + override fun Battler.Readonly.remove() { removeBattler(id) } - fun injectEffect(effect: BattleEffect) { + override fun injectEffect(effect: BattleEffect) { when { effect.timestamp > battle.currentTime -> battle._pendingEffects.add(effect) + effect.timestamp == battle.currentTime -> with (effect) { with (battle.eventAPI) { + execute() + } } else -> throw InvalidArgumentException( - "can't inject an effect that would take place now or in the past: $effect") + "can't inject an effect that would take place in the past: $effect") } } - fun injectEffects(effects: Iterable) { + override fun injectEffects(effects: Iterable) { effects.forEach { injectEffect(it) } } + + override val random: Random get() = battle.random + + override fun reseedRandom(seed: Xoshiro128StarStarRandom.State) { + battle.random.loadState(seed) + } } fun injectAction(action: BattleAction) { @@ -100,16 +128,16 @@ class Battle(val initializer: HistoryEntry.Load = HistoryEntry.Load.ZERO) { } /** Abbreviates [_history] up to and including the given [timestamp], moving it to [_pastHistory]. */ - fun abbreviateHistoryUpTo(timestamp: TimeSpan) { + fun abbreviateHistoryUpTo(timestamp: BattleTime) { while (_history.isNotEmpty() && _history.first.timestamp <= timestamp) { val previous = _history.removeFirst() - if (previous is HistoryEntry.Action) { + if (previous is BattleHistoryEntry.Action) { _pastHistory.enqueue(previous.action) } } } - fun setTimeTo(timestamp: TimeSpan) { + fun setTimeTo(timestamp: BattleTime) { if (timestamp == currentTime) { // then we're done already! } else if (timestamp > currentTime){ @@ -119,21 +147,21 @@ class Battle(val initializer: HistoryEntry.Load = HistoryEntry.Load.ZERO) { } } - private fun rewindTo(timestamp: TimeSpan) { + private fun rewindTo(timestamp: BattleTime) { if (_history.isEmpty()) { throw IllegalStateException( - "tried to rewind to ${timestamp.inWholeMilliseconds}ms, but have no full history at all") + "tried to rewind to $timestamp, but have no full history at all") } if (_history.first.timestamp > timestamp) { throw IllegalStateException( - "tried to rewind to ${timestamp.inWholeMilliseconds}ms, " + - "but only have full history back to ${_history.first.timestamp.inWholeMilliseconds}") + "tried to rewind to $timestamp, " + + "but only have full history back to ${_history.first.timestamp}") } while (_history.isNotEmpty() && _history.last.timestamp > timestamp) { val previous = _history.removeLast() - if (previous is HistoryEntry.Action) { + if (previous is BattleHistoryEntry.Action) { _pendingActions.add(previous.action) } } @@ -144,21 +172,22 @@ class Battle(val initializer: HistoryEntry.Load = HistoryEntry.Load.ZERO) { } } - private fun advanceTo(timestamp: TimeSpan) { + private fun advanceTo(timestamp: BattleTime) { while (nextEventAt < timestamp) { val nextEvent = if (nextActionAt < nextEffectAt) { _pendingActions.removeHead() } else { _pendingEffects.removeHead() } + advanceTimeTo(nextEvent.timestamp) applyEvent(nextEvent) } advanceTimeTo(timestamp) } - val nextActionAt: TimeSpan get() = nextAction?.timestamp ?: TimeSpan.INFINITE - val nextEffectAt: TimeSpan get() = nextEffect?.timestamp ?: TimeSpan.INFINITE - val nextEventAt: TimeSpan get() = nextEvent?.timestamp ?: TimeSpan.INFINITE + val nextActionAt: BattleTime get() = nextAction?.timestamp ?: BattleTime.INFINITE + val nextEffectAt: BattleTime get() = nextEffect?.timestamp ?: BattleTime.INFINITE + val nextEventAt: BattleTime get() = nextEvent?.timestamp ?: BattleTime.INFINITE val nextAction: BattleAction? get() = when { _pendingActions.isNotEmpty() -> _pendingActions.head @@ -170,7 +199,7 @@ class Battle(val initializer: HistoryEntry.Load = HistoryEntry.Load.ZERO) { else -> null } - val nextEvent: TimedEvent? get() { + val nextEvent: BattleEvent? get() { val action = nextAction val effect = nextEffect return when { @@ -179,7 +208,7 @@ class Battle(val initializer: HistoryEntry.Load = HistoryEntry.Load.ZERO) { } } - private fun advanceTimeTo(timestamp: TimeSpan) { + private fun advanceTimeTo(timestamp: BattleTime) { if (currentTime == timestamp) { return } @@ -187,32 +216,34 @@ class Battle(val initializer: HistoryEntry.Load = HistoryEntry.Load.ZERO) { _battlers.forEach { it.advanceTo(currentTime) } } - private fun applyEvent(event: TimedEvent) { - advanceTimeTo(event.timestamp) + private fun applyEvent(event: BattleEvent) { with(event) { with(eventAPI) { execute() } } saveHistoryEntry(event) } - private fun saveHistoryEntry(entry: TimedEvent) { + private fun saveHistoryEntry(entry: BattleEvent) { val archivedBattlers = _battlers.map { it.asSaved } val archivedEffects = _pendingEffects.toList() + val archivedRandom = random.copyState() _history.add(when (entry) { - is BattleAction -> HistoryEntry.Action( + is BattleAction -> BattleHistoryEntry.Action( action = entry, battlers = archivedBattlers, pendingEffects = archivedEffects, + randomState = archivedRandom, ) - is BattleEffect -> HistoryEntry.Effect( + is BattleEffect -> BattleHistoryEntry.Effect( effect = entry, battlers = archivedBattlers, pendingEffects = archivedEffects, + randomState = archivedRandom, ) }) } - private fun applyHistoryEntry(entry: HistoryEntry) { + private fun applyHistoryEntry(entry: BattleHistoryEntry) { currentTime = entry.timestamp _pendingEffects.clear() _pendingEffects.addAll(entry.pendingEffects) @@ -226,15 +257,25 @@ class Battle(val initializer: HistoryEntry.Load = HistoryEntry.Load.ZERO) { } } } + random.loadState(entry.randomState) } - val asSaved: Saved get() = Saved( + val asLoadEntry: BattleHistoryEntry.Load + get() = BattleHistoryEntry.Load( + timestamp = BattleTime.ZERO, + pendingEffects = _pendingEffects.map { it.withTimestamp(it.timestamp - currentTime) }, + battlers = _battlers.map { it.asSaved.withDelta(-currentTime) }, + randomState = random.copyState() + ) + + val asSaved: Saved + get() = Saved( currentTime = currentTime, initializer = initializer, history = buildList { addAll(pastHistory) _history.forEach { - if (it is HistoryEntry.Action) { + if (it is BattleHistoryEntry.Action) { add(it.action) } } @@ -247,8 +288,8 @@ class Battle(val initializer: HistoryEntry.Load = HistoryEntry.Load.ZERO) { @Serializable data class Saved( - val currentTime: TimeSpan, - val initializer: HistoryEntry.Load, + val currentTime: BattleTime, + val initializer: BattleHistoryEntry.Load, val history: List, ) } diff --git a/src/commonMain/kotlin/net/deliciousreya/predkemon/battle/BattleAction.kt b/src/commonMain/kotlin/net/deliciousreya/predkemon/battle/BattleAction.kt new file mode 100644 index 0000000..7a989fe --- /dev/null +++ b/src/commonMain/kotlin/net/deliciousreya/predkemon/battle/BattleAction.kt @@ -0,0 +1,95 @@ +package net.deliciousreya.predkemon.battle + +import kotlinx.serialization.Serializable +import net.deliciousreya.predkemon.math.* + +@Serializable +/** A [BattleAction] represents some kind of input from a player. */ +sealed class BattleAction: BattleEvent() { + abstract override fun withTimestamp(time: BattleTime): BattleAction + + @Serializable + data class MultiHit( + override val timestamp: BattleTime, + val target: Battler.Id, + val hits: Int, + val firstHitDelay: BattleTime, + val subsequentHitDelay: BattleTime, + val damageRange: ClosedRange + ): BattleAction() { + override fun Battle.EventAPI.execute() { + injectEffect(BattleEffect.Damage( + timestamp = timestamp + firstHitDelay, + id = target, + damage = random.nextDouble(damageRange.start, damageRange.endInclusive)) + ) + for (hit in 2..hits) { + injectEffect(BattleEffect.Damage( + timestamp = timestamp + firstHitDelay + subsequentHitDelay * (hit - 1), + id = target, + damage = random.nextDouble(damageRange.start, damageRange.endInclusive))) + } + } + + override fun withTimestamp(time: BattleTime): MultiHit { + return copy(timestamp = time) + } + } + + data class AddBattler( + override val timestamp: BattleTime, + val battler: Battler.Saved, + ): BattleAction() { + override fun Battle.EventAPI.execute() { + addBattler(battler) + } + + override fun withTimestamp(time: BattleTime): AddBattler { + return copy(timestamp = time) + } + } + + data class RemoveBattler( + override val timestamp: BattleTime, + val battler: Battler.Id, + ): BattleAction() { + override fun Battle.EventAPI.execute() { + removeBattler(battler) + } + + override fun withTimestamp(time: BattleTime): RemoveBattler { + return copy(timestamp = time) + } + } + + @Serializable + data class Reseed( + override val timestamp: BattleTime, + val newSeed: Xoshiro128StarStarRandom.State + ): BattleAction() { + override fun Battle.EventAPI.execute() { + reseedRandom(newSeed) + } + + override fun withTimestamp(time: BattleTime): Reseed { + return copy(timestamp = time) + } + } + + @Serializable + data class Speak( + override val timestamp: BattleTime, + val speaker: Battler.Id, + val message: String, + ) : BattleAction() { + override fun Battle.EventAPI.execute() { + battler(speaker) { + speak(message) + } + } + + override fun withTimestamp(time: BattleTime): Speak { + return copy(timestamp = time) + } + } +} diff --git a/src/commonMain/kotlin/net/deliciousreya/predkemon/battle/BattleEffect.kt b/src/commonMain/kotlin/net/deliciousreya/predkemon/battle/BattleEffect.kt new file mode 100644 index 0000000..3f0ef24 --- /dev/null +++ b/src/commonMain/kotlin/net/deliciousreya/predkemon/battle/BattleEffect.kt @@ -0,0 +1,25 @@ +package net.deliciousreya.predkemon.battle + +import kotlinx.serialization.Serializable + +@Serializable +/** A [BattleEffect] represents some kind of timed impact on the game as a result of an [BattleAction]. */ +sealed class BattleEffect: BattleEvent() { + abstract override fun withTimestamp(time: BattleTime): BattleEffect + + @Serializable + data class Damage( + override val timestamp: BattleTime, + val id: Battler.Id, + val damage: Double): BattleEffect() { + override fun Battle.EventAPI.execute() { + battler(id) { + inflictComposureDamage(damage) + } + } + + override fun withTimestamp(time: BattleTime): Damage { + return copy(timestamp = time) + } + } +} diff --git a/src/commonMain/kotlin/net/deliciousreya/predkemon/battle/BattleEvent.kt b/src/commonMain/kotlin/net/deliciousreya/predkemon/battle/BattleEvent.kt new file mode 100644 index 0000000..e37c85a --- /dev/null +++ b/src/commonMain/kotlin/net/deliciousreya/predkemon/battle/BattleEvent.kt @@ -0,0 +1,15 @@ +package net.deliciousreya.predkemon.battle + +import kotlinx.serialization.Serializable + +@Serializable +sealed class BattleEvent: Comparable { + abstract val timestamp: BattleTime + abstract fun Battle.EventAPI.execute() + + abstract fun withTimestamp(time: BattleTime): BattleEvent + + override fun compareTo(other: BattleEvent): Int { + return timestamp.compareTo(other.timestamp) + } +} diff --git a/src/commonMain/kotlin/net/deliciousreya/predkemon/battle/BattleHistoryEntry.kt b/src/commonMain/kotlin/net/deliciousreya/predkemon/battle/BattleHistoryEntry.kt new file mode 100644 index 0000000..dd14ff3 --- /dev/null +++ b/src/commonMain/kotlin/net/deliciousreya/predkemon/battle/BattleHistoryEntry.kt @@ -0,0 +1,47 @@ +package net.deliciousreya.predkemon.battle + +import kotlinx.serialization.* +import net.deliciousreya.predkemon.math.* + +@Serializable +sealed class BattleHistoryEntry: Comparable { + abstract val timestamp: BattleTime + abstract val pendingEffects: List + abstract val battlers: List + abstract val randomState: Xoshiro128StarStarRandom.State + + override fun compareTo(other: BattleHistoryEntry): Int { + return timestamp.compareTo(other.timestamp) + } + + @Serializable + @SerialName("histLoad") + data class Load( + override val timestamp: BattleTime, + override val pendingEffects: List, + override val battlers: List, + override val randomState: Xoshiro128StarStarRandom.State, + ): BattleHistoryEntry() + + @Serializable + @SerialName("histAction") + data class Action( + val action: BattleAction, + override val pendingEffects: List, + override val battlers: List, + override val randomState: Xoshiro128StarStarRandom.State, + ): BattleHistoryEntry() { + override val timestamp: BattleTime get() = action.timestamp + } + + @Serializable + @SerialName("histFX") + data class Effect( + val effect: BattleEffect, + override val pendingEffects: List, + override val battlers: List, + override val randomState: Xoshiro128StarStarRandom.State, + ): BattleHistoryEntry() { + override val timestamp: BattleTime get() = effect.timestamp + } +} diff --git a/src/commonMain/kotlin/net/deliciousreya/predkemon/battle/BattleTime.kt b/src/commonMain/kotlin/net/deliciousreya/predkemon/battle/BattleTime.kt new file mode 100644 index 0000000..82d9e92 --- /dev/null +++ b/src/commonMain/kotlin/net/deliciousreya/predkemon/battle/BattleTime.kt @@ -0,0 +1,173 @@ +package net.deliciousreya.predkemon.battle + +import korlibs.io.lang.* +import korlibs.math.* +import korlibs.time.* +import kotlinx.serialization.Serializable +import kotlin.jvm.JvmInline +import kotlin.math.* +import kotlin.time.* + +@JvmInline +@Serializable +value class BattleTime private constructor (val asMilliseconds: Int) : Comparable { + constructor( + milliseconds: Int = 0, + seconds: Int = 0, + minutes: Int = 0, + hours: Int = 0, + days: Int = 0, + ) : this( + days * DAY.asMilliseconds + + hours * HOUR.asMilliseconds + + minutes * MINUTE.asMilliseconds + + seconds * SECOND.asMilliseconds + + milliseconds + ) { + if (this.asMilliseconds !in MIN.asMilliseconds..MAX.asMilliseconds) { + throw OutOfRangeException() + } + } + + private constructor(millisecondsDouble: Double) : this(milliseconds = millisecondsDouble.toIntRound()) + + constructor( + milliseconds: Double = 0.0, + seconds: Double = 0.0, + minutes: Double = 0.0, + hours: Double = 0.0, + days: Double = 0.0, + ) : this( + millisecondsDouble = + days * DAY.asMilliseconds + + hours * HOUR.asMilliseconds + + minutes * MINUTE.asMilliseconds + + seconds * SECOND.asMilliseconds + + milliseconds + ) + + operator fun plus(other: BattleTime): BattleTime { + return when (this) { + INFINITE -> when (other) { + NEGATIVE_INFINITE -> throw OppositeInfinitiesException() + else -> INFINITE + } + NEGATIVE_INFINITE -> when(other) { + INFINITE -> throw OppositeInfinitiesException() + else -> NEGATIVE_INFINITE + } + else -> BattleTime(asMilliseconds = asMilliseconds + other.asMilliseconds) + } + } + + operator fun minus(other: BattleTime): BattleTime { + return when (this) { + INFINITE -> when (other) { + INFINITE -> throw OppositeInfinitiesException() + else -> INFINITE + } + NEGATIVE_INFINITE -> when(other) { + NEGATIVE_INFINITE -> throw OppositeInfinitiesException() + else -> NEGATIVE_INFINITE + } + else -> BattleTime(asMilliseconds = asMilliseconds - other.asMilliseconds) + } + } + + operator fun times(other: Int): BattleTime { + return BattleTime(asMilliseconds * other) + } + + operator fun times(other: Double): BattleTime { + return BattleTime(asMilliseconds * other) + } + + operator fun div(other: Int): BattleTime { + return BattleTime(asMilliseconds / other) + } + + operator fun div(other: Double): BattleTime { + return BattleTime(asMilliseconds / other) + } + + operator fun div(other: BattleTime): Int { + return asMilliseconds / other.asMilliseconds + } + + val days: Int get() = this.asMilliseconds / DAY.asMilliseconds + val asWholeDays: Int get() = days + val asDays: Double get() = this.asMilliseconds.toDouble() / DAY.asMilliseconds + + val hours: Int get() = this.asMilliseconds.rem(DAY.asMilliseconds) / HOUR.asMilliseconds + val asWholeHours: Int get() = this.asMilliseconds / HOUR.asMilliseconds + val asHours: Double get() = this.asMilliseconds.toDouble() / HOUR.asMilliseconds + + val minutes: Int get() = this.asMilliseconds.rem(HOUR.asMilliseconds) / MINUTE.asMilliseconds + val asWholeMinutes: Int get() = this.asMilliseconds / MINUTE.asMilliseconds + val asMinutes: Double get() = this.asMilliseconds.toDouble() / MINUTE.asMilliseconds + + val seconds: Int get() = this.asMilliseconds.rem(MINUTE.asMilliseconds) / SECOND.asMilliseconds + val asWholeSeconds: Int get() = this.asMilliseconds / SECOND.asMilliseconds + val asSeconds: Double get() = this.asMilliseconds.toDouble() / SECOND.asMilliseconds + + val milliseconds: Int get() = this.asMilliseconds.rem(SECOND.asMilliseconds) + + fun isPositive(): Boolean { + return asMilliseconds > 0 && asMilliseconds <= MAX.asMilliseconds + } + + override fun compareTo(other: BattleTime): Int { + return asMilliseconds.compareTo(other.asMilliseconds) + } + + val absoluteValue get() = BattleTime(this.asMilliseconds.absoluteValue) + + val asTimeSpan get() = TimeSpan(this.asMilliseconds.toDouble()) + + override fun toString(): String { + return when (this.absoluteValue) { + INFINITE -> "[BT::∞]" + NEGATIVE_INFINITE -> "[BT::-∞]" + in DAY..MAX -> "[BT::${days}d${hours.absoluteValue.toString().padStart(2, '0')}h${minutes.absoluteValue.toString().padStart(2, '0')}m${seconds.absoluteValue.toString().padStart(2, '0')}s${milliseconds.absoluteValue.toString().padStart(3, '0')}ms]" + in HOUR.. "[BT::${hours}h${minutes.absoluteValue.toString().padStart(2, '0')}m${seconds.absoluteValue.toString().padStart(2, '0')}s${milliseconds.absoluteValue.toString().padStart(3, '0')}ms]" + in MINUTE.. "[BT::${minutes}m${seconds.absoluteValue.toString().padStart(2, '0')}s${milliseconds.absoluteValue.toString().padStart(3, '0')}ms]" + in SECOND.. "[BT::${seconds}s${milliseconds.absoluteValue.toString().padStart(3, '0')}ms]" + else -> "[BT::${milliseconds}ms]" + } + } + + operator fun unaryMinus(): BattleTime = + when (this) { + INFINITE -> NEGATIVE_INFINITE + NEGATIVE_INFINITE -> INFINITE + else -> BattleTime(-asMilliseconds) + } + + class OppositeInfinitiesException : ArithmeticException("Can't add INFINITE and NEGATIVE_INFINITE") + class OutOfRangeException : ArithmeticException("Must be finite and in the range $MIN..$MAX") + + companion object { + val ZERO = BattleTime(0) + val INFINITE = BattleTime(Int.MAX_VALUE) + val NEGATIVE_INFINITE = BattleTime(Int.MIN_VALUE) + val MIN = BattleTime(-24 * 24 * 60 * 60 * 1000) // Arbitrary limit of -24 days + val MAX = BattleTime(24 * 24 * 60 * 60 * 1000) // Arbitrary limit of 24 days + + val MILLISECOND = BattleTime(1) + val SECOND = 1000 * MILLISECOND + val MINUTE = 60 * SECOND + val HOUR = 60 * MINUTE + val DAY = 24 * HOUR + + val TimeSpan.asBattleTime: BattleTime get() = + BattleTime(milliseconds = this.millisecondsInt) + + operator fun Int.times(other: BattleTime): BattleTime { + return BattleTime(this * other.asMilliseconds) + } + + operator fun Double.times(other: BattleTime): BattleTime { + return BattleTime(this * other.asMilliseconds) + } + } +} diff --git a/src/commonMain/kotlin/model/Battler.kt b/src/commonMain/kotlin/net/deliciousreya/predkemon/battle/Battler.kt similarity index 57% rename from src/commonMain/kotlin/model/Battler.kt rename to src/commonMain/kotlin/net/deliciousreya/predkemon/battle/Battler.kt index 611d04c..ba70595 100644 --- a/src/commonMain/kotlin/model/Battler.kt +++ b/src/commonMain/kotlin/net/deliciousreya/predkemon/battle/Battler.kt @@ -1,7 +1,6 @@ -package model +package net.deliciousreya.predkemon.battle import korlibs.math.* -import korlibs.time.* import kotlinx.serialization.Serializable import kotlin.jvm.* import kotlin.math.* @@ -19,50 +18,84 @@ data class Battler ( private var _shaken: Boolean = false, private var _shakenTimes: Int = 0, - private var _composurePausedUntil: TimeSpan = TimeSpan.ZERO, - private var _energyPausedUntil: TimeSpan = TimeSpan.ZERO, - private var _currentUpdate: TimeSpan = TimeSpan.ZERO, + private var _composurePausedUntil: BattleTime = BattleTime.ZERO, + private var _energyPausedUntil: BattleTime = BattleTime.ZERO, + private var _currentUpdate: BattleTime = BattleTime.ZERO, - private var lastUpdate: TimeSpan = TimeSpan.ZERO, + private var _lastMessage: String? = null, + private var _lastMessageAt: BattleTime = BattleTime.NEGATIVE_INFINITE, + + private var lastUpdate: BattleTime = BattleTime.ZERO, private var lastShaken: Boolean = _shaken, private var lastComposure: Double = _composure, private var lastEnergy: Double = _energy, private var lastStamina: Double = _stamina, ) { @JvmInline @Serializable - value class Id(val v: Int) - - private val eventAPI: EventAPI by lazy { EventAPI(this) } + value class Id(private val id: Int) + + private val eventAPI: EventAPI by lazy { EventWritable(this) } + + interface EventAPI { + val id: Id + var name: String + var composure: Double + var health: Double + var maxHealth: Double + var energy: Double + var stamina: Double + var maxStamina: Double + var shaken: Boolean + var shakenTimes: Int + var composurePausedUntil: BattleTime + var energyPausedUntil: BattleTime + val currentUpdate: BattleTime + val asSaved: Saved + fun speak(message: String) + fun becomeShaken() + fun inflictComposureDamage(damage: Double) + fun inflictHealthDamage(damage: Double) + fun spendEnergy(cost: Double) + fun spendStamina(cost: Double) + fun revertTo(saved: Saved) + } - class EventAPI (private val battler: Battler){ - val id: Id get() = battler.id - var name: String get() = battler.name + class EventWritable (private val battler: Battler) : EventAPI { + override val id: Id get() = battler.id + override var name: String get() = battler.name set(v) { battler._name = v } - var composure: Double get() = battler.composure + override var composure: Double get() = battler.composure set(v) { battler._composure = v } - var health: Double get() = battler.health + override var health: Double get() = battler.health set(v) { battler._health = v } - var maxHealth: Double get() = battler.maxHealth + override var maxHealth: Double get() = battler.maxHealth set(v) { battler._maxHealth = v } - var energy: Double get() = battler.energy + override var energy: Double get() = battler.energy set(v) { battler._energy = v } - var stamina: Double get() = battler.stamina + override var stamina: Double get() = battler.stamina set(v) { battler._stamina = v } - var maxStamina: Double get() = battler.maxStamina + override var maxStamina: Double get() = battler.maxStamina set(v) { battler._maxStamina = v } - var shaken: Boolean get() = battler.shaken + override var shaken: Boolean get() = battler.shaken set(v) { battler._shaken = v } - var shakenTimes: Int get() = battler.shakenTimes + override var shakenTimes: Int get() = battler.shakenTimes set(v) { battler._shakenTimes = v } - var composurePausedUntil: TimeSpan get() = battler.composurePausedUntil + override var composurePausedUntil: BattleTime + get() = battler.composurePausedUntil set(v) { battler._composurePausedUntil = v } - var energyPausedUntil: TimeSpan get() = battler.energyPausedUntil + override var energyPausedUntil: BattleTime + get() = battler.energyPausedUntil set(v) { battler._energyPausedUntil = v } - val currentUpdate: TimeSpan get() = battler.currentUpdate + override val currentUpdate: BattleTime get() = battler.currentUpdate - val asSaved: Saved get() = battler.asSaved + override val asSaved: Saved get() = battler.asSaved + + override fun speak(message: String) { + battler._lastMessage = message + battler._lastMessageAt = currentUpdate + } - fun becomeShaken() { + override fun becomeShaken() { composure = health.coerceAtMost(0.0) if (shaken) { return @@ -72,7 +105,7 @@ data class Battler ( shakenTimes += 1 } - fun inflictComposureDamage(damage: Double) { + override fun inflictComposureDamage(damage: Double) { if (shaken) { return } @@ -84,7 +117,7 @@ data class Battler ( } } - fun inflictHealthDamage(damage: Double) { + override fun inflictHealthDamage(damage: Double) { composurePausedUntil = (currentUpdate + battler.composureRecoveryDelay).coerceAtLeast(composurePausedUntil) health -= damage composure -= damage @@ -93,18 +126,18 @@ data class Battler ( } } - fun spendEnergy(cost: Double) { + override fun spendEnergy(cost: Double) { energyPausedUntil = (currentUpdate + battler.energyChargeDelay).coerceAtLeast(energyPausedUntil) energy = (energy - cost).coerceAtLeast(0.0) } - fun spendStamina(cost: Double) { + override fun spendStamina(cost: Double) { energyPausedUntil = (currentUpdate + battler.energyChargeDelay).coerceAtLeast(energyPausedUntil) stamina = (stamina - cost).coerceAtLeast(0.0) energy = (energy - cost).coerceAtLeast(0.0) } - fun revertTo(saved: Saved) { + override fun revertTo(saved: Saved) { battler.revertTo(previous = saved, at = currentUpdate) } } @@ -118,27 +151,29 @@ data class Battler ( val maxStamina: Double get() = _maxStamina val shaken: Boolean get() = _shaken val shakenTimes: Int get() = _shakenTimes - val composurePausedUntil: TimeSpan get() = _composurePausedUntil - val energyPausedUntil: TimeSpan get() = _energyPausedUntil - val currentUpdate: TimeSpan get() = _currentUpdate + val composurePausedUntil: BattleTime get() = _composurePausedUntil + val energyPausedUntil: BattleTime get() = _energyPausedUntil + val currentUpdate: BattleTime get() = _currentUpdate + val lastMessage: String? get() = _lastMessage + val lastMessageAt: BattleTime get() = _lastMessageAt - private val composureChargeTime: TimeSpan get() = TimeSpan(45_000.0 * 1.2.pow(shakenTimes - 1)) - private val composureChargeDelay: TimeSpan get() = TimeSpan(2_000.0 * 1.2.pow(shakenTimes - 1)) - private val composureRecoveryTime: TimeSpan get() = TimeSpan(7_000.0 * 1.2.pow(shakenTimes - 1)) - private val composureRecoveryDelay: TimeSpan get() = TimeSpan(3_000.0 * 1.2.pow(shakenTimes - 1)) + private val composureChargeTime: BattleTime get() = BattleTime(seconds = 45) * 1.2.pow(shakenTimes - 1) + private val composureChargeDelay: BattleTime get() = BattleTime(seconds = 2) * 1.2.pow(shakenTimes - 1) + private val composureRecoveryTime: BattleTime get() = BattleTime(seconds = 7) * 1.2.pow(shakenTimes - 1) + private val composureRecoveryDelay: BattleTime get() = BattleTime(seconds = 3) * 1.2.pow(shakenTimes - 1) - private val composureChargeCoefficient: Double get() = health / composureChargeTime.milliseconds.squared() + private val composureChargeCoefficient: Double get() = health / composureChargeTime.asMilliseconds.squared() - private val energyChargeTime: TimeSpan get() = TimeSpan(30_000.0) - private val energyChargeDelay: TimeSpan get() = TimeSpan(1_000.0) - private val staminaChargeTime: TimeSpan get() = TimeSpan(300_000.0) + private val energyChargeTime: BattleTime get() = BattleTime(seconds = 30) + private val energyChargeDelay: BattleTime get() = BattleTime(seconds = 1) + private val staminaChargeTime: BattleTime get() = BattleTime(minutes = 5) - private fun advanceComposureTo(t: TimeSpan) { + private fun advanceComposureTo(t: BattleTime) { var dtComposure = if (lastUpdate < composurePausedUntil) { if (t > composurePausedUntil) { t - composurePausedUntil } else { - TimeSpan.ZERO + BattleTime.ZERO } } else { t - lastUpdate @@ -152,9 +187,9 @@ data class Battler ( dtComposure -= dtRecover } else { _composure = ( - lastComposure.coerceAtLeast(0.0) + dtComposure.milliseconds * halfHealth / composureRecoveryTime.milliseconds) + lastComposure.coerceAtLeast(0.0) + dtComposure.asMilliseconds * halfHealth / composureRecoveryTime.asMilliseconds) _shaken = lastShaken - dtComposure = TimeSpan.ZERO + dtComposure = BattleTime.ZERO } } else { _composure = lastComposure @@ -162,13 +197,13 @@ data class Battler ( } if (dtComposure.isPositive()) { if (health > 0) { - val lastChargeTime = TimeSpan( + val lastChargeTime = BattleTime( sqrt(4 * composureChargeCoefficient * composure) / (2 * composureChargeCoefficient) ) _composure = if (dtComposure + lastChargeTime >= composureChargeTime) { health } else { - (composureChargeCoefficient * (lastChargeTime + dtComposure).milliseconds.squared()) + (composureChargeCoefficient * (lastChargeTime + dtComposure).asMilliseconds.squared()) } } else { _composure = health @@ -176,12 +211,12 @@ data class Battler ( } } - private fun advanceEnergyAndStaminaTo(t: TimeSpan) { + private fun advanceEnergyAndStaminaTo(t: BattleTime) { var dtEnergy = if (lastUpdate < energyPausedUntil) { if (t > energyPausedUntil) { t - energyPausedUntil } else { - TimeSpan.ZERO + BattleTime.ZERO } } else { t - lastUpdate @@ -194,8 +229,8 @@ data class Battler ( _energy = lastStamina dtEnergy -= dtToFull } else { - _energy = lastEnergy + (lastStamina * dtEnergy.milliseconds) / energyChargeTime.milliseconds - dtEnergy = TimeSpan.ZERO + _energy = lastEnergy + (lastStamina * dtEnergy.asMilliseconds) / energyChargeTime.asMilliseconds + dtEnergy = BattleTime.ZERO } } else { _energy = lastStamina @@ -204,13 +239,13 @@ data class Battler ( if (dtEnergy.isPositive() && lastStamina < maxStamina) { _stamina = min( maxStamina, - lastStamina + (maxStamina * dtEnergy.milliseconds) / staminaChargeTime.milliseconds) + lastStamina + (maxStamina * dtEnergy.asMilliseconds) / staminaChargeTime.asMilliseconds) _energy = stamina } } } - fun advanceTo(t: TimeSpan) { + fun advanceTo(t: BattleTime) { _currentUpdate = t if (composure < health) { advanceComposureTo(t) @@ -220,7 +255,7 @@ data class Battler ( } } - fun updateAt(t: TimeSpan, block: EventAPI.() -> Unit) { + fun updateAt(t: BattleTime, block: EventAPI.() -> Unit) { advanceTo(t) block(eventAPI) @@ -232,24 +267,28 @@ data class Battler ( lastStamina = stamina } - val asSaved: Saved get() = Saved( - id = id, - - name = name, - composure = composure, - health = health, - maxHealth = maxHealth, - energy = energy, - stamina = stamina, - maxStamina = maxStamina, - - shaken = shaken, - shakenTimes = shakenTimes, - composurePausedUntil = composurePausedUntil, - energyPausedUntil = energyPausedUntil - ) + val asSaved: Saved + get() = Saved( + id = id, - fun revertTo(previous: Saved, at: TimeSpan) { + name = name, + composure = composure, + health = health, + maxHealth = maxHealth, + energy = energy, + stamina = stamina, + maxStamina = maxStamina, + + shaken = shaken, + shakenTimes = shakenTimes, + composurePausedUntil = composurePausedUntil, + energyPausedUntil = energyPausedUntil, + + lastMessage = _lastMessage, + lastMessageAt = _lastMessageAt, + ) + + fun revertTo(previous: Saved, at: BattleTime) { _name = previous.name _composure = previous.composure _health = previous.health @@ -271,7 +310,7 @@ data class Battler ( lastStamina = previous.stamina } - constructor(saved: Saved, at: TimeSpan) : this( + constructor(saved: Saved, at: BattleTime) : this( id = saved.id, _name = saved.name, @@ -287,6 +326,9 @@ data class Battler ( _composurePausedUntil = saved.composurePausedUntil, _energyPausedUntil = saved.energyPausedUntil, + _lastMessage = saved.lastMessage, + _lastMessageAt = saved.lastMessageAt, + _currentUpdate = at, lastUpdate = at, lastShaken = saved.shaken, @@ -309,9 +351,19 @@ data class Battler ( val shaken: Boolean = false, val shakenTimes: Int = 0, - val composurePausedUntil: TimeSpan = TimeSpan.ZERO, - val energyPausedUntil: TimeSpan = TimeSpan.ZERO, - ) + val composurePausedUntil: BattleTime = BattleTime.ZERO, + val energyPausedUntil: BattleTime = BattleTime.ZERO, + val lastMessage: String?, + val lastMessageAt: BattleTime, + ) { + fun withDelta(delta: BattleTime): Saved { + return copy( + composurePausedUntil = composurePausedUntil + delta, + energyPausedUntil = energyPausedUntil + delta, + lastMessageAt = lastMessageAt + delta, + ) + } + } val asReadonly get() = Readonly(this) @@ -326,9 +378,11 @@ data class Battler ( val maxStamina: Double get() = battler.maxStamina val shaken: Boolean get() = battler.shaken val shakenTimes: Int get() = battler.shakenTimes - val composurePausedUntil: TimeSpan get() = battler.composurePausedUntil - val energyPausedUntil: TimeSpan get() = battler.energyPausedUntil - val currentUpdate: TimeSpan get() = battler.currentUpdate + val composurePausedUntil: BattleTime get() = battler.composurePausedUntil + val energyPausedUntil: BattleTime get() = battler.energyPausedUntil + val currentUpdate: BattleTime get() = battler.currentUpdate val asSaved: Saved get() = battler.asSaved + val lastMessage: String? get() = battler.lastMessage + val lastMessageAt: BattleTime get() = battler.lastMessageAt } } diff --git a/src/commonMain/kotlin/net/deliciousreya/predkemon/client/Connection.kt b/src/commonMain/kotlin/net/deliciousreya/predkemon/client/Connection.kt new file mode 100644 index 0000000..7011119 --- /dev/null +++ b/src/commonMain/kotlin/net/deliciousreya/predkemon/client/Connection.kt @@ -0,0 +1,13 @@ +package net.deliciousreya.predkemon.client + +import korlibs.io.async.* +import korlibs.io.lang.* +import net.deliciousreya.predkemon.message.* + +interface Connection : Closeable { + val onLoad: Signal + val onMessage: Signal + val onClose: Signal + + suspend fun sendMessage(message: Message) +} diff --git a/src/commonMain/kotlin/net/deliciousreya/predkemon/client/WebSocketConnection.kt b/src/commonMain/kotlin/net/deliciousreya/predkemon/client/WebSocketConnection.kt new file mode 100644 index 0000000..cbe0c58 --- /dev/null +++ b/src/commonMain/kotlin/net/deliciousreya/predkemon/client/WebSocketConnection.kt @@ -0,0 +1,57 @@ +package net.deliciousreya.predkemon.client + +import korlibs.io.async.* +import korlibs.io.lang.* +import korlibs.io.net.ws.* +import kotlinx.coroutines.* +import kotlinx.coroutines.selects.* +import kotlinx.serialization.json.* +import net.deliciousreya.predkemon.message.* +import kotlin.coroutines.* + +class WebSocketConnection( + private val client: WebSocketClient +) : Connection { + private val handlers: MutableList = mutableListOf() + private fun init() { + handlers.add(client.onOpen { + println("Connected to WebSocket") + }) + handlers.add(client.onStringMessage { + when (val message = Json.decodeFromString(Message.serializer(), it)) { + is LoadBattleMessage -> onLoad(message) + else -> onMessage(message) + } + }) + handlers.add(client.onClose { + onClose(null) + close() + }) + handlers.add(client.onError { + it.printStackTrace() + }) + } + + override val onLoad: Signal = Signal() + override val onMessage: Signal = Signal() + override val onClose: Signal = Signal() + + override suspend fun sendMessage(message: Message) { + client.send(Json.encodeToString(Message.serializer(), message)) + } + + override fun close() { + client.close() + onLoad.clear() + onMessage.clear() + onClose.clear() + handlers.forEach { + it.close() + } + } + + companion object { + fun forClient(client: WebSocketClient): WebSocketConnection = + WebSocketConnection(client).also { it.init() } + } +} diff --git a/src/commonMain/kotlin/main.kt b/src/commonMain/kotlin/net/deliciousreya/predkemon/client/main.kt similarity index 76% rename from src/commonMain/kotlin/main.kt rename to src/commonMain/kotlin/net/deliciousreya/predkemon/client/main.kt index ff6a9e2..144b3d1 100644 --- a/src/commonMain/kotlin/main.kt +++ b/src/commonMain/kotlin/net/deliciousreya/predkemon/client/main.kt @@ -1,8 +1,10 @@ +package net.deliciousreya.predkemon.client + import korlibs.image.color.* import korlibs.korge.* import korlibs.korge.scene.* import korlibs.math.geom.* -import scenes.* +import net.deliciousreya.predkemon.scenes.* suspend fun main() = Korge(windowSize = Size(512, 512), backgroundColor = Colors["#2b2b2b"]) { val sceneContainer = sceneContainer() diff --git a/src/commonMain/kotlin/net/deliciousreya/predkemon/math/SplitMix32Random.kt b/src/commonMain/kotlin/net/deliciousreya/predkemon/math/SplitMix32Random.kt new file mode 100644 index 0000000..c683e67 --- /dev/null +++ b/src/commonMain/kotlin/net/deliciousreya/predkemon/math/SplitMix32Random.kt @@ -0,0 +1,23 @@ +package net.deliciousreya.predkemon.math + +import kotlin.random.* + +class SplitMix32Random(seed: Int) : Random() { + var state: Int = seed + private set + + override fun nextBits(bitCount: Int): Int { + state += -0x1e3779b9 // 0x9e3779b9 + var z = state + z = z.xor(z.ushr(16)) + z *= 0x21f0aaad + z = z.xor(z.ushr(15)) + z *= 0x735a2d97 + z = z.xor(z.ushr(15)) + return z.ushr(32 - bitCount) + } + + fun loadState(seed: Int) { + state = seed + } +} diff --git a/src/commonMain/kotlin/net/deliciousreya/predkemon/math/Xoshiro128StarStarRandom.kt b/src/commonMain/kotlin/net/deliciousreya/predkemon/math/Xoshiro128StarStarRandom.kt new file mode 100644 index 0000000..33353b1 --- /dev/null +++ b/src/commonMain/kotlin/net/deliciousreya/predkemon/math/Xoshiro128StarStarRandom.kt @@ -0,0 +1,94 @@ +package net.deliciousreya.predkemon.math + +import korlibs.datastructure.* +import korlibs.io.lang.* +import kotlinx.serialization.* +import kotlin.random.* + +class Xoshiro128StarStarRandom(seed: State) : Random() { + private val state: State = seed.copy() + + @Serializable + data class State( + var s0: Int, + var s1: Int, + var s2: Int, + var s3: Int, + ) { + operator fun get(index: Int) = + when (index) { + 0 -> s0 + 1 -> s1 + 2 -> s2 + 3 -> s3 + else -> throw OutOfBoundsException(index, "State only has components 0-3") + } + + operator fun set(index: Int, value: Int) { + when (index) { + 0 -> s0 = value + 1 -> s1 = value + 2 -> s2 = value + 3 -> s3 = value + else -> throw OutOfBoundsException(index, "State only has components 0-3") + } + } + + fun updateFrom(other: State) { + s0 = other.s0 + s1 = other.s1 + s2 = other.s2 + s3 = other.s3 + } + + companion object { + fun fromRandom(random: Random = Default): State { + return State( + s0 = random.nextInt(), + s1 = random.nextInt(), + s2 = random.nextInt(), + s3 = random.nextInt(), + ) + } + fun fromSeed(seed: Int): State { + return fromRandom(SplitMix32Random(seed)) + } + } + } + + constructor(seed: Int) : this(State.fromSeed(seed)) + + constructor(random: Random) : this(State.fromRandom(random)) + + var totalSteps: Int = 0 + private set + + fun step(count: Int = 1) { + for (step in 1..count) { + val t = state[1].shl(9) + state[2] = state[2].xor(state[0]) + state[3] = state[3].xor(state[1]) + state[1] = state[1].xor(state[2]) + state[0] = state[0].xor(state[3]) + state[2] = state[2].xor(t) + state[3] = state[3].rotateLeft(11) + totalSteps += 1 + } + } + + override fun nextBits(bitCount: Int): Int { + val result = (state[1] * 5).rotateLeft(7) * 9 + step(1) + return result.ushr(32 - bitCount) + } + + fun copyState(): State = state.copy() + + fun copyStateInto(other: State) { + other.updateFrom(state) + } + + fun loadState(seed: State) { + state.updateFrom(seed) + } +} diff --git a/src/commonMain/kotlin/net/deliciousreya/predkemon/message/BattleActionMessage.kt b/src/commonMain/kotlin/net/deliciousreya/predkemon/message/BattleActionMessage.kt new file mode 100644 index 0000000..feb4347 --- /dev/null +++ b/src/commonMain/kotlin/net/deliciousreya/predkemon/message/BattleActionMessage.kt @@ -0,0 +1,7 @@ +package net.deliciousreya.predkemon.message + +import kotlinx.serialization.Serializable +import net.deliciousreya.predkemon.battle.BattleAction + +@Serializable +data class BattleActionMessage(val action: BattleAction): Message() diff --git a/src/commonMain/kotlin/net/deliciousreya/predkemon/message/BattleActionsMessage.kt b/src/commonMain/kotlin/net/deliciousreya/predkemon/message/BattleActionsMessage.kt new file mode 100644 index 0000000..8a660a9 --- /dev/null +++ b/src/commonMain/kotlin/net/deliciousreya/predkemon/message/BattleActionsMessage.kt @@ -0,0 +1,7 @@ +package net.deliciousreya.predkemon.message + +import kotlinx.serialization.Serializable +import net.deliciousreya.predkemon.battle.BattleAction + +@Serializable +data class BattleActionsMessage(val actions: List): Message() diff --git a/src/commonMain/kotlin/net/deliciousreya/predkemon/message/LoadBattleMessage.kt b/src/commonMain/kotlin/net/deliciousreya/predkemon/message/LoadBattleMessage.kt new file mode 100644 index 0000000..e27a736 --- /dev/null +++ b/src/commonMain/kotlin/net/deliciousreya/predkemon/message/LoadBattleMessage.kt @@ -0,0 +1,7 @@ +package net.deliciousreya.predkemon.message + +import kotlinx.serialization.Serializable +import net.deliciousreya.predkemon.battle.* + +@Serializable +data class LoadBattleMessage (val saved: Battle.Saved): Message() diff --git a/src/commonMain/kotlin/net/deliciousreya/predkemon/message/Message.kt b/src/commonMain/kotlin/net/deliciousreya/predkemon/message/Message.kt new file mode 100644 index 0000000..96a4690 --- /dev/null +++ b/src/commonMain/kotlin/net/deliciousreya/predkemon/message/Message.kt @@ -0,0 +1,7 @@ +package net.deliciousreya.predkemon.message + +import kotlinx.serialization.Serializable + +@Serializable +sealed class Message + diff --git a/src/commonMain/kotlin/net/deliciousreya/predkemon/message/Player.kt b/src/commonMain/kotlin/net/deliciousreya/predkemon/message/Player.kt new file mode 100644 index 0000000..b76f0cc --- /dev/null +++ b/src/commonMain/kotlin/net/deliciousreya/predkemon/message/Player.kt @@ -0,0 +1,12 @@ +package net.deliciousreya.predkemon.message + +import kotlinx.serialization.Serializable +import kotlin.jvm.* + +@Serializable +data class Player( + val id: Id, + val name: String) { + @JvmInline @Serializable + value class Id(private val id: String) +} diff --git a/src/commonMain/kotlin/net/deliciousreya/predkemon/message/QuitMessage.kt b/src/commonMain/kotlin/net/deliciousreya/predkemon/message/QuitMessage.kt new file mode 100644 index 0000000..1135808 --- /dev/null +++ b/src/commonMain/kotlin/net/deliciousreya/predkemon/message/QuitMessage.kt @@ -0,0 +1,16 @@ +package net.deliciousreya.predkemon.message + +import net.deliciousreya.predkemon.battle.* + +data class QuitMessage( + val id: Player.Id, + val timestamp: BattleTime, + val reason: Reason, +) : Message() { + enum class Reason { + LEFT, + DISCONNECTED, + CRASHED, + } + +} diff --git a/src/commonMain/kotlin/net/deliciousreya/predkemon/scenes/BattleScene.kt b/src/commonMain/kotlin/net/deliciousreya/predkemon/scenes/BattleScene.kt new file mode 100644 index 0000000..b37930b --- /dev/null +++ b/src/commonMain/kotlin/net/deliciousreya/predkemon/scenes/BattleScene.kt @@ -0,0 +1,84 @@ +package net.deliciousreya.predkemon.scenes + +import korlibs.event.* +import korlibs.korge.input.* +import korlibs.korge.scene.* +import korlibs.korge.view.* +import korlibs.time.* +import net.deliciousreya.predkemon.battle.* +import net.deliciousreya.predkemon.battle.BattleTime +import net.deliciousreya.predkemon.client.* +import net.deliciousreya.predkemon.message.* +import net.deliciousreya.predkemon.views.* + +class BattleScene(message: LoadBattleMessage, connection: Connection, buffer: List) : Scene() { + private val battle = Battle(message.saved) + + override suspend fun SContainer.sceneInit() { + + val usView = BattlerView(battle.battlers[0]) + usView.xy(sceneWidth - (usView.width + 5), sceneHeight - (usView.height + 5)) + addChild(usView) + + val themView = BattlerView(battle.battlers[1]) + themView.xy(5, 5) + addChild(themView) + + var time = BattleTime.ZERO + var nanos = 0 + addUpdater { + val whole = it.millisecondsInt + nanos += it.rem(TimeSpan(milliseconds = 1.0)).nanosecondsInt + time += BattleTime(milliseconds = whole) + if (nanos > TimeSpan(milliseconds = 1.0).nanosecondsInt) { + time += BattleTime(milliseconds = nanos.floorDiv(TimeSpan(milliseconds = 1.0).nanosecondsInt)) + nanos = nanos.rem(TimeSpan(milliseconds = 1.0).nanosecondsInt) + } + battle.setTimeTo(time) + } + + keys { + justDown(Key.R) { + time = BattleTime.ZERO + nanos = 0 + battle.setTimeTo(time) + } + + justDown(Key.F) { + time += BattleTime(10_000.0) + battle.setTimeTo(time) + } + + justDown(Key.M) { + battle.injectAction( + BattleAction.Speak( + timestamp = time + BattleTime(milliseconds = 100), + speaker = Battler.Id(0), + message = "Hello! It's ${time}!" + ) + ) + } + + justDown(Key.V) { + battle.injectAction( + BattleAction.MultiHit( + timestamp = time, + target = Battler.Id(1), + hits = 5, + firstHitDelay = BattleTime(500.0), + subsequentHitDelay = BattleTime(100.0), + damageRange = 3.0..7.0 + ) + ) + } + } + } + + override suspend fun SContainer.sceneMain() { + // @TODO: Main scene code here (after sceneInit) + } + + override suspend fun sceneAfterInit() { + super.sceneAfterInit() + } +} diff --git a/src/commonMain/kotlin/net/deliciousreya/predkemon/scenes/ConnectScene.kt b/src/commonMain/kotlin/net/deliciousreya/predkemon/scenes/ConnectScene.kt new file mode 100644 index 0000000..97accc9 --- /dev/null +++ b/src/commonMain/kotlin/net/deliciousreya/predkemon/scenes/ConnectScene.kt @@ -0,0 +1,64 @@ +package net.deliciousreya.predkemon.scenes + +import korlibs.event.* +import korlibs.io.net.ws.* +import korlibs.korge.input.* +import korlibs.korge.scene.* +import korlibs.korge.ui.* +import korlibs.korge.view.* +import kotlinx.coroutines.* +import net.deliciousreya.predkemon.client.* +import net.deliciousreya.predkemon.message.* + +class ConnectScene(val sceneContainer: SceneContainer) : Scene() { + val textId = UIText("0") + val button = UIButton() + val status = UIText("Waiting for ID") + + suspend fun attemptConnection() { + status.text = "Connecting" + val connection = WebSocketConnection.forClient(WebSocketClient( + url = "wss://localhost:8689/", + )) + val deferred = CompletableDeferred(null) + val buffer = mutableListOf() + val onLoad = connection.onLoad { + deferred.complete(it) + } + val onMessage = connection.onMessage { + buffer.add(it) + } + val onClose = connection.onClose { + @Suppress("ThrowableNotThrown") + deferred.completeExceptionally( + Exception("Closed by other end")) + } + val load: LoadBattleMessage? = try { + deferred.await() + } catch(ex: Throwable) { + status.text = "Failed: ${ex.message}" + ex.printStackTrace() + null + } finally { + onLoad.close() + onMessage.close() + onClose.close() + } + if (load != null) { + sceneContainer.changeTo { + BattleScene(load, connection, buffer) + } + } + } + override suspend fun SContainer.sceneInit() { + addChild(textId) + addChild(button) + addChild(status) + keys { + + } + } + override suspend fun SContainer.sceneMain() { + // @TODO: Main scene code here (after sceneInit) + } +} diff --git a/src/commonMain/kotlin/views/BattlerView.kt b/src/commonMain/kotlin/net/deliciousreya/predkemon/views/BattlerView.kt similarity index 66% rename from src/commonMain/kotlin/views/BattlerView.kt rename to src/commonMain/kotlin/net/deliciousreya/predkemon/views/BattlerView.kt index 2f5d2a5..a0f08c6 100644 --- a/src/commonMain/kotlin/views/BattlerView.kt +++ b/src/commonMain/kotlin/net/deliciousreya/predkemon/views/BattlerView.kt @@ -1,14 +1,30 @@ -package views +package net.deliciousreya.predkemon.views import korlibs.image.color.* +import korlibs.image.paint.* +import korlibs.image.text.* import korlibs.image.vector.* import korlibs.korge.ui.* import korlibs.korge.view.* import korlibs.math.geom.* -import model.* +import net.deliciousreya.predkemon.battle.* import kotlin.math.* -class BattlerView (val battler: Battler.Readonly) : UIContainer(Size(200.0, 40.0)) { +class BattlerView(private val battler: Battler.Readonly) : UIContainer(Size(400.0, 40.0)) { + companion object { + val chatVisibleOffset: BattleTime = BattleTime(seconds = 1) + val chatVisibleMsPerChar: BattleTime = BattleTime(milliseconds = 60) + fun chatVisibleTime(message: String?): BattleTime = when (message) { + null -> BattleTime.ZERO + else -> chatVisibleOffset + chatVisibleMsPerChar * message.length + } + + fun showChat(message: String?, at: BattleTime, currentTime: BattleTime): Boolean = when (message) { + null -> false + else -> currentTime in at..(at + chatVisibleTime(message)) + } + } + private val staminaBar = graphics { drawStaminaBar() @@ -18,11 +34,16 @@ class BattlerView (val battler: Battler.Readonly) : UIContainer(Size(200.0, 40.0 } private val staminaText = uiText("${battler.energy.roundToInt()}/${battler.stamina.roundToInt()}/${battler.maxStamina.roundToInt()}").xy(100, 25) private val healthText = uiText("${battler.composure.roundToInt()}/${battler.health.roundToInt()}/${battler.maxHealth.roundToInt()}").xy(100, 5) + private val chatText = textBlock( + size = Size(200.0, 40.0), + align = TextAlignment.TOP_LEFT, + ).xy(200.0, 0.0) private val battlerName = uiText(battler.name) private var composure: Double = battler.composure private var health: Double = battler.health private var energy: Double = battler.energy private var stamina: Double = battler.stamina + private var chat = "" init { addUpdater { @@ -84,5 +105,16 @@ class BattlerView (val battler: Battler.Readonly) : UIContainer(Size(200.0, 40.0 stamina = battler.stamina updateStaminaBar() } + val shouldChatBeVisible = showChat( + message = battler.lastMessage, + at = battler.lastMessageAt, + currentTime = battler.currentUpdate + ) + if (shouldChatBeVisible != chatText.visible || (shouldChatBeVisible && chat != battler.lastMessage)) { + chatText.visible = shouldChatBeVisible + if (shouldChatBeVisible) { + chatText.plainText = battler.lastMessage ?: "" + } + } } } diff --git a/src/commonMain/kotlin/scenes/BattleScene.kt b/src/commonMain/kotlin/scenes/BattleScene.kt deleted file mode 100644 index fa3b643..0000000 --- a/src/commonMain/kotlin/scenes/BattleScene.kt +++ /dev/null @@ -1,102 +0,0 @@ -package scenes - -import korlibs.event.* -import korlibs.korge.input.* -import korlibs.korge.scene.* -import korlibs.korge.view.* -import korlibs.time.* -import model.* -import views.* -import kotlin.math.* - -class BattleScene : Scene() { - private val battle = Battle(HistoryEntry.Load( - timestamp = TimeSpan.ZERO, - battlers = listOf( - Battler.Saved( - id = Battler.Id(0), - name = "Us", - composure = 150.0, - health = 150.0, - maxHealth = 200.0, - energy = 0.0, - stamina = 150.0, - maxStamina = 300.0, - - shaken = false, - shakenTimes = 0, - composurePausedUntil = TimeSpan.ZERO, - energyPausedUntil = TimeSpan.ZERO, - ), - Battler.Saved( - id = Battler.Id(1), - name = "Them", - composure = 150.0, - health = 150.0, - maxHealth = 200.0, - energy = 0.0, - stamina = 150.0, - maxStamina = 300.0, - - shaken = false, - shakenTimes = 0, - composurePausedUntil = TimeSpan.ZERO, - energyPausedUntil = TimeSpan.ZERO, - ) - ), - pendingEffects = listOf() - )) - - override suspend fun SContainer.sceneInit() { - - val usView = BattlerView(battle.battlers[0]) - usView.xy(sceneWidth - (usView.width + 5), sceneHeight - (usView.height + 5)) - addChild(usView) - - val themView = BattlerView(battle.battlers[1]) - themView.xy(5, 5) - addChild(themView) - - var time = TimeSpan.ZERO - var fractional = 0.0 - addUpdater { - val step = it.milliseconds + fractional - val whole = floor(step) - fractional = step - whole - time += TimeSpan(whole) - battle.setTimeTo(time) - } - - keys { - justDown(Key.R) { - time = TimeSpan.ZERO - fractional = 0.0 - battle.setTimeTo(time) - } - - justDown(Key.F) { - time += TimeSpan(10_000.0) - battle.setTimeTo(time) - } - - justDown(Key.V) { - battle.injectAction(MultiHitAction( - timestamp = time, - target = Battler.Id(1), - hits = 5, - firstHitDelay = TimeSpan(500.0), - subsequentHitDelay = TimeSpan(100.0), - damage = 5.0, - )) - } - } - } - - override suspend fun SContainer.sceneMain() { - // @TODO: Main scene code here (after sceneInit) - } - - override suspend fun sceneAfterInit() { - super.sceneAfterInit() - } -} diff --git a/src/jvmMain/kotlin/server/GameServer.kt b/src/jvmMain/kotlin/server/GameServer.kt new file mode 100644 index 0000000..dfbced5 --- /dev/null +++ b/src/jvmMain/kotlin/server/GameServer.kt @@ -0,0 +1,108 @@ +package server + +import korlibs.io.async.* +import korlibs.io.lang.* +import korlibs.time.* +import net.deliciousreya.predkemon.battle.* +import net.deliciousreya.predkemon.battle.BattleTime.Companion.asBattleTime +import net.deliciousreya.predkemon.math.* +import net.deliciousreya.predkemon.message.* +import kotlin.coroutines.* + +class GameServer { + private val _battle = Battle(BattleHistoryEntry.Load( + timestamp = BattleTime.ZERO, + pendingEffects = listOf(), + battlers = listOf(), + randomState = Xoshiro128StarStarRandom.State.fromRandom() + )) + + val battle get() = _battle.also { catchUp() } + + private var startRelative: DateTime = DateTime.EPOCH + private var running: Boolean = false + + private fun catchUp() { + if (running) { + _battle.setTimeTo((DateTime.now() - startRelative).asBattleTime) + } + } + + fun resume() { + if (running) { + return + } + running = true + startRelative = DateTime.now() - _battle.currentTime.asTimeSpan + } + + fun pause() { + if (!running) { + return + } + running = false + startRelative = DateTime.EPOCH + } + + data class WrappedPlayer( + val player: ServerPlayer, + val onMessage: Closeable, + val onDisconnect: Closeable, + ) + + val players: MutableList = mutableListOf() + + suspend fun handleMessage(source: ServerPlayer?, message: Message) { + when (message) { + is BattleActionMessage -> { + battle.injectAction(message.action) + broadcastMessage(source, message) + } + is BattleActionsMessage -> { + battle.injectActions(message.actions) + broadcastMessage(source, message) + } + is LoadBattleMessage -> { + // discard + } + is QuitMessage -> { + // discard + } + } + } + + suspend fun broadcastMessage(source: ServerPlayer?, message: Message) { + players.forEach { + launch(coroutineContext) { + if (it.player != source) { + it.player.sendMessage(message) + } + } + } + } + + suspend fun addPlayer(player: ServerPlayer) { + val onMessage = player.onMessage.addSuspend { + handleMessage(player, it) + } + val onDisconnect = player.onDisconnect.addSuspend { + removePlayer(player) + } + resume() + players.add(WrappedPlayer( + player = player, + onMessage = onMessage, + onDisconnect = onDisconnect, + )) + player.sendMessage(LoadBattleMessage(battle.asSaved)) + } + + fun removePlayer(player: ServerPlayer) { + val wrapped = players.find { it.player == player } + wrapped?.onMessage?.close() + players.remove(wrapped) + if (players.isEmpty()) { + pause() + } + } +} diff --git a/src/jvmMain/kotlin/server/ServerPlayer.kt b/src/jvmMain/kotlin/server/ServerPlayer.kt new file mode 100644 index 0000000..dac2e77 --- /dev/null +++ b/src/jvmMain/kotlin/server/ServerPlayer.kt @@ -0,0 +1,12 @@ +package server + +import korlibs.io.async.* +import korlibs.io.lang.* +import net.deliciousreya.predkemon.message.* + +interface ServerPlayer : Closeable { + val onMessage: Signal + val onDisconnect: Signal + + suspend fun sendMessage(message: Message) +} diff --git a/src/jvmMain/kotlin/server/WebSocketPlayer.kt b/src/jvmMain/kotlin/server/WebSocketPlayer.kt new file mode 100644 index 0000000..ac6b69f --- /dev/null +++ b/src/jvmMain/kotlin/server/WebSocketPlayer.kt @@ -0,0 +1,49 @@ +package server + +import korlibs.io.async.* +import korlibs.io.lang.* +import korlibs.io.net.http.* +import kotlinx.serialization.json.* +import net.deliciousreya.predkemon.message.* + +class WebSocketPlayer private constructor( + private val request: HttpServer.WsRequest +) : ServerPlayer { + private val listeners: MutableList = mutableListOf() + + private fun initialize() { + request.onClose { + onDisconnect(null) + close() + } + request.onStringMessage { + val msg = Json.decodeFromString(Message.serializer(), it) + onMessage(msg) + } + } + + companion object { + fun forRequest(request: HttpServer.WsRequest): WebSocketPlayer { + val player = WebSocketPlayer(request) + player.initialize() + return player + } + } + + override fun close() { + onMessage.clear() + onDisconnect.clear() + listeners.forEach { it.close() } + request.close() + } + + override val onMessage: Signal = Signal() + override val onDisconnect: Signal = Signal() + + override suspend fun sendMessage(message: Message) { + request.send( + Json.encodeToString( + Message.serializer(), message)) + } + +} diff --git a/src/jvmMain/kotlin/server/main.kt b/src/jvmMain/kotlin/server/main.kt new file mode 100644 index 0000000..d97d7fa --- /dev/null +++ b/src/jvmMain/kotlin/server/main.kt @@ -0,0 +1,32 @@ +package server + +import korlibs.io.lang.* +import korlibs.io.net.http.* +import kotlinx.coroutines.* + +fun main() { + val server = createHttpServer() + val game = GameServer() + + runBlocking { + server.websocketHandler { + println("new WS request!") + it.accept(Http.Headers.build {}) + game.addPlayer(WebSocketPlayer.forRequest(it)) + } + + server.httpHandler { + println("new request!") + it.setStatus(200, "OK") + it.write("Running!", Charset.forName("UTF-8")) + it.end() + } + + server.errorHandler { + println("oops failure") + it.printStackTrace() + } + + server.listen(port = 8689, host = "localhost") + } +}