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` -
- + ${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` -
+