From 068edf09dd50a76b858c341786231dccdf1c6031 Mon Sep 17 00:00:00 2001 From: TheSaminator Date: Fri, 11 Mar 2022 13:04:14 -0500 Subject: [PATCH] So many changes that this could be called Starship Fights 2 --- .../kotlin/starshipfights/game/admiralty.kt | 6 +- .../starshipfights/game/game_ability.kt | 194 +++++++++++---- .../kotlin/starshipfights/game/game_chat.kt | 24 ++ .../kotlin/starshipfights/game/game_phase.kt | 5 + .../kotlin/starshipfights/game/game_state.kt | 87 +++++-- .../kotlin/starshipfights/game/matchmaking.kt | 10 +- .../kotlin/starshipfights/game/pick_bounds.kt | 6 +- .../kotlin/starshipfights/game/ship.kt | 59 ++--- .../starshipfights/game/ship_factions.kt | 2 +- .../starshipfights/game/ship_instances.kt | 58 +++-- .../starshipfights/game/ship_modules.kt | 198 +++++++++++++++ .../kotlin/starshipfights/game/ship_types.kt | 3 + .../starshipfights/game/ship_weapons.kt | 190 +++++++++++---- .../game/ship_weapons_formats.kt | 2 +- .../kotlin/starshipfights/game/util.kt | 7 + .../kotlin/starshipfights/game/client_game.kt | 16 +- .../kotlin/starshipfights/game/game_render.kt | 2 +- .../starshipfights/game/game_resources.kt | 22 +- .../kotlin/starshipfights/game/game_ui.kt | 229 +++++++++++------- .../starshipfights/game/pick_bounds_js.kt | 4 + .../kotlin/starshipfights/game/popup.kt | 13 +- src/jsMain/resources/style.css | 26 +- .../resources/textures/friendly-marker.png | Bin 11430 -> 11059 bytes .../resources/textures/hostile-marker.png | Bin 10770 -> 11059 bytes .../kotlin/starshipfights/auth/providers.kt | 37 ++- .../kotlin/starshipfights/auth/utils.kt | 6 +- .../data/admiralty/ship_prices.kt | 11 +- .../starshipfights/data/auth/user_trophies.kt | 9 +- .../starshipfights/game/game_start_jvm.kt | 10 +- .../kotlin/starshipfights/info/html_utils.kt | 4 +- .../kotlin/starshipfights/info/view_tpl.kt | 2 +- .../kotlin/starshipfights/info/views_error.kt | 8 + .../kotlin/starshipfights/info/views_ships.kt | 6 +- .../kotlin/starshipfights/info/views_user.kt | 20 +- src/jvmMain/kotlin/starshipfights/server.kt | 4 +- .../kotlin/starshipfights/server_utils.kt | 9 +- src/jvmMain/resources/static/style.css | 10 +- 37 files changed, 965 insertions(+), 334 deletions(-) create mode 100644 src/commonMain/kotlin/starshipfights/game/ship_modules.kt diff --git a/src/commonMain/kotlin/starshipfights/game/admiralty.kt b/src/commonMain/kotlin/starshipfights/game/admiralty.kt index dbeb470..acdafea 100644 --- a/src/commonMain/kotlin/starshipfights/game/admiralty.kt +++ b/src/commonMain/kotlin/starshipfights/game/admiralty.kt @@ -13,14 +13,14 @@ enum class AdmiralRank { val maxShipWeightClass: ShipWeightClass get() = when (this) { REAR_ADMIRAL -> ShipWeightClass.CRUISER - VICE_ADMIRAL -> ShipWeightClass.CRUISER - ADMIRAL -> ShipWeightClass.BATTLECRUISER + VICE_ADMIRAL -> ShipWeightClass.BATTLECRUISER + ADMIRAL -> ShipWeightClass.BATTLESHIP HIGH_ADMIRAL -> ShipWeightClass.BATTLESHIP LORD_ADMIRAL -> ShipWeightClass.COLOSSUS } val maxBattleSize: BattleSize - get() = BattleSize.values().last { it.maxWeightClass <= maxShipWeightClass } + get() = BattleSize.values().last { it.maxWeightClass.rank <= maxShipWeightClass.rank } val minAcumen: Int get() = when (this) { diff --git a/src/commonMain/kotlin/starshipfights/game/game_ability.kt b/src/commonMain/kotlin/starshipfights/game/game_ability.kt index 4f499ba..5102bcc 100644 --- a/src/commonMain/kotlin/starshipfights/game/game_ability.kt +++ b/src/commonMain/kotlin/starshipfights/game/game_ability.kt @@ -21,13 +21,17 @@ sealed class PlayerAbilityType { data class DonePhase(val phase: GamePhase) : PlayerAbilityType() { override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalSide, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? { if (gameState.phase != phase) return null - return PlayerAbilityData.DonePhase + return if (gameState.canFinishPhase(playerSide)) + PlayerAbilityData.DonePhase + else null } override fun finishOnServer(gameState: GameState, playerSide: GlobalSide, data: PlayerAbilityData): GameEvent { - return if (phase == gameState.phase) - GameEvent.StateChange(gameState.afterPlayerReady(playerSide)) - else GameEvent.InvalidAction("Cannot complete non-current phase") + return if (phase == gameState.phase) { + if (gameState.canFinishPhase(playerSide)) + GameEvent.StateChange(gameState.afterPlayerReady(playerSide)) + else GameEvent.InvalidAction("You cannot complete the current phase yet") + } else GameEvent.InvalidAction("Cannot complete non-current phase") } } @@ -40,7 +44,7 @@ sealed class PlayerAbilityType { val playerStart = gameState.start.playerStart(playerSide) val shipData = playerStart.deployableFleet[ship] ?: return null - val pickType = PickType.Location(setOf(playerSide), PickHelper.Ship(shipData.shipType, playerStart.deployFacing)) + val pickType = PickType.Location(gameState.ships.keys, PickHelper.Ship(shipData.shipType, playerStart.deployFacing)) val pickResponse = pick(PickRequest(pickType, pickBoundary)) val shipPosition = (pickResponse as? PickResponse.Location)?.position ?: return null @@ -55,15 +59,14 @@ sealed class PlayerAbilityType { val position = data.position val pickRequest = PickRequest( - PickType.Location(setOf(GlobalSide.HOST, GlobalSide.GUEST), PickHelper.Ship(shipData.shipType, playerStart.deployFacing)), + PickType.Location(gameState.ships.keys, PickHelper.Ship(shipData.shipType, playerStart.deployFacing)), gameState.start.playerStart(playerSide).deployZone ) val pickResponse = PickResponse.Location(position) if (!gameState.isValidPick(pickRequest, pickResponse)) return GameEvent.InvalidAction("That ship cannot be deployed there") - val prevPosition = Distance(Vec2(-1000.0, 0.0) rotatedBy playerStart.deployFacing) + position - val shipPosition = ShipPosition(position, prevPosition, playerStart.deployFacing) + val shipPosition = ShipPosition(position, playerStart.deployFacing) val shipInstance = ShipInstance(shipData, playerSide, shipPosition) val newShipSet = gameState.ships + mapOf(shipInstance.id to shipInstance) @@ -144,7 +147,9 @@ sealed class PlayerAbilityType { isDoneCurrentPhase = true, weaponAmount = data.powerMode.weapons, - shieldAmount = (data.powerMode.shields - prevShieldDamage).coerceAtLeast(0), + shieldAmount = if (shipInstance.canUseShields) + (data.powerMode.shields - prevShieldDamage).coerceAtLeast(0) + else 0, ) val newShips = gameState.ships + mapOf(ship to newShipInstance) @@ -159,31 +164,34 @@ sealed class PlayerAbilityType { override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalSide, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? { if (gameState.phase !is GamePhase.Move) return null val shipInstance = gameState.ships[ship] ?: return null + if (shipInstance.isDoneCurrentPhase) return null val anglePickReq = PickRequest( - PickType.Location(emptySet(), PickHelper.None, shipInstance.position.currentLocation), - PickBoundary.Angle(shipInstance.position.currentLocation, shipInstance.position.facingAngle, shipInstance.movement.turnAngle) + PickType.Location(emptySet(), PickHelper.None, shipInstance.position.location), + PickBoundary.Angle(shipInstance.position.location, shipInstance.position.facing, shipInstance.movement.turnAngle) ) val anglePickRes = (pick(anglePickReq) as? PickResponse.Location) ?: return null - val facingTowards = (anglePickRes.position - shipInstance.position.currentLocation) - val newFacing = facingTowards.angle - val oldFacingNormal = normalDistance(shipInstance.position.facingAngle) - val moveAlong = (oldFacingNormal rotatedBy ((oldFacingNormal angleTo facingTowards) / 2)) * shipInstance.movement.moveSpeed + val newFacingNormal = (anglePickRes.position - shipInstance.position.location).normal + val newFacing = newFacingNormal.angle - val moveOrigin = shipInstance.position.currentLocation + shipInstance.position.currentVelocity - val moveFrom = moveOrigin - moveAlong - val moveTo = moveOrigin + moveAlong + val oldFacingNormal = normalDistance(shipInstance.position.facing) + val angleDiff = (oldFacingNormal angleBetween newFacingNormal) + val maxMoveSpeed = shipInstance.movement.moveSpeed + val minMoveSpeed = maxMoveSpeed * (angleDiff / shipInstance.movement.turnAngle) / 2 + + val moveOrigin = shipInstance.position.location + val moveFrom = moveOrigin + (newFacingNormal * minMoveSpeed) + val moveTo = moveOrigin + (newFacingNormal * maxMoveSpeed) val positionPickReq = PickRequest( - PickType.Location(GlobalSide.values().toSet(), PickHelper.Ship(shipInstance.ship.shipType, newFacing), null), + PickType.Location(gameState.ships.keys - ship, PickHelper.Ship(shipInstance.ship.shipType, newFacing), null), PickBoundary.AlongLine(moveFrom, moveTo) ) val positionPickRes = (pick(positionPickReq) as? PickResponse.Location) ?: return null val newPosition = ShipPosition( positionPickRes.position, - shipInstance.position.currentLocation, newFacing ) @@ -196,20 +204,20 @@ sealed class PlayerAbilityType { val shipInstance = gameState.ships[ship] ?: return GameEvent.InvalidAction("That ship does not exist") if (shipInstance.isDoneCurrentPhase) return GameEvent.InvalidAction("Ships cannot be moved twice") - if ((data.newPosition.previousLocation - shipInstance.position.currentLocation).length > EPSILON) return GameEvent.InvalidAction("Invalid ship position") - - val oldFacingNormal = normalDistance(shipInstance.position.facingAngle) - val newFacingNormal = normalDistance(data.newPosition.facingAngle) + val moveOrigin = shipInstance.position.location + val newFacingNormal = (data.newPosition.location - moveOrigin).normal + val oldFacingNormal = normalDistance(shipInstance.position.facing) + val angleDiff = (oldFacingNormal angleBetween newFacingNormal) - if (oldFacingNormal angleBetween newFacingNormal > shipInstance.movement.turnAngle) return GameEvent.InvalidAction("Excessive turn") + if (angleDiff - shipInstance.movement.turnAngle > EPSILON) return GameEvent.InvalidAction("Illegal move - turn angle is too big") - val moveAlong = (oldFacingNormal rotatedBy ((oldFacingNormal angleTo newFacingNormal) / 2)) * shipInstance.movement.moveSpeed + val maxMoveSpeed = shipInstance.movement.moveSpeed + val minMoveSpeed = maxMoveSpeed * (angleDiff / shipInstance.movement.turnAngle) / 2 - val moveOrigin = shipInstance.position.currentLocation + shipInstance.position.currentVelocity - val moveFrom = moveOrigin - moveAlong - val moveTo = moveOrigin + moveAlong + val moveFrom = moveOrigin + (newFacingNormal * minMoveSpeed) + val moveTo = moveOrigin + (newFacingNormal * maxMoveSpeed) - if (data.newPosition.currentLocation.distanceToLineSegment(moveFrom, moveTo) > EPSILON) return GameEvent.InvalidAction("Illegal move") + if (data.newPosition.location.distanceToLineSegment(moveFrom, moveTo) > EPSILON) return GameEvent.InvalidAction("Illegal move - must be on facing line") val newShipInstance = shipInstance.copy(position = data.newPosition, isDoneCurrentPhase = true) val newShips = gameState.ships + mapOf(ship to newShipInstance) @@ -263,9 +271,8 @@ sealed class PlayerAbilityType { override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalSide, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? { if (gameState.phase !is GamePhase.Attack) return null val shipInstance = gameState.ships[ship] ?: return null - if (weapon in shipInstance.usedArmaments) return null + if (!shipInstance.canUseWeapon(weapon)) return null val shipWeapon = shipInstance.armaments.weaponInstances[weapon] ?: return null - if (!canWeaponBeUsed(shipInstance, shipWeapon)) return null val pickResponse = pick(getWeaponPickRequest(shipWeapon.weapon, shipInstance.position, shipInstance.owner)) @@ -277,9 +284,8 @@ sealed class PlayerAbilityType { if (gameState.phase !is GamePhase.Attack) return GameEvent.InvalidAction("Ships can only attack during Phase III") val shipInstance = gameState.ships[ship] ?: return GameEvent.InvalidAction("That attacking ship does not exist") - if (weapon in shipInstance.usedArmaments) return GameEvent.InvalidAction("That weapon has already been used") + if (!shipInstance.canUseWeapon(weapon)) return GameEvent.InvalidAction("That weapon cannot be used") val shipWeapon = shipInstance.armaments.weaponInstances[weapon] ?: return GameEvent.InvalidAction("That weapon does not exist") - if (!canWeaponBeUsed(shipInstance, shipWeapon)) return GameEvent.InvalidAction("That weapon cannot be used at this time") val pickRequest = getWeaponPickRequest(shipWeapon.weapon, shipInstance.position, shipInstance.owner) val pickResponse = data.target @@ -311,6 +317,10 @@ sealed class PlayerAbilityType { val hangarWing = ShipHangarWing(ship, weapon) + val newShip = shipInstance.copy( + usedArmaments = shipInstance.usedArmaments - weapon + ) + return GameEvent.StateChange( gameState.copy( ships = gameState.ships.mapValues { (_, targetShip) -> @@ -318,7 +328,71 @@ sealed class PlayerAbilityType { fighterWings = targetShip.fighterWings - hangarWing, bomberWings = targetShip.bomberWings - hangarWing, ) - } + } + mapOf(ship to newShip) + ) + ) + } + } + + @Serializable + data class RepairShipModule(override val ship: Id, val module: ShipModule) : PlayerAbilityType(), ShipAbility { + override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalSide, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? { + if (gameState.phase !is GamePhase.Repair) return null + val shipInstance = gameState.ships[ship] ?: return null + if (shipInstance.remainingRepairTokens <= 0) return null + if (!shipInstance.modulesStatus[module].canBeRepaired) return null + + return PlayerAbilityData.RepairShipModule + } + + override fun finishOnServer(gameState: GameState, playerSide: GlobalSide, data: PlayerAbilityData): GameEvent { + if (gameState.phase !is GamePhase.Repair) return GameEvent.InvalidAction("Ships can only repair modules during Phase IV") + val shipInstance = gameState.ships[ship] ?: return GameEvent.InvalidAction("That ship does not exist") + if (shipInstance.remainingRepairTokens <= 0) return GameEvent.InvalidAction("That ship has no remaining repair tokens") + if (!shipInstance.modulesStatus[module].canBeRepaired) return GameEvent.InvalidAction("That module cannot be repaired") + + val newShip = shipInstance.copy( + modulesStatus = shipInstance.modulesStatus.repair(module), + usedRepairTokens = shipInstance.usedRepairTokens + 1 + ) + + return GameEvent.StateChange( + gameState.copy( + ships = gameState.ships + mapOf( + ship to newShip + ) + ) + ) + } + } + + @Serializable + data class ExtinguishFire(override val ship: Id) : PlayerAbilityType(), ShipAbility { + override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalSide, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? { + if (gameState.phase !is GamePhase.Repair) return null + val shipInstance = gameState.ships[ship] ?: return null + if (shipInstance.remainingRepairTokens <= 0) return null + if (shipInstance.numFires <= 0) return null + + return PlayerAbilityData.ExtinguishFire + } + + override fun finishOnServer(gameState: GameState, playerSide: GlobalSide, data: PlayerAbilityData): GameEvent { + if (gameState.phase !is GamePhase.Repair) return GameEvent.InvalidAction("Ships can only extinguish fires during Phase IV") + val shipInstance = gameState.ships[ship] ?: return GameEvent.InvalidAction("That ship does not exist") + if (shipInstance.remainingRepairTokens <= 0) return GameEvent.InvalidAction("That ship has no remaining repair tokens") + if (shipInstance.numFires <= 0) return GameEvent.InvalidAction("Cannot extinguish non-existent fires") + + val newShip = shipInstance.copy( + numFires = shipInstance.numFires - 1, + usedRepairTokens = shipInstance.usedRepairTokens + 1 + ) + + return GameEvent.StateChange( + gameState.copy( + ships = gameState.ships + mapOf( + ship to newShip + ) ) ) } @@ -350,6 +424,12 @@ sealed class PlayerAbilityData { @Serializable object RecallStrikeCraft : PlayerAbilityData() + + @Serializable + object RepairShipModule : PlayerAbilityData() + + @Serializable + object ExtinguishFire : PlayerAbilityData() } fun GameState.getPossibleAbilities(forPlayer: GlobalSide): List = if (ready == forPlayer) @@ -369,7 +449,7 @@ else when (phase) { .keys .map { PlayerAbilityType.UndeployShip(it) } - val finishDeploying = if (deployShips.isEmpty()) + val finishDeploying = if (canFinishPhase(forPlayer)) listOf(PlayerAbilityType.DonePhase(GamePhase.Deploy)) else emptyList() @@ -381,7 +461,9 @@ else when (phase) { .keys .map { PlayerAbilityType.DistributePower(it) } - val finishPowering = listOf(PlayerAbilityType.DonePhase(GamePhase.Power(phase.turn))) + val finishPowering = if (canFinishPhase(forPlayer)) + listOf(PlayerAbilityType.DonePhase(GamePhase.Power(phase.turn))) + else emptyList() powerableShips + finishPowering } @@ -391,7 +473,9 @@ else when (phase) { .keys .map { PlayerAbilityType.MoveShip(it) } - val finishMoving = listOf(PlayerAbilityType.DonePhase(GamePhase.Move(phase.turn))) + val finishMoving = if (canFinishPhase(forPlayer)) + listOf(PlayerAbilityType.DonePhase(GamePhase.Move(phase.turn))) + else emptyList() movableShips + finishMoving } @@ -402,7 +486,7 @@ else when (phase) { ship.armaments.weaponInstances.mapNotNull { (weaponId, weapon) -> PlayerAbilityType.ChargeLance(id, weaponId).takeIf { when (weapon) { - is ShipWeaponInstance.Lance -> weapon.charge != 1.0 && weaponId !in ship.usedArmaments + is ShipWeaponInstance.Lance -> weapon.numCharges < 7 && weaponId !in ship.usedArmaments else -> false } } @@ -412,9 +496,9 @@ else when (phase) { val usableWeapons = ships .filterValues { it.owner == forPlayer } .flatMap { (id, ship) -> - ship.armaments.weaponInstances.mapNotNull { (weaponId, weapon) -> + ship.armaments.weaponInstances.keys.mapNotNull { weaponId -> PlayerAbilityType.UseWeapon(id, weaponId).takeIf { - weaponId !in ship.usedArmaments && canWeaponBeUsed(ship, weapon) + weaponId !in ship.usedArmaments && ship.canUseWeapon(weaponId) } } } @@ -429,10 +513,36 @@ else when (phase) { } } - val finishAttacking = listOf(PlayerAbilityType.DonePhase(GamePhase.Attack(phase.turn))) + val finishAttacking = if (canFinishPhase(forPlayer)) + listOf(PlayerAbilityType.DonePhase(GamePhase.Attack(phase.turn))) + else emptyList() chargeableLances + usableWeapons + recallableStrikeWings + finishAttacking } + is GamePhase.Repair -> { + val repairableModules = ships + .filterValues { it.owner == forPlayer } + .flatMap { (id, ship) -> + ship.modulesStatus.statuses.filterValues { it.canBeRepaired }.keys.map { module -> + PlayerAbilityType.RepairShipModule(id, module) + } + } + + val extinguishableFires = ships + .filterValues { it.owner == forPlayer } + .mapNotNull { (id, ship) -> + if (ship.numFires <= 0) + null + else + PlayerAbilityType.ExtinguishFire(id) + } + + val finishRepairing = if (canFinishPhase(forPlayer)) + listOf(PlayerAbilityType.DonePhase(GamePhase.Repair(phase.turn))) + else emptyList() + + repairableModules + extinguishableFires + finishRepairing + } } object ClientAbilityData { diff --git a/src/commonMain/kotlin/starshipfights/game/game_chat.kt b/src/commonMain/kotlin/starshipfights/game/game_chat.kt index 42c575a..e47d665 100644 --- a/src/commonMain/kotlin/starshipfights/game/game_chat.kt +++ b/src/commonMain/kotlin/starshipfights/game/game_chat.kt @@ -33,6 +33,7 @@ sealed class ChatEntry { override val sentAt: Moment, val damageInflicted: Int, val weapon: ShipWeapon?, + val critical: ShipCritical?, ) : ChatEntry() @Serializable @@ -50,4 +51,27 @@ sealed class ShipAttacker { @Serializable object Bombers : ShipAttacker() + + @Serializable + object Fire : ShipAttacker() +} + +@Serializable +sealed class ShipCritical { + @Serializable + object ExtraDamage : ShipCritical() + + @Serializable + object Fire : ShipCritical() + + @Serializable + data class ModulesHit(val module: Set) : ShipCritical() +} + +fun CritResult.report(): ShipCritical? = when (this) { + CritResult.NoEffect -> null + is CritResult.FireStarted -> ShipCritical.Fire + is CritResult.ModulesDisabled -> ShipCritical.ModulesHit(modules) + is CritResult.HullDamaged -> ShipCritical.ExtraDamage + is CritResult.Destroyed -> null } diff --git a/src/commonMain/kotlin/starshipfights/game/game_phase.kt b/src/commonMain/kotlin/starshipfights/game/game_phase.kt index 43d715a..8c47bdd 100644 --- a/src/commonMain/kotlin/starshipfights/game/game_phase.kt +++ b/src/commonMain/kotlin/starshipfights/game/game_phase.kt @@ -27,6 +27,11 @@ sealed class GamePhase { @Serializable data class Attack(override val turn: Int) : GamePhase() { + override fun next() = Repair(turn) + } + + @Serializable + data class Repair(override val turn: Int) : GamePhase() { override fun next() = Power(turn + 1) } } diff --git a/src/commonMain/kotlin/starshipfights/game/game_state.kt b/src/commonMain/kotlin/starshipfights/game/game_state.kt index fb1855e..555dfaa 100644 --- a/src/commonMain/kotlin/starshipfights/game/game_state.kt +++ b/src/commonMain/kotlin/starshipfights/game/game_state.kt @@ -4,6 +4,7 @@ import kotlinx.serialization.Serializable import starshipfights.data.Id import kotlin.math.abs import kotlin.random.Random +import kotlin.random.nextInt @Serializable data class GameState( @@ -25,6 +26,20 @@ data class GameState( fun getShipOwner(id: Id) = destroyedShips[id]?.owner ?: ships.getValue(id).owner } +fun GameState.canFinishPhase(side: GlobalSide): Boolean { + return when (phase) { + GamePhase.Deploy -> { + val usedPoints = ships.values + .filter { it.owner == side } + .sumOf { it.ship.pointCost } + + start.playerStart(side).deployableFleet.values.none { usedPoints + it.pointCost <= battleInfo.size.numPoints } + } + is GamePhase.Move -> ships.values.filter { it.owner == side }.all { it.isDoneCurrentPhase } + else -> true + } +} + private fun GameState.afterPhase(): GameState { var newShips = ships val newWrecks = destroyedShips.toMutableMap() @@ -32,15 +47,9 @@ private fun GameState.afterPhase(): GameState { when (phase) { is GamePhase.Move -> { - // Auto-move drifting ships - newShips = newShips.mapValues { (_, ship) -> - if (ship.isDoneCurrentPhase) ship - else ship.copy(position = ship.position.drift) - } - // Ships that move off the battlefield are considered to disengage newShips = newShips.mapNotNull fleeingShips@{ (id, ship) -> - val r = ship.position.currentLocation.vector + val r = ship.position.location.vector val mx = start.battlefieldWidth / 2 val my = start.battlefieldLength / 2 @@ -56,7 +65,7 @@ private fun GameState.afterPhase(): GameState { // Identify enemy ships newShips = newShips.mapValues { (_, ship) -> if (ship.isIdentified) ship - else if (newShips.values.any { it.owner != ship.owner && (it.position.currentLocation - ship.position.currentLocation).length <= SHIP_SENSOR_RANGE }) + else if (newShips.values.any { it.owner != ship.owner && (it.position.location - ship.position.location).length <= SHIP_SENSOR_RANGE }) ship.copy(isIdentified = true).also { newChatEntries += ChatEntry.ShipIdentified(it.id, Moment.now) } @@ -73,7 +82,7 @@ private fun GameState.afterPhase(): GameState { val totalFighterHealth = ship.fighterWings.sumOf { (carrierId, wingId) -> (newShips[carrierId]?.armaments?.weaponInstances?.get(wingId) as? ShipWeaponInstance.Hangar)?.wingHealth ?: 0.0 - } + ship.ship.durability.turretDefense + } + (if (ship.canUseTurrets) ship.ship.durability.turretDefense else 0.0) val totalBomberHealth = ship.bomberWings.sumOf { (carrierId, wingId) -> (newShips[carrierId]?.armaments?.weaponInstances?.get(wingId) as? ShipWeaponInstance.Hangar)?.wingHealth ?: 0.0 @@ -96,7 +105,14 @@ private fun GameState.afterPhase(): GameState { when (val impactResult = ship.impact(hits)) { is ImpactResult.Damaged -> { - newChatEntries += ChatEntry.ShipAttacked(id, ShipAttacker.Bombers, Moment.now, hits, null) + newChatEntries += ChatEntry.ShipAttacked( + ship = id, + attacker = ShipAttacker.Bombers, + sentAt = Moment.now, + damageInflicted = hits, + weapon = null, + critical = null + ) id to impactResult.ship } is ImpactResult.Destroyed -> { @@ -120,11 +136,10 @@ private fun GameState.afterPhase(): GameState { ) } - // Recall strike craft and regenerate weapon and shield powers + // Recall strike craft and regenerate weapon power newShips = newShips.mapValues { (_, ship) -> ship.copy( weaponAmount = ship.powerMode.weapons, - shieldAmount = (ship.shieldAmount..ship.powerMode.shields).random(), fighterWings = emptySet(), bomberWings = emptySet(), @@ -132,6 +147,42 @@ private fun GameState.afterPhase(): GameState { ) } } + is GamePhase.Repair -> { + // Deal fire damage + newShips = newShips.mapNotNull fireDamage@{ (id, ship) -> + if (ship.numFires <= 0) + return@fireDamage id to ship + + val hits = Random.nextInt(0..ship.numFires) + + when (val impactResult = ship.impact(hits)) { + is ImpactResult.Damaged -> { + newChatEntries += ChatEntry.ShipAttacked( + ship = id, + attacker = ShipAttacker.Fire, + sentAt = Moment.now, + damageInflicted = hits, + weapon = null, + critical = null + ) + id to impactResult.ship + } + is ImpactResult.Destroyed -> { + newWrecks[id] = impactResult.ship + newChatEntries += ChatEntry.ShipDestroyed(id, Moment.now, ShipAttacker.Fire) + null + } + } + }.toMap() + + // Replenish repair tokens and regenerate shield power + newShips = newShips.mapValues { (_, ship) -> + ship.copy( + shieldAmount = if (ship.canUseShields) (ship.shieldAmount..ship.powerMode.shields).random() else 0, + usedRepairTokens = 0 + ) + } + } else -> { // do nothing } @@ -183,20 +234,20 @@ enum class GlobalSide { } } -fun GlobalSide.relativeTo(me: GlobalSide) = if (this == me) LocalSide.BLUE else LocalSide.RED +fun GlobalSide.relativeTo(me: GlobalSide) = if (this == me) LocalSide.GREEN else LocalSide.RED enum class LocalSide { - BLUE, RED; + GREEN, RED; val other: LocalSide get() = when (this) { - BLUE -> RED - RED -> BLUE + GREEN -> RED + RED -> GREEN } } val LocalSide.htmlColor: String get() = when (this) { - LocalSide.BLUE -> "#3399FF" - LocalSide.RED -> "#FF6666" + LocalSide.GREEN -> "#55FF55" + LocalSide.RED -> "#FF5555" } diff --git a/src/commonMain/kotlin/starshipfights/game/matchmaking.kt b/src/commonMain/kotlin/starshipfights/game/matchmaking.kt index bc00efb..ea73836 100644 --- a/src/commonMain/kotlin/starshipfights/game/matchmaking.kt +++ b/src/commonMain/kotlin/starshipfights/game/matchmaking.kt @@ -4,11 +4,11 @@ import kotlinx.serialization.Serializable import starshipfights.data.Id enum class BattleSize(val numPoints: Int, val maxWeightClass: ShipWeightClass, val displayName: String) { - SKIRMISH(500, ShipWeightClass.CRUISER, "Skirmish"), - FIREFIGHT(700, ShipWeightClass.BATTLECRUISER, "Firefight"), - BATTLE(900, ShipWeightClass.BATTLESHIP, "Battle"), - GRAND_CLASH(1200, ShipWeightClass.BATTLESHIP, "Grand Clash"), - LEGENDARY_STRUGGLE(1500, ShipWeightClass.BATTLESHIP, "Legendary Struggle"), + SKIRMISH(600, ShipWeightClass.CRUISER, "Skirmish"), + FIREFIGHT(800, ShipWeightClass.CRUISER, "Firefight"), + BATTLE(1000, ShipWeightClass.BATTLECRUISER, "Battle"), + GRAND_CLASH(1300, ShipWeightClass.BATTLESHIP, "Grand Clash"), + LEGENDARY_STRUGGLE(1600, ShipWeightClass.BATTLESHIP, "Legendary Struggle"), CRUCIBLE_OF_HISTORY(2000, ShipWeightClass.COLOSSUS, "Crucible of History"); } diff --git a/src/commonMain/kotlin/starshipfights/game/pick_bounds.kt b/src/commonMain/kotlin/starshipfights/game/pick_bounds.kt index 4181350..fe6354a 100644 --- a/src/commonMain/kotlin/starshipfights/game/pick_bounds.kt +++ b/src/commonMain/kotlin/starshipfights/game/pick_bounds.kt @@ -15,7 +15,7 @@ fun GameState.isValidPick(request: PickRequest, response: PickResponse): Boolean if (response.position !in request.boundary) return false if (ships.values.any { - it.owner in request.type.excludesNearShips && (it.position.currentLocation - response.position).length <= SHIP_BASE_SIZE + it.id in request.type.excludesNearShips && (it.position.location - response.position).length <= SHIP_BASE_SIZE }) return false return true @@ -26,7 +26,7 @@ fun GameState.isValidPick(request: PickRequest, response: PickResponse): Boolean if (response.id !in ships) return false val ship = ships.getValue(response.id) - if (ship.position.currentLocation !in request.boundary) return false + if (ship.position.location !in request.boundary) return false if (ship.owner !in request.type.allowSides) return false return true @@ -49,7 +49,7 @@ sealed class PickResponse { @Serializable sealed class PickType { @Serializable - data class Location(val excludesNearShips: Set, val helper: PickHelper, val drawLineFrom: Position? = null) : PickType() + data class Location(val excludesNearShips: Set>, val helper: PickHelper, val drawLineFrom: Position? = null) : PickType() @Serializable data class Ship(val allowSides: Set) : PickType() diff --git a/src/commonMain/kotlin/starshipfights/game/ship.kt b/src/commonMain/kotlin/starshipfights/game/ship.kt index c411d92..4d6ecac 100644 --- a/src/commonMain/kotlin/starshipfights/game/ship.kt +++ b/src/commonMain/kotlin/starshipfights/game/ship.kt @@ -15,7 +15,7 @@ data class Ship( get() = "${shipType.faction.shipPrefix}$name" val pointCost: Int - get() = shipType.weightClass.basePointCost + get() = shipType.pointCost val reactor: ShipReactor get() = shipType.weightClass.reactor @@ -68,48 +68,49 @@ data class ShipMovement( val ShipWeightClass.movement: ShipMovement get() = when (this) { - ShipWeightClass.ESCORT -> ShipMovement(PI / 2, 800.0) - ShipWeightClass.DESTROYER -> ShipMovement(PI / 2, 700.0) - ShipWeightClass.CRUISER -> ShipMovement(PI / 3, 600.0) - ShipWeightClass.BATTLECRUISER -> ShipMovement(PI / 3, 600.0) - ShipWeightClass.BATTLESHIP -> ShipMovement(PI / 4, 500.0) + ShipWeightClass.ESCORT -> ShipMovement(PI / 2, 2500.0) + ShipWeightClass.DESTROYER -> ShipMovement(PI / 2, 2200.0) + ShipWeightClass.CRUISER -> ShipMovement(PI / 3, 1900.0) + ShipWeightClass.BATTLECRUISER -> ShipMovement(PI / 3, 1900.0) + ShipWeightClass.BATTLESHIP -> ShipMovement(PI / 4, 1600.0) - ShipWeightClass.GRAND_CRUISER -> ShipMovement(PI / 4, 600.0) - ShipWeightClass.COLOSSUS -> ShipMovement(PI / 6, 400.0) + ShipWeightClass.GRAND_CRUISER -> ShipMovement(PI / 4, 1750.0) + ShipWeightClass.COLOSSUS -> ShipMovement(PI / 6, 2800.0) - ShipWeightClass.AUXILIARY_SHIP -> ShipMovement(PI / 2, 800.0) - ShipWeightClass.LIGHT_CRUISER -> ShipMovement(PI / 2, 700.0) - ShipWeightClass.MEDIUM_CRUISER -> ShipMovement(PI / 3, 600.0) - ShipWeightClass.HEAVY_CRUISER -> ShipMovement(PI / 3, 500.0) + ShipWeightClass.AUXILIARY_SHIP -> ShipMovement(PI / 2, 2500.0) + ShipWeightClass.LIGHT_CRUISER -> ShipMovement(PI / 2, 2250.0) + ShipWeightClass.MEDIUM_CRUISER -> ShipMovement(PI / 3, 2000.0) + ShipWeightClass.HEAVY_CRUISER -> ShipMovement(PI / 3, 1750.0) - ShipWeightClass.FRIGATE -> ShipMovement(PI * 2 / 3, 1000.0) - ShipWeightClass.LINE_SHIP -> ShipMovement(PI / 2, 800.0) - ShipWeightClass.DREADNOUGHT -> ShipMovement(PI / 3, 600.0) + ShipWeightClass.FRIGATE -> ShipMovement(PI * 2 / 3, 2750.0) + ShipWeightClass.LINE_SHIP -> ShipMovement(PI / 2, 2250.0) + ShipWeightClass.DREADNOUGHT -> ShipMovement(PI / 3, 1750.0) } @Serializable data class ShipDurability( val maxHullPoints: Int, val turretDefense: Double, + val repairTokens: Int, ) val ShipWeightClass.durability: ShipDurability get() = when (this) { - ShipWeightClass.ESCORT -> ShipDurability(2, 0.5) - ShipWeightClass.DESTROYER -> ShipDurability(4, 0.5) - ShipWeightClass.CRUISER -> ShipDurability(6, 1.0) - ShipWeightClass.BATTLECRUISER -> ShipDurability(7, 1.0) - ShipWeightClass.BATTLESHIP -> ShipDurability(9, 2.0) + ShipWeightClass.ESCORT -> ShipDurability(4, 0.5, 1) + ShipWeightClass.DESTROYER -> ShipDurability(8, 0.5, 2) + ShipWeightClass.CRUISER -> ShipDurability(12, 1.0, 3) + ShipWeightClass.BATTLECRUISER -> ShipDurability(14, 1.0, 3) + ShipWeightClass.BATTLESHIP -> ShipDurability(18, 2.0, 4) - ShipWeightClass.GRAND_CRUISER -> ShipDurability(8, 1.5) - ShipWeightClass.COLOSSUS -> ShipDurability(13, 3.0) + ShipWeightClass.GRAND_CRUISER -> ShipDurability(16, 1.5, 3) + ShipWeightClass.COLOSSUS -> ShipDurability(27, 3.0, 5) - ShipWeightClass.AUXILIARY_SHIP -> ShipDurability(2, 2.0) - ShipWeightClass.LIGHT_CRUISER -> ShipDurability(4, 3.0) - ShipWeightClass.MEDIUM_CRUISER -> ShipDurability(6, 3.5) - ShipWeightClass.HEAVY_CRUISER -> ShipDurability(8, 4.0) + ShipWeightClass.AUXILIARY_SHIP -> ShipDurability(4, 2.0, 1) + ShipWeightClass.LIGHT_CRUISER -> ShipDurability(8, 3.0, 2) + ShipWeightClass.MEDIUM_CRUISER -> ShipDurability(12, 3.5, 3) + ShipWeightClass.HEAVY_CRUISER -> ShipDurability(16, 4.0, 4) - ShipWeightClass.FRIGATE -> ShipDurability(4, 1.5) - ShipWeightClass.LINE_SHIP -> ShipDurability(7, 2.0) - ShipWeightClass.DREADNOUGHT -> ShipDurability(10, 2.5) + ShipWeightClass.FRIGATE -> ShipDurability(10, 1.5, 1) + ShipWeightClass.LINE_SHIP -> ShipDurability(15, 2.0, 1) + ShipWeightClass.DREADNOUGHT -> ShipDurability(20, 2.5, 1) } diff --git a/src/commonMain/kotlin/starshipfights/game/ship_factions.kt b/src/commonMain/kotlin/starshipfights/game/ship_factions.kt index 45e01d5..eafdf5e 100644 --- a/src/commonMain/kotlin/starshipfights/game/ship_factions.kt +++ b/src/commonMain/kotlin/starshipfights/game/ship_factions.kt @@ -27,7 +27,7 @@ enum class Faction( +"Having spent much of its history coming under threat from oppressive theocracies, conquering hordes, rebelling sectors, and invading syndicalists, the Empire of Mechyrdia now enjoys a place in the stars as the foremost power of the galaxy." } p { - +"Don't be confused by the name \"Empire\", Mechyrdia is a free and liberal democratic republic. While they once had an emperor, Nicólei the First and Only, he declared that the people of Mechyrdia would inherit the throne, thus abolishing the monarchy. Now the Empire runs on a semi-presidential democracy; the government does not have any office named \"President\", rather there is a Chancellor, the head of state who is elected by the people, and a Prime Minister, the head of government who is appointed by the Chancellor and confirmed by the tricameral Senate. " + +"Don't be confused by the name \"Empire\", Mechyrdia is a free and liberal democratic republic. While they once had an emperor, Nicólei the First and Only, he declared that the people of Mechyrdia would inherit the throne, thus abolishing the monarchy upon his death. Now the Empire runs on a semi-presidential democracy; the government does not have any office named \"President\", rather there is a Chancellor, the head of state who is elected by the people, and a Prime Minister, the head of government who is appointed by the Chancellor and confirmed by the tricameral Senate. " } p { +"But things are not so ideal for Mechyrdia. The western menace, the Diadochus Masra Draetsen, threatens to upend this peaceful order and conquer Mechyrdia, to succeed where their predecessors the Arkant Horde had failed. Their new leader, Ogus Khan, has made many connections with the disgraced nations of the galaxy, and will stop at nothing to see Mechyrdia fall. Isarnareykk is making waves in its neighboring states of Theudareykk and Stahlareykk, states that are now within Mechyrdia's sphere of influence. Vestigium forces are being spotted in deep space throughout the Empire, and the Corvus Cluster sect has ended its radio silence." diff --git a/src/commonMain/kotlin/starshipfights/game/ship_instances.kt b/src/commonMain/kotlin/starshipfights/game/ship_instances.kt index a337090..c2ef064 100644 --- a/src/commonMain/kotlin/starshipfights/game/ship_instances.kt +++ b/src/commonMain/kotlin/starshipfights/game/ship_instances.kt @@ -21,12 +21,45 @@ data class ShipInstance( val shieldAmount: Int = powerMode.shields, val hullAmount: Int = ship.durability.maxHullPoints, + val modulesStatus: ShipModulesStatus = ShipModulesStatus.forShip(ship), + val numFires: Int = 0, + val usedRepairTokens: Int = 0, + val armaments: ShipInstanceArmaments = ship.armaments.instantiate(), val usedArmaments: Set> = emptySet(), val fighterWings: Set = emptySet(), val bomberWings: Set = emptySet(), ) { + val canUseShields: Boolean + get() = modulesStatus[ShipModule.Shields].canBeUsed + + val canUseTurrets: Boolean + get() = modulesStatus[ShipModule.Turrets].canBeUsed + + fun canUseWeapon(weaponId: Id): Boolean { + if (weaponId in usedArmaments) + return false + + if (!modulesStatus[ShipModule.Weapon(weaponId)].canBeUsed) + return false + + val weapon = armaments.weaponInstances[weaponId] ?: return false + + return when (weapon) { + is ShipWeaponInstance.Cannon -> weaponAmount > 0 + is ShipWeaponInstance.Hangar -> weapon.wingHealth > 0.0 + is ShipWeaponInstance.Lance -> weapon.numCharges > 0 + is ShipWeaponInstance.Torpedo -> true + is ShipWeaponInstance.MegaCannon -> weapon.remainingShots > 0 + is ShipWeaponInstance.RevelationGun -> weapon.remainingShots > 0 + is ShipWeaponInstance.EmpAntenna -> weapon.remainingShots > 0 + } + } + + val remainingRepairTokens: Int + get() = ship.durability.repairTokens - usedRepairTokens + val id: Id get() = ship.id.reinterpret() } @@ -43,19 +76,9 @@ data class ShipWreck( @Serializable data class ShipPosition( - val currentLocation: Position, - val previousLocation: Position, - val facingAngle: Double -) { - val currentVelocity: Distance - get() = currentLocation - previousLocation - - val drift: ShipPosition - get() = copy( - currentLocation = currentLocation + currentVelocity, - previousLocation = currentLocation - ) -} + val location: Position, + val facing: Double +) enum class ShipSubsystem { WEAPONS, SHIELDS, ENGINES; @@ -108,9 +131,16 @@ data class ShipPowerMode( fun ShipInstance.remainingGridEfficiency(newPowerMode: ShipPowerMode) = (ship.reactor.gridEfficiency * 2 - (newPowerMode distanceTo powerMode)) / 2 fun ShipInstance.validatePowerMode(newPowerMode: ShipPowerMode) = newPowerMode.total == ship.reactor.powerOutput && ShipSubsystem.values().none { newPowerMode[it] < 0 } && (newPowerMode distanceTo powerMode) <= ship.reactor.gridEfficiency * 2 +val ShipInstance.movementCoefficient: Double + get() = sqrt(powerMode.engines.toDouble() / ship.reactor.subsystemAmount) * + if (modulesStatus[ShipModule.Engines].canBeUsed) + 1.0 + else + 0.5 + val ShipInstance.movement: ShipMovement get() { - val coefficient = sqrt(powerMode.engines.toDouble() / ship.reactor.subsystemAmount) + val coefficient = movementCoefficient return with(ship.movement) { copy(turnAngle = turnAngle * coefficient, moveSpeed = moveSpeed * coefficient) } diff --git a/src/commonMain/kotlin/starshipfights/game/ship_modules.kt b/src/commonMain/kotlin/starshipfights/game/ship_modules.kt new file mode 100644 index 0000000..2d03f11 --- /dev/null +++ b/src/commonMain/kotlin/starshipfights/game/ship_modules.kt @@ -0,0 +1,198 @@ +package starshipfights.game + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.builtins.PairSerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import starshipfights.data.Id +import kotlin.random.Random +import kotlin.random.nextInt + +@Serializable +sealed class ShipModule { + abstract fun getDisplayName(ship: Ship): String + + @Serializable + data class Weapon(val weaponId: Id) : ShipModule() { + override fun getDisplayName(ship: Ship): String { + return ship.armaments.weapons[weaponId]?.displayName ?: "" + } + } + + @Serializable + object Shields : ShipModule() { + override fun getDisplayName(ship: Ship): String { + return "Shield Generators" + } + } + + @Serializable + object Engines : ShipModule() { + override fun getDisplayName(ship: Ship): String { + return "Mach-Effect Thrusters" + } + } + + @Serializable + object Turrets : ShipModule() { + override fun getDisplayName(ship: Ship): String { + return "Point Defense Turrets" + } + } +} + +@Serializable +enum class ShipModuleStatus(val canBeUsed: Boolean, val canBeRepaired: Boolean) { + INTACT(true, false), + DAMAGED(false, true), + DESTROYED(false, false) +} + +@Serializable(with = ShipModulesStatusSerializer::class) +data class ShipModulesStatus(val statuses: Map) { + operator fun get(module: ShipModule) = statuses[module] ?: ShipModuleStatus.INTACT + + fun repair(module: ShipModule) = ShipModulesStatus( + statuses + if (this[module].canBeRepaired) + mapOf(module to ShipModuleStatus.INTACT) + else emptyMap() + ) + + fun damage(module: ShipModule) = ShipModulesStatus( + statuses + mapOf( + module to when (this[module]) { + ShipModuleStatus.INTACT -> ShipModuleStatus.DAMAGED + ShipModuleStatus.DAMAGED -> ShipModuleStatus.DESTROYED + ShipModuleStatus.DESTROYED -> ShipModuleStatus.DESTROYED + } + ) + ) + + fun damageMany(modules: Iterable) = ShipModulesStatus( + statuses + modules.associateWith { module -> + when (this[module]) { + ShipModuleStatus.INTACT -> ShipModuleStatus.DAMAGED + ShipModuleStatus.DAMAGED -> ShipModuleStatus.DESTROYED + ShipModuleStatus.DESTROYED -> ShipModuleStatus.DESTROYED + } + } + ) + + companion object { + fun forShip(ship: Ship) = ShipModulesStatus( + mapOf( + ShipModule.Shields to ShipModuleStatus.INTACT, + ShipModule.Engines to ShipModuleStatus.INTACT, + ShipModule.Turrets to ShipModuleStatus.INTACT, + ) + ship.armaments.weapons.keys.associate { + ShipModule.Weapon(it) to ShipModuleStatus.INTACT + } + ) + } +} + +object ShipModulesStatusSerializer : KSerializer { + private val inner = ListSerializer(PairSerializer(ShipModule.serializer(), ShipModuleStatus.serializer())) + + override val descriptor: SerialDescriptor + get() = inner.descriptor + + override fun serialize(encoder: Encoder, value: ShipModulesStatus) { + inner.serialize(encoder, value.statuses.toList()) + } + + override fun deserialize(decoder: Decoder): ShipModulesStatus { + return ShipModulesStatus(inner.deserialize(decoder).toMap()) + } +} + +sealed class CritResult { + object NoEffect : CritResult() + data class FireStarted(val ship: ShipInstance) : CritResult() + data class ModulesDisabled(val ship: ShipInstance, val modules: Set) : CritResult() + data class HullDamaged(val ship: ShipInstance, val amount: Int) : CritResult() + data class Destroyed(val ship: ShipWreck) : CritResult() + + companion object { + fun fromImpactResult(impactResult: ImpactResult) = when (impactResult) { + is ImpactResult.Damaged -> impactResult.amount?.let { HullDamaged(impactResult.ship, it) } ?: NoEffect + is ImpactResult.Destroyed -> Destroyed(impactResult.ship) + } + } +} + +fun ShipInstance.doCriticalDamage(): CritResult { + return when (Random.nextInt(0..4) + Random.nextInt(0..4)) { // Ranges in 0..8, probability density peaks at 4 + 0 -> { + // Damage 3 weapons + val modulesDamaged = armaments.weaponInstances.keys.shuffled().take(3).map { ShipModule.Weapon(it) } + CritResult.ModulesDisabled( + copy(modulesStatus = modulesStatus.damageMany(modulesDamaged)), + modulesDamaged.toSet() + ) + } + 1 -> { + // Damage 1 weapon + val modulesDamaged = armaments.weaponInstances.keys.shuffled().take(1).map { ShipModule.Weapon(it) } + CritResult.ModulesDisabled( + copy(modulesStatus = modulesStatus.damageMany(modulesDamaged)), + modulesDamaged.toSet() + ) + } + 2 -> { + // Damage 2 weapons + val modulesDamaged = armaments.weaponInstances.keys.shuffled().take(2).map { ShipModule.Weapon(it) } + CritResult.ModulesDisabled( + copy(modulesStatus = modulesStatus.damageMany(modulesDamaged)), + modulesDamaged.toSet() + ) + } + 3 -> { + // Damage engines + val moduleDamaged = ShipModule.Engines + CritResult.ModulesDisabled( + copy(modulesStatus = modulesStatus.damage(moduleDamaged)), + setOf(moduleDamaged) + ) + } + 4 -> { + // Fire! + CritResult.FireStarted( + copy(numFires = numFires + 1) + ) + } + 5 -> { + // Damage turrets + val moduleDamaged = ShipModule.Turrets + CritResult.ModulesDisabled( + copy(modulesStatus = modulesStatus.damage(moduleDamaged)), + setOf(moduleDamaged) + ) + } + 6 -> { + // Damage shields + val moduleDamaged = ShipModule.Shields + CritResult.ModulesDisabled( + copy( + shieldAmount = 0, + modulesStatus = modulesStatus.damage(moduleDamaged) + ), + setOf(moduleDamaged) + ) + } + 7 -> { + // Hull breach + val damage = Random.nextInt(0, 2) + Random.nextInt(0, 2) + CritResult.fromImpactResult(impact(damage)) + } + 8 -> { + // Bulkhead collapse + val damage = Random.nextInt(0, 5) + Random.nextInt(0, 5) + CritResult.fromImpactResult(impact(damage)) + } + else -> CritResult.NoEffect + } +} diff --git a/src/commonMain/kotlin/starshipfights/game/ship_types.kt b/src/commonMain/kotlin/starshipfights/game/ship_types.kt index 2b4d7dd..1bc6a95 100644 --- a/src/commonMain/kotlin/starshipfights/game/ship_types.kt +++ b/src/commonMain/kotlin/starshipfights/game/ship_types.kt @@ -150,5 +150,8 @@ enum class ShipType( get() = "$displayName-class ${faction.demonymSingular} ${weightClass.displayName}" } +val ShipType.pointCost: Int + get() = weightClass.basePointCost + armaments.weapons.values.sumOf { it.addsPointCost } + val ShipType.meshName: String get() = "${faction.meshTag}-${weightClass.meshIndex}-${toUrlSlug()}-class" diff --git a/src/commonMain/kotlin/starshipfights/game/ship_weapons.kt b/src/commonMain/kotlin/starshipfights/game/ship_weapons.kt index e4158e7..ffb6328 100644 --- a/src/commonMain/kotlin/starshipfights/game/ship_weapons.kt +++ b/src/commonMain/kotlin/starshipfights/game/ship_weapons.kt @@ -2,9 +2,7 @@ package starshipfights.game import kotlinx.serialization.Serializable import starshipfights.data.Id -import kotlin.math.abs import kotlin.math.expm1 -import kotlin.math.sqrt import kotlin.random.Random enum class FiringArc { @@ -34,14 +32,16 @@ sealed interface AreaWeapon { @Serializable sealed class ShipWeapon { abstract val numShots: Int + open val minRange: Double get() = SHIP_BASE_SIZE abstract val maxRange: Double abstract val firingArcs: Set - open val isNormal: Boolean - get() = true + abstract val groupLabel: String + abstract val addsPointCost: Int + abstract fun instantiate(): ShipWeaponInstance @Serializable @@ -53,6 +53,9 @@ sealed class ShipWeapon { override val maxRange: Double get() = SHIP_CANNON_RANGE + override val addsPointCost: Int + get() = numShots * 5 + override fun instantiate() = ShipWeaponInstance.Cannon(this) } @@ -65,6 +68,9 @@ sealed class ShipWeapon { override val maxRange: Double get() = SHIP_LANCE_RANGE + override val addsPointCost: Int + get() = numShots * 10 + override fun instantiate() = ShipWeaponInstance.Lance(this, 10) } @@ -79,6 +85,9 @@ sealed class ShipWeapon { override val maxRange: Double get() = SHIP_TORPEDO_RANGE + override val addsPointCost: Int + get() = 5 + override fun instantiate() = ShipWeaponInstance.Torpedo(this) } @@ -96,6 +105,12 @@ sealed class ShipWeapon { override val firingArcs: Set get() = FiringArc.FIRE_360 + override val addsPointCost: Int + get() = when (wing) { + StrikeCraftWing.FIGHTERS -> 5 + StrikeCraftWing.BOMBERS -> 10 + } + override fun instantiate() = ShipWeaponInstance.Hangar(this, 1.0) } @@ -118,12 +133,12 @@ sealed class ShipWeapon { override val firingArcs: Set get() = setOf(FiringArc.BOW) - override val isNormal: Boolean - get() = false - override val groupLabel: String get() = "Mega Giga Cannon" + override val addsPointCost: Int + get() = 50 + override fun instantiate() = ShipWeaponInstance.MegaCannon(numShots) } @@ -144,12 +159,12 @@ sealed class ShipWeapon { override val firingArcs: Set get() = setOf(FiringArc.BOW) - override val isNormal: Boolean - get() = false - override val groupLabel: String get() = "Revelation Gun" + override val addsPointCost: Int + get() = 75 + override fun instantiate() = ShipWeaponInstance.RevelationGun(numShots) } @@ -167,12 +182,12 @@ sealed class ShipWeapon { override val firingArcs: Set get() = setOf(FiringArc.BOW) - override val isNormal: Boolean - get() = false - override val groupLabel: String get() = "EMP Emitter" + override val addsPointCost: Int + get() = 40 + override fun instantiate() = ShipWeaponInstance.EmpAntenna(numShots) } } @@ -240,14 +255,12 @@ data class ShipInstanceArmaments( ) fun cannonChanceToHit(attacker: ShipInstance, targeted: ShipInstance): Double { - val relativeDistance = (attacker.position.currentLocation - targeted.position.currentLocation) / (SHIP_BASE_SIZE * 2) - val relativeVelocity = (attacker.position.currentVelocity - targeted.position.currentVelocity) / (SHIP_BASE_SIZE * 2) - - return sqrt(1 / abs(relativeDistance cross relativeVelocity)) + val relativeDistance = attacker.position.location - targeted.position.location + return SHIP_BASE_SIZE / relativeDistance.length } sealed class ImpactResult { - data class Damaged(val ship: ShipInstance, val amount: Int?) : ImpactResult() + data class Damaged(val ship: ShipInstance, val amount: Int? = null, val critical: CritResult = CritResult.NoEffect) : ImpactResult() data class Destroyed(val ship: ShipWreck) : ImpactResult() } @@ -307,7 +320,7 @@ fun ShipInstance.afterTargeted(by: ShipInstance, weaponId: Id) = whe hits++ } - impact(hits) + impact(hits).applyCriticals(by, weaponId) } is ShipWeaponInstance.Lance -> { var hits = 0 @@ -317,28 +330,27 @@ fun ShipInstance.afterTargeted(by: ShipInstance, weaponId: Id) = whe hits++ } - impact(hits) + impact(hits).applyCriticals(by, weaponId) + } + is ShipWeaponInstance.Torpedo -> { + if (shieldAmount > 0) { + if (Random.nextBoolean()) + impact(1).applyCriticals(by, weaponId) + else + ImpactResult.Damaged(this, 0) + } else + impact(2).applyCriticals(by, weaponId) } is ShipWeaponInstance.Hangar -> { ImpactResult.Damaged( if (weapon.weapon.wing == StrikeCraftWing.FIGHTERS) copy(fighterWings = fighterWings + setOf(ShipHangarWing(by.id, weaponId))) else - copy(bomberWings = bomberWings + setOf(ShipHangarWing(by.id, weaponId))), - amount = null + copy(bomberWings = bomberWings + setOf(ShipHangarWing(by.id, weaponId))) ) } - is ShipWeaponInstance.Torpedo -> { - if (shieldAmount > 0) { - if (Random.nextBoolean()) - impact(1) - else - ImpactResult.Damaged(this, amount = 0) - } else - impact(2) - } is ShipWeaponInstance.MegaCannon -> { - impact((3..7).random()) + impact((3..7).random()).applyCriticals(by, weaponId) } is ShipWeaponInstance.RevelationGun -> { ImpactResult.Destroyed(ShipWreck(ship, owner)) @@ -354,14 +366,49 @@ fun ShipInstance.afterTargeted(by: ShipInstance, weaponId: Id) = whe } } -fun canWeaponBeUsed(shipInstance: ShipInstance, shipWeapon: ShipWeaponInstance): Boolean = when (shipWeapon) { - is ShipWeaponInstance.Cannon -> shipInstance.weaponAmount > 0 - is ShipWeaponInstance.Hangar -> shipWeapon.wingHealth > 0.0 - is ShipWeaponInstance.Lance -> shipWeapon.numCharges > 0 - is ShipWeaponInstance.Torpedo -> true - is ShipWeaponInstance.MegaCannon -> shipWeapon.remainingShots > 0 - is ShipWeaponInstance.RevelationGun -> shipWeapon.remainingShots > 0 - is ShipWeaponInstance.EmpAntenna -> shipWeapon.remainingShots > 0 +fun ImpactResult.Damaged.withCritResult(critical: CritResult): ImpactResult = when (critical) { + is CritResult.NoEffect -> this + is CritResult.FireStarted -> copy( + ship = critical.ship, + amount = amount, + critical = critical + ) + is CritResult.ModulesDisabled -> copy( + ship = critical.ship, + amount = amount, + critical = critical + ) + is CritResult.HullDamaged -> copy( + ship = critical.ship, + amount = amount?.let { it + critical.amount }, + critical = critical + ) + is CritResult.Destroyed -> ImpactResult.Destroyed(critical.ship) +} + +fun ImpactResult.applyCriticals(attacker: ShipInstance, weaponId: Id): ImpactResult { + return when (this) { + is ImpactResult.Destroyed -> this + is ImpactResult.Damaged -> { + val critChance = criticalChance(attacker, weaponId, ship) + if (Random.nextDouble() > critChance) + this + else + withCritResult(ship.doCriticalDamage()) + } + } +} + +fun criticalChance(attacker: ShipInstance, weaponId: Id, targeted: ShipInstance): Double { + val targetHasShields = targeted.canUseShields && targeted.shieldAmount > 0 + val weapon = attacker.armaments.weaponInstances[weaponId] ?: return 0.0 + + return when (weapon) { + is ShipWeaponInstance.Torpedo -> if (targetHasShields) 0.0 else 0.375 + is ShipWeaponInstance.Hangar -> 0.0 + is ShipWeaponInstance.MegaCannon -> 0.5 + else -> if (targetHasShields) 0.125 else 0.25 + } } fun getWeaponPickRequest(weapon: ShipWeapon, position: ShipPosition, side: GlobalSide): PickRequest = when (weapon) { @@ -369,17 +416,17 @@ fun getWeaponPickRequest(weapon: ShipWeapon, position: ShipPosition, side: Globa type = PickType.Location( excludesNearShips = emptySet(), helper = PickHelper.Circle(radius = weapon.areaRadius), - drawLineFrom = if (weapon.isLine) null else position.currentLocation + drawLineFrom = if (weapon.isLine) null else position.location ), boundary = if (weapon.isLine) PickBoundary.AlongLine( - pointA = position.currentLocation + (normalDistance(position.facingAngle) * weapon.minRange), - pointB = position.currentLocation + (normalDistance(position.facingAngle) * weapon.maxRange) + pointA = position.location + (normalDistance(position.facing) * weapon.minRange), + pointB = position.location + (normalDistance(position.facing) * weapon.maxRange) ) else PickBoundary.WeaponsFire( - center = position.currentLocation, - facing = position.facingAngle, + center = position.location, + facing = position.facing, minDistance = weapon.minRange, maxDistance = weapon.maxRange, firingArcs = weapon.firingArcs, @@ -394,8 +441,8 @@ fun getWeaponPickRequest(weapon: ShipWeapon, position: ShipPosition, side: Globa PickRequest( PickType.Ship(targetSet), PickBoundary.WeaponsFire( - center = position.currentLocation, - facing = position.facingAngle, + center = position.location, + facing = position.facing, minDistance = weapon.minRange, maxDistance = weapon.maxRange, firingArcs = weapon.firingArcs, @@ -411,7 +458,7 @@ fun GameState.useWeaponPickResponse(attacker: ShipInstance, weaponId: Id { val targetedLocation = (target as? PickResponse.Location)?.position ?: return GameEvent.InvalidAction("Invalid pick response type") - val targetedShips = ships.filterValues { (it.position.currentLocation - targetedLocation).length < weaponType.areaRadius } + val targetedShips = ships.filterValues { (it.position.location - targetedLocation).length < weaponType.areaRadius } if (targetedShips.isEmpty()) return GameEvent.InvalidAction("No ships targeted - aborting fire") @@ -440,6 +487,7 @@ fun GameState.useWeaponPickResponse(attacker: ShipInstance, weaponId: Id { @@ -485,6 +533,7 @@ fun GameState.useWeaponPickResponse(attacker: ShipInstance, weaponId: Id "360-Degree " + FiringArc.FIRE_BROADSIDE -> "Broadside " + FiringArc.FIRE_FORE_270 -> "Dorsal " + setOf(FiringArc.ABEAM_PORT) -> "Port " + setOf(FiringArc.ABEAM_STARBOARD) -> "Starboard " + setOf(FiringArc.BOW) -> "Fore " + setOf(FiringArc.STERN) -> "Rear " + else -> null + }.takeIf { this !is ShipWeapon.Hangar } ?: "" + + val weaponIsPlural = numShots > 1 + + val weaponDesc = when (this) { + is ShipWeapon.Cannon -> "Cannon" + (if (weaponIsPlural) "s" else "") + is ShipWeapon.Lance -> "Lance" + (if (weaponIsPlural) "s" else "") + is ShipWeapon.Hangar -> when (wing) { + StrikeCraftWing.FIGHTERS -> "Fighters" + StrikeCraftWing.BOMBERS -> "Bombers" + } + is ShipWeapon.Torpedo -> "Torpedo" + (if (weaponIsPlural) "es" else "") + is ShipWeapon.MegaCannon -> "Mega Giga Cannon" + is ShipWeapon.RevelationGun -> "Revelation Gun" + is ShipWeapon.EmpAntenna -> "EMP Antenna" + } + + return "$firingArcsDesc$weaponDesc" + } + +val ShipWeaponInstance.displayName: String + get() { + val weaponParam = when (this) { + is ShipWeaponInstance.Lance -> " (${charge.toPercent()})" + is ShipWeaponInstance.Hangar -> " (${wingHealth.toPercent()})" + is ShipWeaponInstance.MegaCannon -> " ($remainingShots)" + is ShipWeaponInstance.RevelationGun -> " ($remainingShots)" + is ShipWeaponInstance.EmpAntenna -> " ($remainingShots)" + else -> "" + } + + return "${weapon.displayName}$weaponParam" + } diff --git a/src/commonMain/kotlin/starshipfights/game/ship_weapons_formats.kt b/src/commonMain/kotlin/starshipfights/game/ship_weapons_formats.kt index 6141a2d..8885d80 100644 --- a/src/commonMain/kotlin/starshipfights/game/ship_weapons_formats.kt +++ b/src/commonMain/kotlin/starshipfights/game/ship_weapons_formats.kt @@ -97,7 +97,7 @@ fun diadochiShipWeapons( } repeat(hangarSections * 2) { w -> - if (w % 3 == 0) + if (w % 2 == 0) idCounter.add(weapons, ShipWeapon.Hangar(StrikeCraftWing.FIGHTERS, "Fighter complement")) else idCounter.add(weapons, ShipWeapon.Hangar(StrikeCraftWing.BOMBERS, "Bomber complement")) diff --git a/src/commonMain/kotlin/starshipfights/game/util.kt b/src/commonMain/kotlin/starshipfights/game/util.kt index 3de91fa..cfd38dd 100644 --- a/src/commonMain/kotlin/starshipfights/game/util.kt +++ b/src/commonMain/kotlin/starshipfights/game/util.kt @@ -41,3 +41,10 @@ else this + (this * (multiplier - 1)) fun Double.toPercent() = "${(this * 100).roundToInt()}%" fun smoothNegative(x: Double) = if (x < 0) exp(x) else x + 1 + +fun Iterable.joinToDisplayString(oxfordComma: Boolean = true, transform: (T) -> String = { it.toString() }): String = when (val size = count()) { + 0 -> "" + 1 -> transform(single()) + 2 -> "${transform(first())} and ${transform(last())}" + else -> "${take(size - 1).joinToString { transform(it) }}${if (oxfordComma) "," else ""} and ${transform(last())}" +} diff --git a/src/jsMain/kotlin/starshipfights/game/client_game.kt b/src/jsMain/kotlin/starshipfights/game/client_game.kt index f379f6b..4fbe7dd 100644 --- a/src/jsMain/kotlin/starshipfights/game/client_game.kt +++ b/src/jsMain/kotlin/starshipfights/game/client_game.kt @@ -111,8 +111,8 @@ private suspend fun GameRenderInteraction.execute() { } } -private suspend fun GameNetworkInteraction.execute(token: String): String { - val gameEnd = CompletableDeferred() +private suspend fun GameNetworkInteraction.execute(token: String): Pair { + val gameEnd = CompletableDeferred>() try { httpClient.webSocket("$rootPathWs/game/$token") { @@ -121,7 +121,7 @@ private suspend fun GameNetworkInteraction.execute(token: String): String { }.display() if (!opponentJoined) - Popup.GameOver("Unfortunately, your opponent never entered the battle.", gameState.value).display() + Popup.GameOver(LocalSide.GREEN, "Unfortunately, your opponent never entered the battle.", gameState.value).display() val sendActionsJob = launch { while (true) { @@ -141,18 +141,18 @@ private suspend fun GameNetworkInteraction.execute(token: String): String { errorMessages.send(event.message) } is GameEvent.GameEnd -> { - gameEnd.complete(event.message) + gameEnd.complete(event.winner?.relativeTo(mySide) to event.message) closeAndReturn { return@webSocket sendActionsJob.cancel() } } } } } } catch (ex: WebSocketException) { - gameEnd.complete("Server closed connection abruptly") + gameEnd.complete(null to "Server closed connection abruptly") } if (gameEnd.isActive) - gameEnd.complete("Connection closed") + gameEnd.complete(null to "Connection closed") return gameEnd.await() } @@ -202,10 +202,10 @@ suspend fun gameMain(side: GlobalSide, token: String, state: GameState) { val connectionJob = async { gameConnection.execute(token) } val renderingJob = launch { gameRendering.execute() } - val finalMessage = connectionJob.await() + val (finalWinner, finalMessage) = connectionJob.await() renderingJob.cancel() interruptExit = false - Popup.GameOver(finalMessage, gameState.value).display() + Popup.GameOver(finalWinner, finalMessage, gameState.value).display() } } diff --git a/src/jsMain/kotlin/starshipfights/game/game_render.kt b/src/jsMain/kotlin/starshipfights/game/game_render.kt index eaa9c51..3a23af0 100644 --- a/src/jsMain/kotlin/starshipfights/game/game_render.kt +++ b/src/jsMain/kotlin/starshipfights/game/game_render.kt @@ -15,7 +15,7 @@ object GameRender { state.ships.forEach { (_, ship) -> when (state.renderShipAs(ship, mySide)) { ShipRenderMode.NONE -> {} - ShipRenderMode.SIGNAL -> shipGroup.add(RenderResources.enemySignal.generate(ship.position.currentLocation)) + ShipRenderMode.SIGNAL -> shipGroup.add(RenderResources.enemySignal.generate(ship.position.location)) ShipRenderMode.FULL -> shipGroup.add(RenderResources.shipMesh.generate(ship)) } } diff --git a/src/jsMain/kotlin/starshipfights/game/game_resources.kt b/src/jsMain/kotlin/starshipfights/game/game_resources.kt index 1be28ee..e9a9b30 100644 --- a/src/jsMain/kotlin/starshipfights/game/game_resources.kt +++ b/src/jsMain/kotlin/starshipfights/game/game_resources.kt @@ -56,15 +56,17 @@ object RenderResources { coroutineScope { launch { val img = Image() + val job = launch { img.awaitEvent("load") } img.src = LOGO_URL - img.awaitEvent("load") + job.join() } launch { Faction.values().map { faction -> val img = Image() + val job = launch { img.awaitEvent("load") } img.src = faction.flagUrl - launch { img.awaitEvent("load") } + job }.joinAll() } @@ -181,7 +183,7 @@ object RenderResources { markerFactory = CustomRenderFactory { side -> when (side) { - LocalSide.BLUE -> friendlyMarkerMesh + LocalSide.GREEN -> friendlyMarkerMesh LocalSide.RED -> hostileMarkerMesh }.clone(true) } @@ -227,16 +229,16 @@ object RenderResources { shipMeshesRaw[st] = RenderFactory { mesh.clone(true) } - val blueOutlineMaterial = outlineMaterial.clone().unsafeCast().apply { - uniforms["outlineColor"]!!.value = Color(LocalSide.BLUE.htmlColor) + val greenOutlineMaterial = outlineMaterial.clone().unsafeCast().apply { + uniforms["outlineColor"]!!.value = Color(LocalSide.GREEN.htmlColor) } val redOutlineMaterial = outlineMaterial.clone().unsafeCast().apply { uniforms["outlineColor"]!!.value = Color(LocalSide.RED.htmlColor) } - val outlineBlue = mesh.clone(true).unsafeCast() - outlineBlue.material = blueOutlineMaterial + val outlineGreen = mesh.clone(true).unsafeCast() + outlineGreen.material = greenOutlineMaterial val outlineRed = mesh.clone(true).unsafeCast() outlineRed.material = redOutlineMaterial @@ -249,7 +251,7 @@ object RenderResources { markerFactory.generate(side).unsafeCast(), mesh.clone(true).unsafeCast(), when (side) { - LocalSide.BLUE -> outlineBlue + LocalSide.GREEN -> outlineGreen LocalSide.RED -> outlineRed }.clone(true).unsafeCast() ).group @@ -258,8 +260,8 @@ object RenderResources { shipMesh = CustomRenderFactory { shipInstance -> shipMeshes.getValue(shipInstance.ship.shipType).generate(shipInstance).also { render -> - RenderScaling.toWorldRotation(shipInstance.position.facingAngle, render) - render.position.copy(RenderScaling.toWorldPosition(shipInstance.position.currentLocation)) + RenderScaling.toWorldRotation(shipInstance.position.facing, render) + render.position.copy(RenderScaling.toWorldPosition(shipInstance.position.location)) } } } diff --git a/src/jsMain/kotlin/starshipfights/game/game_ui.kt b/src/jsMain/kotlin/starshipfights/game/game_ui.kt index 33ced42..ac17564 100644 --- a/src/jsMain/kotlin/starshipfights/game/game_ui.kt +++ b/src/jsMain/kotlin/starshipfights/game/game_ui.kt @@ -33,6 +33,7 @@ object GameUI { private lateinit var topRightBar: HTMLDivElement private lateinit var errorMessages: HTMLParagraphElement + private lateinit var helpMessages: HTMLParagraphElement private lateinit var shipsOverlay: HTMLElement private lateinit var shipsOverlayRenderer: CSS3DRenderer @@ -83,6 +84,10 @@ object GameUI { p { id = "error-messages" } + + p { + id = "help-messages" + } } chatHistory = document.getElementById("chat-history").unsafeCast() @@ -112,6 +117,7 @@ object GameUI { topRightBar = document.getElementById("top-right-bar").unsafeCast() errorMessages = document.getElementById("error-messages").unsafeCast() + helpMessages = document.getElementById("help-messages").unsafeCast() shipsOverlayRenderer = CSS3DRenderer() shipsOverlayRenderer.setSize(window.innerWidth, window.innerHeight) @@ -139,6 +145,12 @@ object GameUI { errorMessages.textContent = "" } + var currentHelpMessage: String + get() = helpMessages.textContent ?: "" + set(value) { + helpMessages.textContent = value + } + fun updateGameUI(controls: CameraControls) { shipsOverlayCamera.position.copy(controls.camera.getWorldPosition(shipsOverlayCamera.position)) shipsOverlayCamera.quaternion.copy(controls.camera.getWorldQuaternion(shipsOverlayCamera.quaternion)) @@ -180,7 +192,7 @@ object GameUI { +ship.fullName } +" has been sighted" - if (owner == LocalSide.BLUE) + if (owner == LocalSide.GREEN) +" by the enemy" +"!" } @@ -220,15 +232,11 @@ object GameUI { else entry.damageInflicted.toString() + if (entry.critical != null) + +" critical" + +" damage from " when (entry.attacker) { - ShipAttacker.Bombers -> { - if (owner == LocalSide.RED) - +"our " - else - +"enemy " - +"bombers" - } is ShipAttacker.EnemyShip -> { if (entry.weapon != null) { +"the " @@ -244,11 +252,27 @@ object GameUI { +" of " } +"the " - span { + strong { style = "color:${owner.other.htmlColor}" +state.getShipInfo(entry.attacker.id).fullName } } + ShipAttacker.Fire -> { + +"onboard fires" + } + ShipAttacker.Bombers -> { + if (owner == LocalSide.RED) + +"our " + else + +"enemy " + +"bombers" + } + } + + +when (entry.critical) { + ShipCritical.Fire -> ", starting a fire" + is ShipCritical.ModulesHit -> ", disabling ${entry.critical.module.joinToDisplayString { it.getDisplayName(ship) }}" + else -> "" } +"." } @@ -267,11 +291,14 @@ object GameUI { when (entry.destroyedBy) { is ShipAttacker.EnemyShip -> { +"the " - span { + strong { style = "color:${owner.other.htmlColor}" +state.getShipInfo(entry.destroyedBy.id).fullName } } + ShipAttacker.Fire -> { + +"onboard fires" + } ShipAttacker.Bombers -> { +if (owner == LocalSide.RED) "our " @@ -321,6 +348,13 @@ object GameUI { br +"Phase III - Weapons Fire" } + is GamePhase.Repair -> { + strong(classes = "heading") { + +"Turn ${state.phase.turn}" + } + br + +"Phase IV - Onboard Repairs" + } } } } @@ -347,7 +381,7 @@ object GameUI { element.style.asDynamic().pointerEvents = "none" - position.copy(RenderScaling.toWorldPosition(ship.position.currentLocation)) + position.copy(RenderScaling.toWorldPosition(ship.position.location)) position.y = 7.5 }) } @@ -396,12 +430,12 @@ object GameUI { tr { repeat(activeShield) { td { - style = "background-color:#69F;margin:10px;height:15px" + style = "background-color:#69F;margin:20px;height:15px" } } repeat(downShield) { td { - style = "background-color:#46A;margin:10px;height:15px" + style = "background-color:#46A;margin:20px;height:15px" } } } @@ -417,12 +451,12 @@ object GameUI { tr { repeat(activeHull) { td { - style = "background-color:${if (ship.owner == mySide) "#39F" else "#F66"};margin:10px;height:15px" + style = "background-color:${if (ship.owner == mySide) "#5F5" else "#F55"};margin:20px;height:15px" } } repeat(downHull) { td { - style = "background-color:${if (ship.owner == mySide) "#135" else "#522"};margin:10px;height:15px" + style = "background-color:${if (ship.owner == mySide) "#262" else "#622"};margin:20px;height:15px" } } } @@ -439,12 +473,12 @@ object GameUI { tr { repeat(activeWeapons) { td { - style = "background-color:#F63;margin:10px;height:15px" + style = "background-color:#F63;margin:20px;height:15px" } } repeat(downWeapons) { td { - style = "background-color:#A42;margin:10px;height:15px" + style = "background-color:#A42;margin:20px;height:15px" } } } @@ -464,8 +498,8 @@ object GameUI { if (ship.fighterWings.isNotEmpty()) { span { val (borderColor, fillColor) = when (fighterSide) { - LocalSide.BLUE -> "#39F" to "#135" - LocalSide.RED -> "#F66" to "#522" + LocalSide.GREEN -> "#5F5" to "#262" + LocalSide.RED -> "#F55" to "#622" } style = "display:inline-block;border:5px solid $borderColor;border-radius:15px;background-color:$fillColor;color:#fff" @@ -487,7 +521,7 @@ object GameUI { if (ship.bomberWings.isNotEmpty()) { span { val (borderColor, fillColor) = when (bomberSide) { - LocalSide.BLUE -> "#39F" to "#135" + LocalSide.GREEN -> "#39F" to "#135" LocalSide.RED -> "#F66" to "#522" } @@ -613,13 +647,11 @@ object GameUI { p { style = "height:19%;margin:0" - strong(classes = "heading") { - +ship.ship.fullName - } - + strong(classes = "heading") { +ship.ship.fullName } br +ship.ship.shipType.fullerDisplayName + br if (ship.owner == mySide) table { @@ -636,6 +668,32 @@ object GameUI { } } } + + ship.modulesStatus.statuses.forEach { (module, status) -> + when (status) { + ShipModuleStatus.INTACT -> {} + ShipModuleStatus.DAMAGED -> { + span { + style = "color:#fd4" + +"${module.getDisplayName(ship.ship)} Damaged" + } + br + } + ShipModuleStatus.DESTROYED -> { + span { + style = "color:#e22" + +"${module.getDisplayName(ship.ship)} Destroyed" + } + br + } + } + } + + if (ship.numFires > 0) + span { + style = "color:#e94" + +"${ship.numFires} Onboard Fires" + } } hr { style = "border-color:#555" } @@ -643,6 +701,11 @@ object GameUI { p { style = "height:69%;margin:0" + if (gameState.phase is GamePhase.Repair) { + +"${ship.remainingRepairTokens} Repair Tokens" + br + } + shipAbilities.forEach { ability -> when (ability) { is PlayerAbilityType.DistributePower -> { @@ -678,7 +741,7 @@ object GameUI { title = "${transferFrom.displayName} to ${transferTo.displayName}" img(src = transferFrom.imageUrl, alt = transferFrom.displayName) { - style = "width:0.95em;" + style = "width:0.95em" } +Entities.nbsp img(src = ShipSubsystem.transferImageUrl, alt = " to ") { @@ -686,7 +749,7 @@ object GameUI { } +Entities.nbsp img(src = transferTo.imageUrl, alt = transferTo.displayName) { - style = "width:0.95em;" + style = "width:0.95em" } val delta = mapOf(transferFrom to -1, transferTo to 1) @@ -707,37 +770,58 @@ object GameUI { } } - p { - style = "text-align:center" - button { - +"Confirm" - if (ship.validatePowerMode(shipPowerMode)) - onClickFunction = { e -> - e.preventDefault() - responder.useAbility(ability) - } - else { - disabled = true - style = "cursor:not-allowed" + button { + +"Confirm" + if (ship.validatePowerMode(shipPowerMode)) + onClickFunction = { e -> + e.preventDefault() + responder.useAbility(ability) } + else { + disabled = true + style = "cursor:not-allowed" + } + } + + button { + +"Reset" + onClickFunction = { e -> + e.preventDefault() + ClientAbilityData.newShipPowerModes[ship.id] = ship.powerMode + updateAbilityData(gameState) } } } is PlayerAbilityType.MoveShip -> { - p { - style = "text-align:center" - button { - +"Move Ship" - onClickFunction = { e -> - e.preventDefault() - responder.useAbility(ability) - } + button { + +"Move Ship" + onClickFunction = { e -> + e.preventDefault() + responder.useAbility(ability) + } + } + } + is PlayerAbilityType.RepairShipModule -> { + a(href = "#") { + +"Repair ${ability.module.getDisplayName(ship.ship)}" + onClickFunction = { e -> + e.preventDefault() + responder.useAbility(ability) + } + } + br + } + is PlayerAbilityType.ExtinguishFire -> { + a(href = "#") { + +"Extinguish Fire" + onClickFunction = { e -> + e.preventDefault() + responder.useAbility(ability) } } + br } } - - hr { style = "border-color:#555" } } combatAbilities.forEach { ability -> @@ -745,42 +829,13 @@ object GameUI { val weaponInstance = ship.armaments.weaponInstances.getValue(ability.weapon) - val firingArcs = weaponInstance.weapon.firingArcs - val firingArcsDesc = when (firingArcs) { - FiringArc.FIRE_360 -> "360-Degree" - FiringArc.FIRE_BROADSIDE -> "Broadside" - FiringArc.FIRE_FORE_270 -> "Dorsal" - setOf(FiringArc.ABEAM_PORT) -> "Port" - setOf(FiringArc.ABEAM_STARBOARD) -> "Starboard" - setOf(FiringArc.BOW) -> "Fore" - setOf(FiringArc.STERN) -> "Rear" - else -> null - }.takeIf { weaponInstance !is ShipWeaponInstance.Hangar } - val weaponVerb = if (weaponInstance is ShipWeaponInstance.Hangar) "Release" else "Fire" - - val weaponIsPlural = weaponInstance.weapon.numShots > 1 - - val weaponDesc = when (weaponInstance) { - is ShipWeaponInstance.Cannon -> "Cannon" + (if (weaponIsPlural) "s" else "") - is ShipWeaponInstance.Lance -> "Lance" + (if (weaponIsPlural) "s" else "") + " (${weaponInstance.charge.toPercent()})" - is ShipWeaponInstance.Hangar -> when (weaponInstance.weapon.wing) { - StrikeCraftWing.FIGHTERS -> "Fighters" - StrikeCraftWing.BOMBERS -> "Bombers" - } + " (${weaponInstance.wingHealth.toPercent()})" - is ShipWeaponInstance.Torpedo -> "Torpedo" + (if (weaponIsPlural) "es" else "") - is ShipWeaponInstance.MegaCannon -> "Mega Giga Cannon (" + weaponInstance.remainingShots + ")" - is ShipWeaponInstance.RevelationGun -> "Revelation Gun (" + weaponInstance.remainingShots + ")" - is ShipWeaponInstance.EmpAntenna -> "EMP Antenna (" + weaponInstance.remainingShots + ")" - } + val weaponDesc = weaponInstance.displayName when (ability) { is PlayerAbilityType.ChargeLance -> { a(href = "#") { - +"Charge " - if (firingArcsDesc != null) - +"$firingArcsDesc " - +weaponDesc + +"Charge $weaponDesc" onClickFunction = { e -> e.preventDefault() responder.useAbility(ability) @@ -789,10 +844,7 @@ object GameUI { } is PlayerAbilityType.UseWeapon -> { a(href = "#") { - +"$weaponVerb " - if (firingArcsDesc != null) - +"$firingArcsDesc " - +weaponDesc + +"$weaponVerb $weaponDesc" onClickFunction = { e -> e.preventDefault() responder.useAbility(ability) @@ -801,10 +853,7 @@ object GameUI { } is PlayerAbilityType.RecallStrikeCraft -> { a(href = "#") { - +"Recall " - if (firingArcsDesc != null) - +"$firingArcsDesc " - +weaponDesc + +"Recall $weaponDesc" onClickFunction = { e -> e.preventDefault() responder.useAbility(ability) @@ -819,10 +868,12 @@ object GameUI { p { style = "height:9%;margin:0" + hr { style = "border-color:#555" } + val finishPhase = abilities.filterIsInstance().singleOrNull() if (finishPhase != null) a(href = "#") { - +"End Your Phase" + +"End Phase" id = "done-phase" onClickFunction = { e -> @@ -833,7 +884,7 @@ object GameUI { else span { style = "color:#333;cursor:not-allowed" - +"End Your Phase" + +"End Phase" } } } diff --git a/src/jsMain/kotlin/starshipfights/game/pick_bounds_js.kt b/src/jsMain/kotlin/starshipfights/game/pick_bounds_js.kt index 5c46ffa..d8e3474 100644 --- a/src/jsMain/kotlin/starshipfights/game/pick_bounds_js.kt +++ b/src/jsMain/kotlin/starshipfights/game/pick_bounds_js.kt @@ -293,6 +293,8 @@ private fun beginPick(context: PickContext, pickRequest: PickRequest, responseHa } handleWindowEscapeKey = { responseHandler(null) } + + GameUI.currentHelpMessage = "Press Escape to cancel current action" } private fun endPick(scene: Scene) { @@ -307,6 +309,8 @@ private fun endPick(scene: Scene) { scene.getObjectByName("bound")?.removeFromParent() scene.getObjectByName("pick-helper")?.removeFromParent() scene.getObjectByName("pick-line")?.removeFromParent() + + GameUI.currentHelpMessage = "" } private val pickMutex = Mutex() diff --git a/src/jsMain/kotlin/starshipfights/game/popup.kt b/src/jsMain/kotlin/starshipfights/game/popup.kt index e7e5802..8ac5ad3 100644 --- a/src/jsMain/kotlin/starshipfights/game/popup.kt +++ b/src/jsMain/kotlin/starshipfights/game/popup.kt @@ -413,8 +413,19 @@ sealed class Popup { } } - class GameOver(private val outcome: String, private val finalState: GameState) : Popup() { + class GameOver(private val winner: LocalSide?, private val outcome: String, private val finalState: GameState) : Popup() { override fun TagConsumer<*>.render(context: CoroutineContext, callback: (Nothing) -> Unit) { + p { + style = "text-align:center" + + strong(classes = "heading") { + +when (winner) { + LocalSide.GREEN -> "Victory" + LocalSide.RED -> "Defeat" + null -> "Stalemate" + } + } + } p { style = "text-align:center" diff --git a/src/jsMain/resources/style.css b/src/jsMain/resources/style.css index 7f4e6ab..0b41b75 100644 --- a/src/jsMain/resources/style.css +++ b/src/jsMain/resources/style.css @@ -165,7 +165,7 @@ div.ui-layer { #error-messages { position: fixed; - top: 5vh; + top: 20vh; left: 50vw; transform: translate(-50%, 0); @@ -175,15 +175,25 @@ div.ui-layer { text-shadow: 0 0 4px #a33; } +#help-messages { + position: fixed; + bottom: 20vh; + left: 50vw; + transform: translate(-50%, 0); + + font-size: 1.25em; + color: #fff; +} + #top-middle-info { position: fixed; - top: 0; + top: 2.5vh; left: 50vw; - width: 32.5vw; - height: 15vh; + width: 36vw; + height: 12.5vh; - transform: translate(-50%, -1em); - padding-top: 1.25em; + transform: translate(-50%, 0); + padding-top: 0.75em; } #top-right-bar { @@ -194,6 +204,10 @@ div.ui-layer { height: 75vh; } +hr + hr { + display: none; +} + #bottom-center-bar { position: fixed; bottom: 22.5vh; diff --git a/src/jsMain/resources/textures/friendly-marker.png b/src/jsMain/resources/textures/friendly-marker.png index f71ba03306ea83fabe9a217edbe3ffa085078200..3d547fe17a49beebe15e60e6ebf8443d861227e0 100644 GIT binary patch literal 11059 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%0AiSWHE;)RD%H*#a!bLuG?MW)qVH>&fb} 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 O{@ho1EiYmR|M`FJ3V=ia literal 10770 zcmYLPdq9j^*Wb@HClk_Cq@tz~N~F?_$c!0^LgP-QB6Jyv+)HL0Lph~H7p8QPG>%f8 zgpir{pmG_dMj=NXWvC>XZj))AZ$0DvzOR4Uv)0~gueJ7X?Pu?`_8HDfBk&K~zt)q5ZoZt_2|vgoOI9sGs4RJ$YTIw{TW9a`E&CCg z2+fHI+ul;}8lhQ*E0!)`!3ZpOHC^CsrtoC&t9Ih6c6_ zeb_&3dvE;c@aWYUk%jBi2CF{5AE>MNxKuTKn4$V^8MGX?kOht#ud#-V$f02M`WPDF+#_cAB0#98PCpFG)<5@!2Q)1FP zv#~_X*xMs^IM1x>PUq(Js{0qo%$;N;dY&GP^P<=Kl}_M4qaaSQZ6z5+F+wQlj{P}7;Cr`ae#52VI;~_vI(BdbUvHJx^|dO$+(Z3e3dry2d8=ZW<359%wN?tyn-d7N zyen6F(voqj7>(%NE1}cZe^mJi9;5%IfS?Fjy{c{s4D;6v0}p+AU9ZYlRO1(_nh`9a ziv?nCsc^!-T-3)uaQU#3r*sf!^(rY|Kg?VE$TSF6aR~{fmMv4@v^+J}&3q#5XUwZ% z-nQC4@ikB_m^5oQt@`}zpUI=G?{x<3WxTO7$V@gFZI0}g z(?+W<1nON{sLpwzbdvOk01D|OR8;P%5Sw=$bu)K5tYTa$*FK`(<>59Ks~?M@^ppoi|6coiqGpnZuq&_TmU z)~J&iex8aZ0XN${lQ%I^ZOGv95}n)er&)NiIOa!p7~sGQ@!Na;QQ4h}z6?{D;Xd*r zY&(kQRVV9U96E-Q$`31&vjzA5pZl@+1g)^$3Qz^s#}SJdY^P3$O2n=9tHpCi9LHiD zh^x69y%l1Ut}r+ARPHb1jJnDXEV@8NX;jp9j9BOmqS>8CI5z=-FHPH`L>Zk&=F*Xk z1(LiC4_ChmXvbIF{9j~#zy3~$f6n_>*uJFlmwjXMl9Y>LzFgQ3Ivm!u|J-@15ZrqaN#cD& z)d0$2<;di>+chHZP40SrAZrzc|2r9ZG_n-Qa)0KX*S}yctup@ZL`P#`x)rmfc(^FC zvCrBM0(O5hYK;T1Bv3F8A4#lz!_==5IIa!AO(NFiax>fAr{;+O)viQAWLsxkB>%s_ zPb8!0W?zNauxp!}`Oa>je=}`cBf8&t_YW$%L_z&U6>{2>xGyh`27YrO5PY~Mne=c) z&1;axM7f)6UGU0PQ6-vjL3{32##7O39n>#+BBx2sF7!IXkgZxTwnk2!MwW~vp{lS7 zrRRRT)Rz5V?$40geGYBlra9A5{e|>Q{C)DX9>RYxVl=+pL4Y>H6q9YifA=~2_oSB( zX&gw6jGD-1?qQ)9FzjSgCQtTpnD^HZu}4V8lTdMYp0Z|){8(C&=k=X1%YE(PnX;@g zW>Pv?l0rtlO>FS$;(2${H9A`e6{7Y^0h<3iV%~@jQ)jTMR@daeb^|JvOW;}_Qv!l7{S2sADV5usIrD{eQ?5dSD9|dSQ9x=y7 z?^ds|gGeaWM0z0w$!<2w@nxc)AWf)|Ry`(9e%!2yN@^1zrZhT2{i+4FyA3~oIVxho zZ-8hD8HK;zuL%6>=gn9ZW6NLK7h`OfPGF%&hDfxgPRj6&8i*MOvGbnMecp%NVL`Tc z!uap{KdAC6EqgaY3>Y=R7R}$LgJe4da&gCmBgGKz-c4wKZ~VmzRLMDz?*LFuf3GDj zAe@?W^r@qMlmW>&2{Cm&z-{jf+bUetIddQ`Vr{m=%CYs)o0jELyqmK>=Va?USiT*) zd+lTyop-0eL=^Nj?5dn*TUlTH1mr!8LbqO(w4&G;HM-!{-g`?aSv!Hk6sdP*DHxS}8Bgv3naR z@#F9o1EH58>B}F88D{B4f>!8$JrIq)d@WvL6cS<|tEBp*dp+dymBgA8>@!o;k z+VDki@Xa{Rhg-X37O6NamkG)V6PnQRoKE5&zv&-L#n zpma}LG^#N}Xn{&o<-FUOdD+)MBr&QP)v;pJlU7klqHH8F5aP;!Q}@m%l_%N*5R~@= zCJf9o%cPSz&KSSa2~RM=X)b>gAx8q=j^D z=pxo0^zvrxTr-Z&52qmir3%J}hFAUZfc{V-#+Z}H=w-f=7TI~Z%L6`#gqROuWq_9Z zhG>3*oKe>h^E%lLEZC|wC$@bXm z^BbMNN(Y6ipI1>1TAGD8sPXy+XUPDkuCZ(i3uWq~>TAl9U{y|>vm{P{T<9p#TM_t~ zfcE^%KD;pf_$u>L_cD{V>XWQjBjXEl@rM$&?NQtq&xzKew?CE|9s z59m?{j%c>?w>l`eUZvQgc;(oRClqhdnlrk5m$v}@Wq?Ksm7go*a>r^s&UVzaxN_Dy zg07E7t|>og`*jHdw9ycaTvcY2$@Onl<28PY z3g3}XxseW5G!6B8D(u?Vikda%F5)&3c4Q`vM(>QSj#g|}q>;!yR@f5s0unT0s219wSH+qQ`68uo$3HuXKY2MF-C`?Rv-@y$5%z;h8Wx~>Z7DB?+iM-7 z%Bd5Ud=f64Kj=Ye(;_iPo$%PA%EAYIya4bMH~ZguOFCaX9m!THTI+;`&rI;o6^C?7 z<+GC94M;~kktjnh&Z($Nb5++xN}KxwM0SlShAc#47J#7Q%Dpd!#fhMK?Gt{E0ZH2m z$vhRU&96DF*V?C5<2#GCY8|6>`MO2@LAAHXa_0(hwPu4(Fvc^=<=hx|nc_f)B}wUV`dxB#(o)Koi>TCA zDqhWvGzeCSiz~SN3fCCZv()-oSkOWtuPPC53DzyOrRTCq5*H^tx;U1T*oQABv{?K- zY$=NwLgxQ)!tWKwt}psUzv7&H%sh<>chTHAQt=+m>ElTvK2$!)rD-vb>Dnh#>vgc) zsZ#NAAJ#vsYnA~IR%h>QS^I$O-?f7W%sQIFC16$nw;#2GGF$-{zSqW?F3PN0WDTrGjdwyUCou0df#9S=o`6c2% z&jss#AMDtaZb&knjym#`cRnqFW5?Z#eLVN3<8}`z6@vpHYbqAxE)~0Talo&5F1Hz_ zBwz|E$#fbj%2(d8s(INAACtONtBS68Rb6^p#tx=UYMFQmY`!K$vh$N zK1{nqmqVlHMv)}P;CIw}rF2-lni$_Z{%N6r|A!7zgsLN+@9RC>4&EL8&~e0t45+i2 zfu@4Jt?vrOCU~=LjcHI%BYi<86|OLC^M$;h4|OUAmA%#Y!RKY#unFHdFx`=!JC7vU zCgjng{M&Y_0>x8XEhlTGTaw`tefOfgHSR`Uz}5Ec)p$g0nfCBpIAUBy^jrgyMCpVd ze=O%&noV|8N3xTC?ZGK50e>t7g*2*u!ZF^6%Xgt*3k}iY0_D&rVh<+V<`-3jY4+fW z)5&nR9zG{;%?XV2{pseaj`U7)W~!{)G7ns}rw3V)Br!rBFKUzu9G|4#ElJ;N_X+rB zNA_6>c{x#u`_AGmslmGEf@2@|3HWDqkdRjw zQhhDHyj48R%z)i(Qae8QY1sU@S$NOMtD8M2)}gmht}>ZkW4ZOL1}xO#*i@(II@qbi zi6F$mf9!yp*(L{dFZ-r5b3E#+c(Pc)CwYLf_;gKLO-#K9pL~5HZas;sAa3k3L>tR56x)YGjVt+Z5`#6?GU>LGyV(8n~in z17PPudrb5KSxH06WgX0<->{LF9toRz#Ob%TpL5&CT*(m*k={m z!;97@HJcozV6*#;V9>r#_n6Oz#etO%@66RMN}!fhbGZD6I+)`vbL7N}`X>**K;tW$ zQ02GzD~_`yDZUeskg?`w%&jSSGQHW5Cc}Qb)`8wOzL<=pImu55IKx`e*1?UNI8vlM)4^zAgaKJkhI5>Y zubMzVsLHj<9d|-?{=xP?mr|!KXCnm`re$zU&WNSNK_Q_Z4F>y=u zG^LZt71}U~qC>R}G6A=70}ZN~!?kuA(R z^?dj5v)g%BVAbHO5@G5>mI0`E6P_wl2r?mqwrXb#`!wH|#Fy7OJk(3;zBaoWUl~

hHGk*@DCe1Na#Vl51#(K*pN=pm{WO_L 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 Instant.now()) redirect("/admiral/${admiralId}/manage") - if (ship.shipType.weightClass.isUnique) redirect("/admiral/${admiralId}/manage") + if (ship.readyAt > Instant.now()) redirect("/admiral/${admiralId}/manage" + withErrorMessage("Cannot sell ships that are not ready for battle")) + if (ship.shipType.weightClass.isUnique) redirect("/admiral/${admiralId}/manage" + withErrorMessage("Cannot sell a ${ship.shipType.fullDisplayName}")) coroutineScope { launch { ShipInDrydock.del(shipId) } - launch { Admiral.set(admiralId, inc(Admiral::money, ship.shipType.weightClass.sellPrice)) } + launch { Admiral.set(admiralId, inc(Admiral::money, ship.shipType.sellPrice)) } } redirect("/admiral/${admiralId}/manage") @@ -253,15 +253,15 @@ interface AuthProvider { if (shipType.faction != admiral.faction || shipType.weightClass.rank > admiral.rank.maxShipWeightClass.rank) throw NotFoundException() - if (shipType.weightClass.buyPrice > admiral.money) - redirect("/admiral/${admiralId}/manage") + if (shipType.buyPrice > admiral.money) + redirect("/admiral/${admiralId}/manage" + withErrorMessage("You cannot afford that ship")) val ownedShips = ShipInDrydock.filter(ShipInDrydock::owningAdmiral eq admiralId).toList() if (shipType.weightClass.isUnique) { val hasSameWeightClass = ownedShips.any { it.shipType.weightClass == shipType.weightClass } if (hasSameWeightClass) - redirect("/admiral/${admiralId}/manage") + redirect("/admiral/${admiralId}/manage" + withErrorMessage("Cannot buy two copies of a ${shipType.fullDisplayName}")) } val shipNames = ownedShips.map { it.name }.toMutableSet() @@ -276,7 +276,7 @@ interface AuthProvider { coroutineScope { launch { ShipInDrydock.put(newShip) } - launch { Admiral.set(admiralId, inc(Admiral::money, -shipType.weightClass.buyPrice)) } + launch { Admiral.set(admiralId, inc(Admiral::money, -shipType.buyPrice)) } } redirect("/admiral/${admiralId}/manage") @@ -434,7 +434,7 @@ object TestAuthProvider : AuthProvider { else "An unknown error occurred." - val redirectUrl = "/login?" + parametersOf("error", errorMsg).formUrlEncode() + val redirectUrl = "/login" + withErrorMessage(errorMsg) call.respondRedirect(redirectUrl) } } @@ -447,8 +447,6 @@ object TestAuthProvider : AuthProvider { if (call.getUserSession() != null) redirect("/me") - val errorMsg = call.request.queryParameters["error"] - call.respondHtml(HttpStatusCode.OK, page("Authentication Test", call.standardNavBar(), CustomSidebar { p { +"This instance does not have Discord OAuth login set up. As a fallback, this authentication mode is used for testing only." @@ -470,10 +468,10 @@ object TestAuthProvider : AuthProvider { required = true } - errorMsg?.let { msg -> + call.request.queryParameters["error"]?.let { errorMsg -> p { style = "color:#d22" - +msg + +errorMsg } } submitInput { @@ -533,8 +531,6 @@ class ProductionAuthProvider(private val discordLogin: DiscordLogin) : AuthProvi override fun installRouting(conf: Routing) { with(conf) { get("/login") { - val errorMsg = call.request.queryParameters["error"] - call.respondHtml(HttpStatusCode.OK, page("Login with Discord", call.standardNavBar()) { section { p { @@ -543,11 +539,12 @@ class ProductionAuthProvider(private val discordLogin: DiscordLogin) : AuthProvi a(href = "/about#pp") { +"Privacy Policy" } +"." } - if (errorMsg != null) + call.request.queryParameters["error"]?.let { errorMsg -> p { style = "color:#d22" +errorMsg } + } p { style = "text-align:center" a(href = "/login/discord") { +"Continue to Discord" } diff --git a/src/jvmMain/kotlin/starshipfights/auth/utils.kt b/src/jvmMain/kotlin/starshipfights/auth/utils.kt index 9808248..8b934a4 100644 --- a/src/jvmMain/kotlin/starshipfights/auth/utils.kt +++ b/src/jvmMain/kotlin/starshipfights/auth/utils.kt @@ -7,11 +7,11 @@ import io.ktor.request.* import io.ktor.sessions.* import io.ktor.util.* import kotlinx.serialization.json.Json -import starshipfights.forbid import starshipfights.data.Id import starshipfights.data.auth.User import starshipfights.data.auth.UserSession import starshipfights.data.createNonce +import starshipfights.invalidCsrfToken import starshipfights.redirect import java.time.Instant import java.time.temporal.ChronoUnit @@ -74,9 +74,11 @@ suspend fun ApplicationCall.receiveValidatedParameters(): Parameters { if (CsrfProtector.verifyNonce(csrfToken, sessionId, request.uri)) return formInput else - forbid() + invalidCsrfToken() } val JsonClientCodec = Json { ignoreUnknownKeys = true } + +fun withErrorMessage(message: String) = "?${parametersOf("error", message).formUrlEncode()}" diff --git a/src/jvmMain/kotlin/starshipfights/data/admiralty/ship_prices.kt b/src/jvmMain/kotlin/starshipfights/data/admiralty/ship_prices.kt index 3eb27cd..fbe6ac3 100644 --- a/src/jvmMain/kotlin/starshipfights/data/admiralty/ship_prices.kt +++ b/src/jvmMain/kotlin/starshipfights/data/admiralty/ship_prices.kt @@ -1,9 +1,10 @@ package starshipfights.data.admiralty -import starshipfights.game.ShipWeightClass +import starshipfights.game.ShipType +import starshipfights.game.pointCost -val ShipWeightClass.buyPrice: Int - get() = basePointCost * 28 / 25 +val ShipType.buyPrice: Int + get() = pointCost * 28 / 25 -val ShipWeightClass.sellPrice: Int - get() = basePointCost * 21 / 25 +val ShipType.sellPrice: Int + get() = pointCost * 21 / 25 diff --git a/src/jvmMain/kotlin/starshipfights/data/auth/user_trophies.kt b/src/jvmMain/kotlin/starshipfights/data/auth/user_trophies.kt index 303b5c0..a498bd4 100644 --- a/src/jvmMain/kotlin/starshipfights/data/auth/user_trophies.kt +++ b/src/jvmMain/kotlin/starshipfights/data/auth/user_trophies.kt @@ -22,7 +22,7 @@ fun ASIDE.renderTrophy(trophy: UserTrophy) = trophy.renderInto(this) object SiteOwnerTrophy : UserTrophy() { override fun ASIDE.render() { p { - style = "text-align:center;border:2px solid #a82;padding:3px;background-color:#fc3;color:#541;font-variant:small-caps;font-family:'Orbitron',sans-serif" + style = "text-align:center;border:2px solid #a82;padding:3px;background-color:#fc3;color:#541;font-variant:small-caps;font-family:'JetBrains Mono',monospace" +"Site Owner" } } @@ -35,7 +35,7 @@ object SiteOwnerTrophy : UserTrophy() { object SiteDeveloperTrophy : UserTrophy() { override fun ASIDE.render() { p { - style = "text-align:center;border:2px solid #62a;padding:3px;background-color:#93f;color:#315;font-variant:small-caps;font-family:'Orbitron',sans-serif" + style = "text-align:center;border:2px solid #62a;padding:3px;background-color:#93f;color:#315;font-variant:small-caps;font-family:'JetBrains Mono',monospace" title = "This person helps with coding the game" +"Site Developer" } @@ -45,10 +45,11 @@ object SiteDeveloperTrophy : UserTrophy() { get() = 1 } +@Serializable data class SiteJanitorTrophy(val isSenior: Boolean) : UserTrophy() { override fun ASIDE.render() { p { - style = "text-align:center;border:2px solid #840;padding:3px;background-color:#c60;color:#420;font-variant:small-caps;font-family:'Orbitron',sans-serif" + style = "text-align:center;border:2px solid #840;padding:3px;background-color:#c60;color:#420;font-variant:small-caps;font-family:'JetBrains Mono',monospace" title = "This person helps with cleaning the poo out of the site" +if (isSenior) "Senior Janitor" else "Janitor" } @@ -62,7 +63,7 @@ data class SiteJanitorTrophy(val isSenior: Boolean) : UserTrophy() { data class SiteSupporterTrophy(val amountInUsCents: Int) : UserTrophy() { override fun ASIDE.render() { p { - style = "text-align:center;border:2px solid #694;padding:3px;background-color:#af7;color:#231;font-variant:small-caps;font-family:'Orbitron',sans-serif" + style = "text-align:center;border:2px solid #694;padding:3px;background-color:#af7;color:#231;font-variant:small-caps;font-family:'JetBrains Mono',monospace" title = "\"I spent money on an online game and all I got was this lousy trophy!\"" +"Site Supporter:" br diff --git a/src/jvmMain/kotlin/starshipfights/game/game_start_jvm.kt b/src/jvmMain/kotlin/starshipfights/game/game_start_jvm.kt index 3d7cb94..6a3ec99 100644 --- a/src/jvmMain/kotlin/starshipfights/game/game_start_jvm.kt +++ b/src/jvmMain/kotlin/starshipfights/game/game_start_jvm.kt @@ -5,11 +5,11 @@ import kotlin.math.PI import kotlin.random.Random suspend fun generateGameStart(hostInfo: InGameAdmiral, guestInfo: InGameAdmiral, battleInfo: BattleInfo, random: Random = Random): GameStart { - val battleWidth = (20..40).random(random) * 500.0 - val battleLength = (30..50).random(random) * 500.0 + val battleWidth = (25..35).random(random) * 500.0 + val battleLength = (15..45).random(random) * 500.0 val deployWidth2 = battleWidth / 2 - val deployLength2 = 1125.0 + val deployLength2 = 875.0 val hostDeployCenter = Position(Vec2(0.0, (-battleLength / 2) + deployLength2)) val guestDeployCenter = Position(Vec2(0.0, (battleLength / 2) - deployLength2)) @@ -22,7 +22,7 @@ suspend fun generateGameStart(hostInfo: InGameAdmiral, guestInfo: InGameAdmiral, PI / 2, PickBoundary.Rectangle(hostDeployCenter, deployWidth2, deployLength2), PI / 2, - getAdmiralsShips(hostInfo.id.reinterpret()).filterValues { it.shipType.weightClass <= battleInfo.size.maxWeightClass } + getAdmiralsShips(hostInfo.id.reinterpret()).filterValues { it.shipType.weightClass.rank <= battleInfo.size.maxWeightClass.rank } ), PlayerStart( @@ -30,7 +30,7 @@ suspend fun generateGameStart(hostInfo: InGameAdmiral, guestInfo: InGameAdmiral, -PI / 2, PickBoundary.Rectangle(guestDeployCenter, deployWidth2, deployLength2), -PI / 2, - getAdmiralsShips(guestInfo.id.reinterpret()).filterValues { it.shipType.weightClass <= battleInfo.size.maxWeightClass } + getAdmiralsShips(guestInfo.id.reinterpret()).filterValues { it.shipType.weightClass.rank <= battleInfo.size.maxWeightClass.rank } ), ) } diff --git a/src/jvmMain/kotlin/starshipfights/info/html_utils.kt b/src/jvmMain/kotlin/starshipfights/info/html_utils.kt index 3b67f55..881ff9e 100644 --- a/src/jvmMain/kotlin/starshipfights/info/html_utils.kt +++ b/src/jvmMain/kotlin/starshipfights/info/html_utils.kt @@ -1,6 +1,8 @@ package starshipfights.info -import kotlinx.html.* +import kotlinx.html.A +import kotlinx.html.FORM +import kotlinx.html.hiddenInput import starshipfights.auth.CsrfProtector import starshipfights.data.Id import starshipfights.data.auth.UserSession diff --git a/src/jvmMain/kotlin/starshipfights/info/view_tpl.kt b/src/jvmMain/kotlin/starshipfights/info/view_tpl.kt index cab67c6..f45f755 100644 --- a/src/jvmMain/kotlin/starshipfights/info/view_tpl.kt +++ b/src/jvmMain/kotlin/starshipfights/info/view_tpl.kt @@ -9,7 +9,7 @@ fun page(pageTitle: String? = null, navBar: List? = null, sidebar: Side link(rel = "icon", type = "image/svg+xml", href = "/static/images/icon.svg") link(rel = "preconnect", href = "https://fonts.googleapis.com") link(rel = "preconnect", href = "https://fonts.gstatic.com") { attributes["crossorigin"] = "anonymous" } - link(rel = "stylesheet", href = "https://fonts.googleapis.com/css2?family=Noto+Sans:ital,wght@0,400;0,700;1,400;1,700&family=Orbitron:wght@500;700;900&display=swap") + link(rel = "stylesheet", href = "https://fonts.googleapis.com/css2?family=Noto+Sans:ital,wght@0,400;0,700;1,400;1,700&family=Jetbrains+Mono:wght@400;600;800&display=swap") link(rel = "stylesheet", href = "/static/style.css") title { diff --git a/src/jvmMain/kotlin/starshipfights/info/views_error.kt b/src/jvmMain/kotlin/starshipfights/info/views_error.kt index 83f9bc2..d548aff 100644 --- a/src/jvmMain/kotlin/starshipfights/info/views_error.kt +++ b/src/jvmMain/kotlin/starshipfights/info/views_error.kt @@ -32,6 +32,14 @@ suspend fun ApplicationCall.error403(): HTML.() -> Unit = page("Not Allowed", st devModeCallId(callId) } +suspend fun ApplicationCall.error403InvalidCsrf(): HTML.() -> Unit = page("CSRF Validation Failed", standardNavBar()) { + section { + h1 { +"CSRF Validation Failed" } + p { +"Unfortunately, the received CSRF failed to validate. Please try again." } + } + devModeCallId(callId) +} + suspend fun ApplicationCall.error404(): HTML.() -> Unit = page("Not Found", standardNavBar()) { section { h1 { +"Not Found" } diff --git a/src/jvmMain/kotlin/starshipfights/info/views_ships.kt b/src/jvmMain/kotlin/starshipfights/info/views_ships.kt index 050ed72..5b40fc7 100644 --- a/src/jvmMain/kotlin/starshipfights/info/views_ships.kt +++ b/src/jvmMain/kotlin/starshipfights/info/views_ships.kt @@ -22,7 +22,7 @@ suspend fun ApplicationCall.shipsPage(): HTML.() -> Unit = page("Game Manual", s h2 { +"Game Mechanics" } h3 { +"Types of Weapons" } p { - +"The four main types of weapons in Starship Fights are cannons, lances, torpedoes, and strike craft. Cannons fire bolts of massive particles that have a chance to miss their target; this chance increases with distance and relative velocity. Lances fire a beam of massless particles that strike their target instantly, however lances also need to be charged by spending Weapons Power. Torpedoes are strong against unshielded hulls, guaranteed to deal two impacts, but are weak against shields, with only a 50% chance to hit if the target has its shields up. Strike craft come in two flavors: fighters and bombers. Fighters are used to defend your ships from bombers, while bombers are used to attack hostile ships." + +"The four main types of weapons in Starship Fights are cannons, lances, torpedoes, and strike craft. Cannons fire bolts of massive particles that have a chance to miss their target; this chance increases with distance. Lances fire a beam of massless particles that strike their target instantly, however lances also need to be charged by spending Weapons Power. Torpedoes are strong against unshielded hulls, guaranteed to deal two impacts, but are weak against shields, with only a 50% chance to hit if the target has its shields up. Strike craft come in two flavors: fighters and bombers. Fighters are used to defend your ships from bombers, while bombers are used to attack hostile ships." } p { +"There are also three types of special weapons: the Mechyrdians' Mega Giga Cannon, the Masra Draetsen Revelation Gun, and the Isarnareyksk EMP Emitter. The Mega Giga Cannon fires a long-range projectile that deals severe damage to enemy ships, but has a limited number of shots. The Revelation Gun instantly vaporizes an enemy ship, but can only be used once in a battle. The EMP Antenna depletes by a random amount the targeted ships' subsystem powers." @@ -36,7 +36,7 @@ suspend fun ApplicationCall.shipsPage(): HTML.() -> Unit = page("Game Manual", s +"Games start with a pre-battle deployment phase, and continue with three-phase turns. During the deploy phase, both players simultaneously deploy their fleets in the area that they are allowed to deploy ships within. Once both players are done deploying, the battle begins: enemy ships are revealed as Signals (not yet identified as ships), and the first Turn starts. The first phase of a turn is the Power Distribution phase: ships distribute power between their various subsystems. Both players take this phase simultaneously." } p { - +"The next phase is the Ship Movement phase: ships turn and then either accelerate or decelerate. The velocity of a ship is the vector distance from the ship's previous position and its current position. If a ship isn't moved manually, it is considered drifting, and will move in accordance with its current velocity. If a ship is moved manually, first it decides a direction and angle to turn towards. Then, it moves along a line centered on what its position would be if it were left to drift; the direction of this line is halfway between the angle that the ship is facing, and the direction of its current velocity. Once both players are done moving their ships, the third and final phase begins." + +"The next phase is the Ship Movement phase: ships turn and then move. Ships turn up to a certain angle away from their current facing, then they move a certain distance away from their current position." } p { +"At the last phase of a turn, ships fire weapons at each other. Again, both players take this phase simultaneously. Ships fire weapons at enemy ships, as far as they can and as much as they can. Damage from weapons is inflicted on targeted ships instantly, so players are encouraged to click fast when attacking enemy ships. Note that this does not apply to strike craft; they are deployed to ships, with percentages on the ship labels indicating the total strength of all strike wings surrounding a ship, and the damage done by bombers is calculated at the end of the phase, before the next turn begins." @@ -91,7 +91,7 @@ suspend fun ApplicationCall.shipPage(shipType: ShipType): HTML.() -> Unit = page td { +shipType.weightClass.displayName br - +"(${shipType.weightClass.basePointCost} points to deploy)" + +"(${shipType.pointCost} points to deploy)" } td { +"${shipType.weightClass.durability.maxHullPoints} impacts" diff --git a/src/jvmMain/kotlin/starshipfights/info/views_user.kt b/src/jvmMain/kotlin/starshipfights/info/views_user.kt index 7132c9c..8bccd9a 100644 --- a/src/jvmMain/kotlin/starshipfights/info/views_user.kt +++ b/src/jvmMain/kotlin/starshipfights/info/views_user.kt @@ -570,7 +570,7 @@ suspend fun ApplicationCall.manageAdmiralPage(): HTML.() -> Unit { val ownedShips = ShipInDrydock.filter(ShipInDrydock::owningAdmiral eq admiralId).toList() val buyableShips = ShipType.values().filter { type -> - type.faction == admiral.faction && type.weightClass.rank <= admiral.rank.maxShipWeightClass.rank && type.weightClass.buyPrice <= admiral.money && (if (type.weightClass.isUnique) ownedShips.none { it.shipType.weightClass == type.weightClass } else true) + type.faction == admiral.faction && type.weightClass.rank <= admiral.rank.maxShipWeightClass.rank && type.buyPrice <= admiral.money && (if (type.weightClass.isUnique) ownedShips.none { it.shipType.weightClass == type.weightClass } else true) }.sortedBy { it.name }.sortedBy { it.weightClass.rank } return page( @@ -582,6 +582,12 @@ suspend fun ApplicationCall.manageAdmiralPage(): HTML.() -> Unit { ) { section { h1 { +"Managing ${admiral.name}" } + request.queryParameters["error"]?.let { errorMsg -> + p { + style = "color:#d22" + +errorMsg + } + } form(method = FormMethod.post, action = "/admiral/${admiral.id}/manage") { csrfToken(currentSession.id) h3 { @@ -716,7 +722,7 @@ suspend fun ApplicationCall.manageAdmiralPage(): HTML.() -> Unit { } } td { - +ship.shipType.weightClass.sellPrice.toString() + +ship.shipType.sellPrice.toString() +" " +admiral.faction.currencyName if (ship.readyAt <= now && !ship.shipType.weightClass.isUnique) { @@ -739,7 +745,7 @@ suspend fun ApplicationCall.manageAdmiralPage(): HTML.() -> Unit { a(href = "/info/${st.toUrlSlug()}") { +st.fullDisplayName } } td { - +st.weightClass.buyPrice.toString() + +st.buyPrice.toString() +" " +admiral.faction.currencyName br @@ -827,7 +833,7 @@ suspend fun ApplicationCall.sellShipConfirmPage(): HTML.() -> Unit { section { h1 { +"Are You Sure?" } p { - +"${admiral.fullName} is about to sell the ${ship.shipType.fullDisplayName} ${ship.shipData.fullName} for ${ship.shipType.weightClass.sellPrice} ${admiral.faction.currencyName}." + +"${admiral.fullName} is about to sell the ${ship.shipType.fullDisplayName} ${ship.shipData.fullName} for ${ship.shipType.sellPrice} ${admiral.faction.currencyName}." } form(method = FormMethod.get, action = "/admiral/${admiral.id}/manage") { submitInput { @@ -858,14 +864,14 @@ suspend fun ApplicationCall.buyShipConfirmPage(): HTML.() -> Unit { if (shipType.faction != admiral.faction || shipType.weightClass.rank > admiral.rank.maxShipWeightClass.rank) throw NotFoundException() - if (shipType.weightClass.buyPrice > admiral.money) { + if (shipType.buyPrice > admiral.money) { return page( "Too Expensive", null, null ) { section { h1 { +"Too Expensive" } p { - +"Unfortunately, the ${shipType.fullDisplayName} is out of ${admiral.fullName}'s budget. It costs ${shipType.weightClass.buyPrice} ${admiral.faction.currencyName}, and ${admiral.name} only has ${admiral.money} ${admiral.faction.currencyName}." + +"Unfortunately, the ${shipType.fullDisplayName} is out of ${admiral.fullName}'s budget. It costs ${shipType.buyPrice} ${admiral.faction.currencyName}, and ${admiral.name} only has ${admiral.money} ${admiral.faction.currencyName}." } form(method = FormMethod.get, action = "/admiral/${admiral.id}/manage") { submitInput { @@ -882,7 +888,7 @@ suspend fun ApplicationCall.buyShipConfirmPage(): HTML.() -> Unit { section { h1 { +"Are You Sure?" } p { - +"${admiral.fullName} is about to buy a ${shipType.fullDisplayName} for ${shipType.weightClass.buyPrice} ${admiral.faction.currencyName}." + +"${admiral.fullName} is about to buy a ${shipType.fullDisplayName} for ${shipType.buyPrice} ${admiral.faction.currencyName}." } form(method = FormMethod.get, action = "/admiral/${admiral.id}/manage") { submitInput { diff --git a/src/jvmMain/kotlin/starshipfights/server.kt b/src/jvmMain/kotlin/starshipfights/server.kt index 3453643..a605ecc 100644 --- a/src/jvmMain/kotlin/starshipfights/server.kt +++ b/src/jvmMain/kotlin/starshipfights/server.kt @@ -20,7 +20,6 @@ import starshipfights.data.DataRoutines import starshipfights.game.installGame import starshipfights.info.* import java.io.InputStream -import java.lang.IllegalArgumentException import java.util.concurrent.atomic.AtomicLong object ResourceLoader { @@ -70,6 +69,9 @@ fun main() { exception { call.respondHtml(HttpStatusCode.Forbidden, call.error403()) } + exception { + call.respondHtml(HttpStatusCode.Forbidden, call.error403InvalidCsrf()) + } exception { call.respondHtml(HttpStatusCode.NotFound, call.error404()) } diff --git a/src/jvmMain/kotlin/starshipfights/server_utils.kt b/src/jvmMain/kotlin/starshipfights/server_utils.kt index 3d0b138..6fb201f 100644 --- a/src/jvmMain/kotlin/starshipfights/server_utils.kt +++ b/src/jvmMain/kotlin/starshipfights/server_utils.kt @@ -3,13 +3,20 @@ package starshipfights import org.slf4j.Logger import org.slf4j.LoggerFactory -class ForbiddenException : IllegalArgumentException() +open class ForbiddenException : IllegalArgumentException() + fun forbid(): Nothing = throw ForbiddenException() +class InvalidCsrfTokenException : ForbiddenException() + +fun invalidCsrfToken(): Nothing = throw InvalidCsrfTokenException() + data class HttpRedirectException(val url: String, val permanent: Boolean) : RuntimeException() + fun redirect(url: String, permanent: Boolean = false): Nothing = throw HttpRedirectException(url, permanent) class RateLimitException : RuntimeException() + fun rateLimit(): Nothing = throw RateLimitException() val sfLogger: Logger = LoggerFactory.getLogger("StarshipFights") diff --git a/src/jvmMain/resources/static/style.css b/src/jvmMain/resources/static/style.css index fec0705..5650586 100644 --- a/src/jvmMain/resources/static/style.css +++ b/src/jvmMain/resources/static/style.css @@ -30,7 +30,7 @@ div#bg { } h1, h2, h3 { - font-family: 'Orbitron', sans-serif; + font-family: 'JetBrains Mono', monospace; margin: 0.5em 0; } @@ -46,20 +46,20 @@ h1 { background-color: #aaa; font-variant: small-caps; font-size: 2.6em; - font-weight: 900; + font-weight: 800; } h2 { border-bottom: 2px solid #666; font-size: 2.2em; - font-weight: 700; + font-weight: 600; } h3 { text-decoration: underline; text-decoration-color: #888; font-size: 1.8em; - font-weight: 500; + font-weight: 400; } main { @@ -217,7 +217,7 @@ th { text-align: center; vertical-align: center; - font-family: 'Orbitron', sans-serif; + font-family: 'JetBrains Mono', monospace; font-size: 0.8em; font-variant: small-caps; font-weight: 700; -- 2.25.1