diff --git a/resources/lang/en.json b/resources/lang/en.json index e911c92c5e..f9ac4f2f81 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -704,8 +704,11 @@ "random_spawn": "Random spawn", "remove_player": "Remove {username}", "start": "Start Game", + "start_delay": "Start delay (Seconds)", + "start_delay_placeholder": "3", "starting_gold": "Starting Gold (Millions)", "starting_gold_placeholder": "5", + "starting_in": "Starting in {time}. Click to cancel", "team_count": "Number of Teams", "teams_Duos": "Duos (teams of 2)", "teams_Humans Vs Nations": "Humans vs Nations", diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index 322cc68add..29a0bd9010 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -1,7 +1,12 @@ import { html } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { ClientEnv } from "src/client/ClientEnv"; -import { translateText } from "../client/Utils"; +import { + calculateServerTimeOffset, + getSecondsUntilServerTimestamp, + renderDuration, + translateText, +} from "../client/Utils"; import { EventBus } from "../core/EventBus"; import { Difficulty, @@ -25,6 +30,7 @@ import "./components/baseComponents/Modal"; import { BaseModal } from "./components/BaseModal"; import { CopyButton } from "./components/CopyButton"; import "./components/GameConfigSettings"; +import "./components/InputCard"; import "./components/LobbyPlayerView"; import "./components/ToggleInputCard"; import { modalHeader } from "./components/ui/ModalHeader"; @@ -65,6 +71,7 @@ export class HostLobbyModal extends BaseModal { @state() private donateTroops: boolean = false; @state() private maxTimer: boolean = false; @state() private maxTimerValue: number | undefined = undefined; + @state() private startDelayValue: number | undefined = 3; @state() private instantBuild: boolean = false; @state() private randomSpawn: boolean = false; @state() private compactMap: boolean = false; @@ -87,6 +94,8 @@ export class HostLobbyModal extends BaseModal { @state() private hostCheatStartingGold: boolean = false; @state() private hostCheatStartingGoldValue: number | undefined = undefined; @state() private lobbyCreatorClientID: string = ""; + @state() private lobbyStartAt: number | null = null; + @state() private serverTimeOffset: number = 0; @property({ attribute: false }) eventBus: EventBus | null = null; // Timers for debouncing slider changes @@ -102,6 +111,10 @@ export class HostLobbyModal extends BaseModal { if (!this.lobbyId || lobby.gameID !== this.lobbyId) { return; } + if ("serverTime" in lobby && typeof lobby.serverTime === "number") { + this.serverTimeOffset = calculateServerTimeOffset(lobby.serverTime); + } + this.lobbyStartAt = lobby.startsAt ?? null; this.lobbyCreatorClientID = lobby.lobbyCreatorClientID ?? ""; if (lobby.clients) { this.clients = lobby.clients; @@ -180,6 +193,22 @@ export class HostLobbyModal extends BaseModal { } protected renderBody() { + const secondsRemaining = + this.lobbyStartAt !== null + ? getSecondsUntilServerTimestamp( + this.lobbyStartAt, + this.serverTimeOffset, + ) + : null; + const statusLabel = + secondsRemaining === null + ? this.clients.length === 1 + ? translateText("host_modal.waiting") + : translateText("host_modal.start") + : translateText("host_modal.starting_in", { + time: renderDuration(secondsRemaining), + }); + const inputCards = [ html``, + html``, html` @@ -419,14 +462,13 @@ export class HostLobbyModal extends BaseModal { variant="primary" width="block" size="lg" - .title=${this.clients.length === 1 - ? translateText("host_modal.waiting") - : translateText("host_modal.start")} - ?disable=${this.clients.length < 2} - @click=${this.startGame} + .title=${statusLabel} + ?disable=${this.lobbyStartAt === null && this.clients.length < 2} + @click=${this.toggleGameStartTimer} > + `; } @@ -529,6 +571,7 @@ export class HostLobbyModal extends BaseModal { this.donateTroops = false; this.maxTimer = false; this.maxTimerValue = undefined; + this.startDelayValue = 3; this.instantBuild = false; this.randomSpawn = false; this.compactMap = false; @@ -900,6 +943,26 @@ export class HostLobbyModal extends BaseModal { this.putGameConfig(); }; + private handleStartDelayValueKeyDown = (e: KeyboardEvent) => { + preventDisallowedKeys(e, ["-", "+", "e", "E"]); + }; + + private handleStartDelayValueChanges = (e: Event) => { + const input = e.target as HTMLInputElement; + const value = parseBoundedIntegerFromInput(input, { + min: 0, + max: 600, + }); + + if (value === undefined) { + this.startDelayValue = undefined; + input.value = ""; + } else { + this.startDelayValue = value; + } + this.putGameConfig(); + }; + private handleNationsChange = (e: Event) => { const customEvent = e as CustomEvent<{ value: number }>; const value = customEvent.detail.value; @@ -967,6 +1030,7 @@ export class HostLobbyModal extends BaseModal { this.defaultNationCount, ), maxTimerValue: this.maxTimer === true ? this.maxTimerValue : null, + startDelay: this.startDelayValue, goldMultiplier: this.goldMultiplier === true ? this.goldMultiplierValue : null, startingGold: @@ -998,7 +1062,7 @@ export class HostLobbyModal extends BaseModal { ); } - private async startGame() { + private async toggleGameStartTimer() { await this.putGameConfig(); console.log( `Starting private game with map: ${GameMapType[this.selectedMap as keyof typeof GameMapType]} ${this.useRandomMap ? " (Randomly selected)" : ""}`, @@ -1008,7 +1072,7 @@ export class HostLobbyModal extends BaseModal { this.leaveLobbyOnClose = false; this.dispatchEvent( - new CustomEvent("start-game", { + new CustomEvent("toggle_game_start_timer", { bubbles: true, composed: true, }), diff --git a/src/client/JoinLobbyModal.ts b/src/client/JoinLobbyModal.ts index 115bff0fa4..4ced7c3184 100644 --- a/src/client/JoinLobbyModal.ts +++ b/src/client/JoinLobbyModal.ts @@ -112,7 +112,9 @@ export class JoinLobbyModal extends BaseModal { : null; const statusLabel = secondsRemaining === null - ? translateText("public_lobby.waiting_for_players") + ? this.isPrivateLobby() + ? translateText("private_lobby.joined_waiting") + : translateText("public_lobby.waiting_for_players") : secondsRemaining > 0 ? translateText("public_lobby.starting_in", { time: renderDuration(secondsRemaining), @@ -162,56 +164,39 @@ export class JoinLobbyModal extends BaseModal { `} - ${this.isPrivateLobby() - ? html` - - + + + ${translateText("public_lobby.status")} - ${translateText("private_lobby.joined_waiting")} - + ${statusLabel} - ` - : html` - - - - ${translateText("public_lobby.status")} - ${statusLabel} 0 + ? html` + - - ${maxPlayers > 0 - ? html` - - ${playerCount}/${maxPlayers} - - - - - ` - : html``} - - - `} + ${playerCount}/${maxPlayers} + + + + + ` + : html``} + + + `} `; } diff --git a/src/client/Main.ts b/src/client/Main.ts index d63f691c6a..08c22f192a 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -54,7 +54,7 @@ import { TerritoryPatternsModal } from "./TerritoryPatternsModal"; import { TokenLoginModal } from "./TokenLoginModal"; import { SendKickPlayerIntentEvent, - SendStartGameEvent, + SendToggleGameStartTimer, SendUpdateGameConfigIntentEvent, } from "./Transport"; import { UserSettingModal } from "./UserSettingModal"; @@ -219,7 +219,7 @@ declare global { interface DocumentEventMap { "join-lobby": CustomEvent; "kick-player": CustomEvent; - "start-game": CustomEvent; + toggle_game_start_timer: CustomEvent; "join-changed": CustomEvent; "open-matchmaking": CustomEvent; userMeResponse: CustomEvent; @@ -376,7 +376,10 @@ class Client { document.addEventListener("join-lobby", this.handleJoinLobby.bind(this)); document.addEventListener("leave-lobby", this.handleLeaveLobby.bind(this)); document.addEventListener("kick-player", this.handleKickPlayer.bind(this)); - document.addEventListener("start-game", this.handleStartGame.bind(this)); + document.addEventListener( + "toggle_game_start_timer", + this.handleToggleGameStartTimer.bind(this), + ); document.addEventListener( "update-game-config", this.handleUpdateGameConfig.bind(this), @@ -1008,9 +1011,9 @@ class Client { } } - private handleStartGame() { + private handleToggleGameStartTimer() { if (this.eventBus) { - this.eventBus.emit(new SendStartGameEvent()); + this.eventBus.emit(new SendToggleGameStartTimer()); } } diff --git a/src/client/Transport.ts b/src/client/Transport.ts index fee60b966c..8115f32417 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -174,7 +174,9 @@ export class SendUpdateGameConfigIntentEvent implements GameEvent { constructor(public readonly config: Partial) {} } -export class SendStartGameEvent implements GameEvent {} +export class SendToggleGameStartTimer implements GameEvent { + constructor() {} +} export class Transport { private socket: WebSocket | null = null; @@ -266,7 +268,9 @@ export class Transport { this.onSendUpdateGameConfigIntent(e), ); - this.eventBus.on(SendStartGameEvent, () => this.onSendStartGame()); + this.eventBus.on(SendToggleGameStartTimer, (e) => + this.onSendToggleGameStartTimer(e), + ); } private startPing() { @@ -647,8 +651,8 @@ export class Transport { }); } - private onSendStartGame() { - this.sendIntent({ type: "start_game" }); + private onSendToggleGameStartTimer(event: SendToggleGameStartTimer) { + this.sendIntent({ type: "toggle_game_start_timer" }); } private sendIntent(intent: Intent) { diff --git a/src/client/components/InputCard.ts b/src/client/components/InputCard.ts new file mode 100644 index 0000000000..74e834b3c9 --- /dev/null +++ b/src/client/components/InputCard.ts @@ -0,0 +1,57 @@ +import { html, LitElement, nothing } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { translateText } from "../Utils"; +import { CARD_LABEL_CLASS, cardClass, INPUT_CLASS } from "./InputCardStyles"; + +@customElement("input-card") +export class InputCard extends LitElement { + @property({ attribute: false }) labelKey = ""; + @property({ attribute: false }) inputId?: string; + @property({ attribute: false }) inputType = "number"; + @property({ attribute: false }) inputMin?: number | string; + @property({ attribute: false }) inputMax?: number | string; + @property({ attribute: false }) inputStep?: number | string; + @property({ attribute: false }) inputValue?: number | string; + @property({ attribute: false }) inputAriaLabel?: string; + @property({ attribute: false }) inputPlaceholder?: string; + @property({ attribute: false }) onInput?: (e: Event) => void; + @property({ attribute: false }) onChange?: (e: Event) => void; + @property({ attribute: false }) onKeyDown?: (e: KeyboardEvent) => void; + + createRenderRoot() { + return this; + } + + render() { + return html` + + + + + + ${translateText(this.labelKey)} + + + + + + + + `; + } +} diff --git a/src/client/components/InputCardStyles.ts b/src/client/components/InputCardStyles.ts new file mode 100644 index 0000000000..18dac1685f --- /dev/null +++ b/src/client/components/InputCardStyles.ts @@ -0,0 +1,12 @@ +export const ACTIVE_CARD = + "bg-malibu-blue/20 border-malibu-blue/50 shadow-[var(--shadow-malibu-blue)]"; +export const INACTIVE_CARD = + "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20"; +export const INPUT_CLASS = + "w-full text-center rounded bg-black/60 text-white text-sm font-bold border border-white/20 focus:outline-none focus:border-malibu-blue p-1 my-1"; +export const CARD_LABEL_CLASS = + "text-xs uppercase font-bold tracking-wider leading-tight break-words hyphens-auto"; + +export function cardClass(active: boolean, extra = ""): string { + return `w-full h-full rounded-xl border cursor-pointer transition-all duration-200 active:scale-95 relative overflow-hidden ${extra} ${active ? ACTIVE_CARD : INACTIVE_CARD}`; +} diff --git a/src/client/components/ToggleInputCard.ts b/src/client/components/ToggleInputCard.ts index b6b816db20..58bd77f3fd 100644 --- a/src/client/components/ToggleInputCard.ts +++ b/src/client/components/ToggleInputCard.ts @@ -1,19 +1,7 @@ import { LitElement, PropertyValues, html, nothing } from "lit"; import { customElement, property } from "lit/decorators.js"; import { translateText } from "../Utils"; - -const ACTIVE_CARD = - "bg-malibu-blue/20 border-malibu-blue/50 shadow-[var(--shadow-malibu-blue)]"; -const INACTIVE_CARD = - "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20"; -const INPUT_CLASS = - "w-full text-center rounded bg-black/60 text-white text-sm font-bold border border-white/20 focus:outline-none focus:border-malibu-blue p-1 my-1"; -const CARD_LABEL_CLASS = - "text-xs uppercase font-bold tracking-wider leading-tight break-words hyphens-auto"; - -function cardClass(active: boolean, extra = ""): string { - return `w-full h-full rounded-xl border cursor-pointer transition-all duration-200 active:scale-95 ${extra} ${active ? ACTIVE_CARD : INACTIVE_CARD}`; -} +import { CARD_LABEL_CLASS, INPUT_CLASS, cardClass } from "./InputCardStyles"; @customElement("toggle-input-card") export class ToggleInputCard extends LitElement { @@ -103,7 +91,7 @@ export class ToggleInputCard extends LitElement { render() { return html` - + ; export type CancelAttackIntent = z.infer; @@ -85,7 +85,9 @@ export type TogglePauseIntent = z.infer; export type UpdateGameConfigIntent = z.infer< typeof UpdateGameConfigIntentSchema >; -export type StartGameIntent = z.infer; +export type ToggleGameStartTimer = z.infer< + typeof ToggleGameStartTimerIntentSchema +>; export type Turn = z.infer; export type GameConfig = z.infer; @@ -281,6 +283,7 @@ export const GameConfigSchema = z.object({ randomSpawn: z.boolean(), maxPlayers: z.number().optional(), maxTimerValue: z.number().int().min(1).max(120).nullable().optional(), // In minutes + startDelay: z.number().int().min(0).max(600).nullable().optional(), // In seconds spawnImmunityDuration: z.number().int().min(0).nullable().optional(), // In ticks disabledUnits: z.enum(UnitType).array().optional(), playerTeams: TeamCountConfigSchema.optional(), @@ -481,8 +484,8 @@ export const UpdateGameConfigIntentSchema = z.object({ config: GameConfigSchema.partial(), }); -export const StartGameIntentSchema = z.object({ - type: z.literal("start_game"), +export const ToggleGameStartTimerIntentSchema = z.object({ + type: z.literal("toggle_game_start_timer"), }); const IntentSchema = z.discriminatedUnion("type", [ @@ -510,7 +513,7 @@ const IntentSchema = z.discriminatedUnion("type", [ KickPlayerIntentSchema, TogglePauseIntentSchema, UpdateGameConfigIntentSchema, - StartGameIntentSchema, + ToggleGameStartTimerIntentSchema, ]); // StampedIntent = Intent with server-stamped clientID (used in turns and execution) diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index 21279f856d..6b82990082 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -149,6 +149,9 @@ export class GameServer { if (gameConfig.maxTimerValue !== undefined) { this.gameConfig.maxTimerValue = gameConfig.maxTimerValue ?? undefined; } + if (gameConfig.startDelay !== undefined) { + this.gameConfig.startDelay = gameConfig.startDelay ?? undefined; + } if (gameConfig.instantBuild !== undefined) { this.gameConfig.instantBuild = gameConfig.instantBuild; } @@ -488,7 +491,7 @@ export class GameServer { this.updateGameConfig(stampedIntent.config); return; } - case "start_game": { + case "toggle_game_start_timer": { if (client.clientID !== this.lobbyCreatorID) { this.log.warn(`Only lobby creator can start game`, { clientID: client.clientID, @@ -514,7 +517,13 @@ export class GameServer { creatorID: client.clientID, gameID: this.id, }); - this.start(); + if (this.startsAt) { + this.startsAt = undefined; + } else { + this.setStartsAt( + Date.now() + (this.gameConfig.startDelay ?? 0) * 1000, + ); + } return; } case "toggle_pause": { @@ -931,25 +940,6 @@ export class GameServer { const noRecentPings = now > this.lastPingUpdate + 20 * 1000; const noActive = this.activeClients.length === 0; - if (this.gameConfig.gameType !== GameType.Public) { - if (this._hasStarted) { - if (noActive && noRecentPings) { - this.log.info("private game complete", { - gameID: this.id, - }); - return GamePhase.Finished; - } else { - return GamePhase.Active; - } - } else if (this._hasEnded) { - return GamePhase.Finished; - } else { - return GamePhase.Lobby; - } - } - - // Public Games - const lessThanLifetime = this.startsAt ? Date.now() < this.startsAt : true; if ( lessThanLifetime &&