diff --git a/resources/lang/debug.json b/resources/lang/debug.json index 924720ce31..bf7b627718 100644 --- a/resources/lang/debug.json +++ b/resources/lang/debug.json @@ -145,6 +145,7 @@ "options_title": "host_modal.options_title", "bots": "host_modal.bots", "bots_disabled": "host_modal.bots_disabled", + "spawn_immunity_duration": "host_modal.spawn_immunity_duration", "disable_nations": "host_modal.disable_nations", "instant_build": "host_modal.instant_build", "random_spawn": "host_modal.random_spawn", diff --git a/resources/lang/en.json b/resources/lang/en.json index c2e03cedcf..dff0b2accd 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -298,6 +298,7 @@ "options_title": "Options", "bots": "Bots: ", "bots_disabled": "Disabled", + "spawn_immunity_duration": "Spawn immunity (seconds)", "nations": "Nations: ", "disable_nations": "Disable Nations", "max_timer": "Game length (minutes)", diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index 247ebe441c..64580324b9 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -23,6 +23,7 @@ import { TeamCountConfig, } from "../core/Schemas"; import { generateID } from "../core/Util"; +import "./components/baseComponents/Button"; import "./components/baseComponents/Modal"; import "./components/Difficulties"; import "./components/FluentSlider"; @@ -45,6 +46,7 @@ export class HostLobbyModal extends LitElement { @state() private gameMode: GameMode = GameMode.FFA; @state() private teamCount: TeamCountConfig = 2; @state() private bots: number = 400; + @state() private spawnImmunityDurationSeconds: number = 5; @state() private infiniteGold: boolean = false; @state() private donateGold: boolean = false; @state() private infiniteTroops: boolean = false; @@ -520,6 +522,27 @@ export class HostLobbyModal extends LitElement { ${translateText("host_modal.max_timer")} + + +
@@ -687,6 +710,23 @@ export class HostLobbyModal extends LitElement { this.putGameConfig(); } + private handleSpawnImmunityDurationKeyDown(e: KeyboardEvent) { + if (["-", "+", "e", "E"].includes(e.key)) { + e.preventDefault(); + } + } + + private handleSpawnImmunityDurationInput(e: Event) { + const input = e.target as HTMLInputElement; + input.value = input.value.replace(/[eE+-]/g, ""); + const value = parseInt(input.value, 10); + if (Number.isNaN(value) || value < 0 || value > 300) { + return; + } + this.spawnImmunityDurationSeconds = value; + this.putGameConfig(); + } + private handleRandomSpawnChange(e: Event) { this.randomSpawn = Boolean((e.target as HTMLInputElement).checked); this.putGameConfig(); @@ -776,6 +816,7 @@ export class HostLobbyModal extends LitElement { randomSpawn: this.randomSpawn, gameMode: this.gameMode, disabledUnits: this.disabledUnits, + spawnImmunityDuration: this.spawnImmunityDurationSeconds * 10, playerTeams: this.teamCount, ...(this.gameMode === GameMode.Team && this.teamCount === HumansVsNations diff --git a/src/client/SinglePlayerModal.ts b/src/client/SinglePlayerModal.ts index f65c955cfb..ee368d04e8 100644 --- a/src/client/SinglePlayerModal.ts +++ b/src/client/SinglePlayerModal.ts @@ -582,6 +582,7 @@ export class SinglePlayerModal extends LitElement { infiniteTroops: this.infiniteTroops, instantBuild: this.instantBuild, randomSpawn: this.randomSpawn, + spawnImmunityDuration: 5 * 10, disabledUnits: this.disabledUnits .map((u) => Object.values(UnitType).find((ut) => ut === u)) .filter((ut): ut is UnitType => ut !== undefined), diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index 152a82587b..a8c722b53e 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -9,6 +9,7 @@ import { UIState } from "./UIState"; import { AdTimer } from "./layers/AdTimer"; import { AlertFrame } from "./layers/AlertFrame"; import { BuildMenu } from "./layers/BuildMenu"; +import { CeasefireTimer } from "./layers/CeasefireTimer"; import { ChatDisplay } from "./layers/ChatDisplay"; import { ChatModal } from "./layers/ChatModal"; import { ControlPanel } from "./layers/ControlPanel"; @@ -229,6 +230,14 @@ export function createRenderer( spawnTimer.game = game; spawnTimer.transformHandler = transformHandler; + const ceasefireTimer = document.querySelector( + "ceasefire-timer", + ) as CeasefireTimer; + if (!(ceasefireTimer instanceof CeasefireTimer)) { + console.error("ceasefire timer not found"); + } + ceasefireTimer.game = game; + // When updating these layers please be mindful of the order. // Try to group layers by the return value of shouldTransform. // Not grouping the layers may cause excessive calls to context.save() and context.restore(). @@ -257,6 +266,7 @@ export function createRenderer( playerPanel, ), spawnTimer, + ceasefireTimer, leaderboard, gameLeftSidebar, unitDisplay, diff --git a/src/client/graphics/layers/CeasefireTimer.ts b/src/client/graphics/layers/CeasefireTimer.ts new file mode 100644 index 0000000000..0895d9557e --- /dev/null +++ b/src/client/graphics/layers/CeasefireTimer.ts @@ -0,0 +1,93 @@ +import { LitElement, html } from "lit"; +import { customElement } from "lit/decorators.js"; +import { GameMode } from "../../../core/game/Game"; +import { GameView } from "../../../core/game/GameView"; +import { Layer } from "./Layer"; + +@customElement("ceasefire-timer") +export class CeasefireTimer extends LitElement implements Layer { + public game: GameView; + + private isVisible = false; + private isActive = false; + private progressRatio = 0; + + createRenderRoot() { + this.style.position = "fixed"; + this.style.top = "0"; + this.style.left = "0"; + this.style.width = "100%"; + this.style.height = "7px"; + this.style.zIndex = "1000"; + this.style.pointerEvents = "none"; + return this; + } + + init() { + this.isVisible = true; + } + + tick() { + if (!this.game || !this.isVisible) { + return; + } + + const showTeamOwnershipBar = + this.game.config().gameConfig().gameMode === GameMode.Team && + !this.game.inSpawnPhase(); + + this.style.top = showTeamOwnershipBar ? "7px" : "0px"; + + const ceasefireDuration = this.game.config().spawnImmunityDuration(); + const spawnPhaseTurns = this.game.config().numSpawnPhaseTurns(); + + if (ceasefireDuration <= 5 * 10 || this.game.inSpawnPhase()) { + this.setInactive(); + return; + } + + const ceasefireEnd = spawnPhaseTurns + ceasefireDuration; + const ticks = this.game.ticks(); + + if (ticks >= ceasefireEnd || ticks < spawnPhaseTurns) { + this.setInactive(); + return; + } + + const elapsedTicks = Math.max(0, ticks - spawnPhaseTurns); + this.progressRatio = Math.min( + 1, + Math.max(0, elapsedTicks / ceasefireDuration), + ); + this.isActive = true; + this.requestUpdate(); + } + + private setInactive() { + if (this.isActive) { + this.isActive = false; + this.requestUpdate(); + } + } + + shouldTransform(): boolean { + return false; + } + + render() { + if (!this.isVisible || !this.isActive) { + return html``; + } + + const widthPercent = this.progressRatio * 100; + + return html` +
+
+
+ `; + } +} diff --git a/src/client/index.html b/src/client/index.html index 868c8e1df3..1c3e6824e2 100644 --- a/src/client/index.html +++ b/src/client/index.html @@ -464,6 +464,7 @@ + diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index 27ceb7c053..5e66359826 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -175,6 +175,9 @@ export const GameConfigSchema = z.object({ randomSpawn: z.boolean(), maxPlayers: z.number().optional(), maxTimerValue: z.number().int().min(1).max(120).optional(), + // startingGold: z.number().int().min(0).max(10*1000*1000), // maybe in steps instead? good way of setting max? default 0? + // incomeMultiplier: z.number().min(0).max(10), + spawnImmunityDuration: z.number().int().min(0).max(3000), // In ticks (10 per second) disabledUnits: z.enum(UnitType).array().optional(), playerTeams: TeamCountConfigSchema.optional(), }); diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index ec22579a94..c88a349df1 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -241,7 +241,7 @@ export class DefaultConfig implements Config { return 30 * 10; // 30 seconds } spawnImmunityDuration(): Tick { - return 5 * 10; + return this._gameConfig.spawnImmunityDuration; } gameConfig(): GameConfig { diff --git a/src/core/execution/AttackExecution.ts b/src/core/execution/AttackExecution.ts index 5997c13202..bb836e5111 100644 --- a/src/core/execution/AttackExecution.ts +++ b/src/core/execution/AttackExecution.ts @@ -91,10 +91,12 @@ export class AttackExecution implements Execution { } if (this.target.isPlayer()) { + const targetPlayer = this.target as Player; if ( + targetPlayer.type() === PlayerType.Human && this.mg.config().numSpawnPhaseTurns() + this.mg.config().spawnImmunityDuration() > - this.mg.ticks() + this.mg.ticks() ) { console.warn("cannot attack player during immunity phase"); this.active = false; diff --git a/src/core/execution/TransportShipExecution.ts b/src/core/execution/TransportShipExecution.ts index 6e37e066d1..89b8d823a4 100644 --- a/src/core/execution/TransportShipExecution.ts +++ b/src/core/execution/TransportShipExecution.ts @@ -5,6 +5,7 @@ import { MessageType, Player, PlayerID, + PlayerType, TerraNullius, Unit, UnitType, @@ -94,6 +95,19 @@ export class TransportShipExecution implements Execution { this.target = mg.player(this.targetID); } + if ( + this.target.isPlayer() && + this.mg.config().numSpawnPhaseTurns() + + this.mg.config().spawnImmunityDuration() > + this.mg.ticks() + ) { + const targetPlayer = this.target as Player; + if (targetPlayer.type() === PlayerType.Human) { + this.active = false; + return; + } + } + this.startTroops ??= this.mg .config() .boatAttackAmount(this.attacker, this.target); diff --git a/src/core/execution/WarshipExecution.ts b/src/core/execution/WarshipExecution.ts index 1ddb064cbe..98ace580cb 100644 --- a/src/core/execution/WarshipExecution.ts +++ b/src/core/execution/WarshipExecution.ts @@ -61,15 +61,20 @@ export class WarshipExecution implements Execution { this.warship.modifyHealth(1); } - this.warship.setTargetUnit(this.findTargetUnit()); - if (this.warship.targetUnit()?.type() === UnitType.TradeShip) { - this.huntDownTradeShip(); - return; + const spawnImmunityActive = this.isSpawnImmunityActive(); + if (spawnImmunityActive) { + this.warship.setTargetUnit(undefined); + } else { + this.warship.setTargetUnit(this.findTargetUnit()); + if (this.warship.targetUnit()?.type() === UnitType.TradeShip) { + this.huntDownTradeShip(); + return; + } } this.patrol(); - if (this.warship.targetUnit() !== undefined) { + if (!spawnImmunityActive && this.warship.targetUnit() !== undefined) { this.shootTarget(); return; } @@ -279,4 +284,12 @@ export class WarshipExecution implements Execution { } return undefined; } + + private isSpawnImmunityActive(): boolean { + return ( + this.mg.config().numSpawnPhaseTurns() + + this.mg.config().spawnImmunityDuration() > + this.mg.ticks() + ); + } } diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts index 4ac72fd742..2ba24efefb 100644 --- a/src/core/game/PlayerImpl.ts +++ b/src/core/game/PlayerImpl.ts @@ -1009,6 +1009,13 @@ export class PlayerImpl implements Player { } nukeSpawn(tile: TileRef): TileRef | false { + if ( + this.mg.config().numSpawnPhaseTurns() + + this.mg.config().spawnImmunityDuration() > + this.mg.ticks() + ) { + return false; + } const owner = this.mg.owner(tile); if (owner.isPlayer()) { if (this.isOnSameTeam(owner)) { @@ -1200,8 +1207,10 @@ export class PlayerImpl implements Player { } public canAttack(tile: TileRef): boolean { + const owner = this.mg.owner(tile); if ( - this.mg.hasOwner(tile) && + owner.isPlayer() && + owner.type() === PlayerType.Human && this.mg.config().numSpawnPhaseTurns() + this.mg.config().spawnImmunityDuration() > this.mg.ticks() @@ -1209,12 +1218,11 @@ export class PlayerImpl implements Player { return false; } - if (this.mg.owner(tile) === this) { + if (owner === this) { return false; } - const other = this.mg.owner(tile); - if (other.isPlayer()) { - if (this.isFriendly(other)) { + if (owner.isPlayer()) { + if (this.isFriendly(owner)) { return false; } } @@ -1223,7 +1231,7 @@ export class PlayerImpl implements Player { return false; } if (this.mg.hasOwner(tile)) { - return this.sharesBorderWith(other); + return this.sharesBorderWith(owner); } else { for (const t of this.mg.bfs( tile, diff --git a/src/core/game/TransportShipUtils.ts b/src/core/game/TransportShipUtils.ts index a0af53526a..c386ee19a9 100644 --- a/src/core/game/TransportShipUtils.ts +++ b/src/core/game/TransportShipUtils.ts @@ -1,6 +1,6 @@ import { PathFindResultType } from "../pathfinding/AStar"; import { MiniAStar } from "../pathfinding/MiniAStar"; -import { Game, Player, UnitType } from "./Game"; +import { Game, Player, PlayerType, UnitType } from "./Game"; import { andFN, GameMap, manhattanDistFN, TileRef } from "./GameMap"; export function canBuildTransportShip( @@ -8,6 +8,16 @@ export function canBuildTransportShip( player: Player, tile: TileRef, ): TileRef | false { + if ( + game.config().numSpawnPhaseTurns() + game.config().spawnImmunityDuration() > + game.ticks() + ) { + const targetOwner = game.owner(tile); + if (targetOwner.isPlayer() && targetOwner.type() === PlayerType.Human) { + return false; + } + } + if ( player.unitCount(UnitType.TransportShip) >= game.config().boatMaxNumber() ) { diff --git a/src/server/GameManager.ts b/src/server/GameManager.ts index 186212f420..171950f608 100644 --- a/src/server/GameManager.ts +++ b/src/server/GameManager.ts @@ -73,6 +73,7 @@ export class GameManager { randomSpawn: false, gameMode: GameMode.FFA, bots: 400, + spawnImmunityDuration: 5 * 10, disabledUnits: [], ...gameConfig, }, diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index 428275f3c0..7f2121db05 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -119,6 +119,9 @@ export class GameServer { if (gameConfig.randomSpawn !== undefined) { this.gameConfig.randomSpawn = gameConfig.randomSpawn; } + if (gameConfig.spawnImmunityDuration !== undefined) { + this.gameConfig.spawnImmunityDuration = gameConfig.spawnImmunityDuration; + } if (gameConfig.gameMode !== undefined) { this.gameConfig.gameMode = gameConfig.gameMode; } diff --git a/src/server/MapPlaylist.ts b/src/server/MapPlaylist.ts index 22c258f59f..cda20ddb94 100644 --- a/src/server/MapPlaylist.ts +++ b/src/server/MapPlaylist.ts @@ -105,6 +105,7 @@ export class MapPlaylist { gameMode: mode, playerTeams, bots: 400, + spawnImmunityDuration: 5 * 10, disabledUnits: [], } satisfies GameConfig; } diff --git a/tests/util/Setup.ts b/tests/util/Setup.ts index e9b2722ee6..f803f1823d 100644 --- a/tests/util/Setup.ts +++ b/tests/util/Setup.ts @@ -65,6 +65,7 @@ export async function setup( donateGold: false, donateTroops: false, bots: 0, + spawnImmunityDuration: 0, infiniteGold: false, infiniteTroops: false, instantBuild: false,