diff --git a/src/client/GameRunner.ts b/src/client/GameRunner.ts index b1f118c1d..5d77f2a75 100644 --- a/src/client/GameRunner.ts +++ b/src/client/GameRunner.ts @@ -188,7 +188,7 @@ export class GameRunner { } catch (error) { const errorText = `Error: ${error.message}\nStack: ${error.stack}`; consolex.error(errorText) - alert(`Game crashed! client id: ${this.clientID}\n Please paste the following your bug report in Discord:\n` + errorText); + alert(`Game crashed! client id: ${this.clientID}\n Please paste the following in your bug report in Discord:\n` + errorText); } this.renderer.tick() this.currTurn++ diff --git a/src/client/LocalServer.ts b/src/client/LocalServer.ts index 56ee39bfa..cb5527088 100644 --- a/src/client/LocalServer.ts +++ b/src/client/LocalServer.ts @@ -1,19 +1,21 @@ -import { Config, ServerConfig } from "../core/configuration/Config"; +import { Config, GameEnv, ServerConfig } from "../core/configuration/Config"; import { consolex } from "../core/Consolex"; +import { GameEvent } from "../core/EventBus"; import { ClientID, ClientMessage, ClientMessageSchema, GameConfig, GameID, GameRecordSchema, Intent, PlayerRecord, ServerMessage, ServerStartGameMessageSchema, ServerTurnMessageSchema, Turn } from "../core/Schemas"; import { CreateGameRecord, generateID } from "../core/Util"; import { LobbyConfig } from "./GameRunner"; import { getPersistentIDFromCookie } from "./Main"; + export class LocalServer { - - private turns: Turn[] = [] private intents: Intent[] = [] private startedAt: number private endTurnIntervalID + private paused = false + constructor( private serverConfig: ServerConfig, @@ -35,14 +37,33 @@ export class LocalServer { })) } + pause() { + this.paused = true + } + + resume() { + this.paused = false + } + onMessage(message: string) { const clientMsg: ClientMessage = ClientMessageSchema.parse(JSON.parse(message)) if (clientMsg.type == "intent") { + if (this.paused) { + if (clientMsg.intent.type == "troop_ratio") { + // Store troop change events because otherwise they are + // not registered when game is paused. + this.intents.push(clientMsg.intent) + } + return + } this.intents.push(clientMsg.intent) } } private endTurn() { + if (this.paused) { + return + } const pastTurn: Turn = { turnNumber: this.turns.length, gameID: this.lobbyConfig.gameID, diff --git a/src/client/Transport.ts b/src/client/Transport.ts index 11c8eafcf..b8990f68a 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -6,6 +6,9 @@ import { ClientID, ClientIntentMessageSchema, ClientJoinMessageSchema, GameID, I import { LobbyConfig } from "./GameRunner" import { LocalServer } from "./LocalServer" +export class PauseGameEvent implements GameEvent { + constructor(public readonly paused: boolean) { } +} export class SendAllianceRequestIntentEvent implements GameEvent { constructor( @@ -121,6 +124,7 @@ export class Transport { this.eventBus.on(BuildUnitIntentEvent, (e) => this.onBuildUnitIntent(e)) this.eventBus.on(SendLogEvent, (e) => this.onSendLogEvent(e)) + this.eventBus.on(PauseGameEvent, (e) => this.onPauseGameEvent(e)) } private startPing() { @@ -352,6 +356,18 @@ export class Transport { }) } + private onPauseGameEvent(event: PauseGameEvent) { + if (!this.isLocal) { + console.log(`cannot pause multiplayer games`) + return + } + if (event.paused) { + this.localServer.pause() + } else { + this.localServer.resume() + } + } + private sendIntent(intent: Intent) { if (this.isLocal || this.socket.readyState === WebSocket.OPEN) { const msg = ClientIntentMessageSchema.parse({ diff --git a/src/client/graphics/layers/PlayerInfoOverlay.ts b/src/client/graphics/layers/PlayerInfoOverlay.ts index 1f9c6a54e..d401d4b94 100644 --- a/src/client/graphics/layers/PlayerInfoOverlay.ts +++ b/src/client/graphics/layers/PlayerInfoOverlay.ts @@ -1,13 +1,14 @@ import { LitElement, html, css } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import { Layer } from './Layer'; -import { Game, Player, Unit, UnitType } from '../../../core/game/Game'; +import { Game, GameType, Player, Unit, UnitType } from '../../../core/game/Game'; import { ClientID } from '../../../core/Schemas'; import { EventBus } from '../../../core/EventBus'; import { TransformHandler } from '../TransformHandler'; import { MouseMoveEvent } from '../../InputHandler'; import { euclideanDist, distSortUnit } from '../../../core/Util'; import { renderNumber, renderTroops } from '../../Utils'; +import { PauseGameEvent } from '../../Transport'; @customElement('player-info-overlay') export class PlayerInfoOverlay extends LitElement implements Layer { @@ -30,11 +31,21 @@ export class PlayerInfoOverlay extends LitElement implements Layer { private unit: Unit | null = null; @state() - private _isVisible: boolean = false; + private showPauseButton: boolean = true + + @state() + private _isInfoVisible: boolean = false; + + @state() + private _isPaused: boolean = false; + + private _isActive = false init(game: Game) { this.game = game; this.eventBus.on(MouseMoveEvent, (e: MouseMoveEvent) => this.onMouseEvent(e)); + this._isActive = true + this.showPauseButton = this.game.config().gameConfig().gameType == GameType.Singleplayer } private onMouseEvent(event: MouseMoveEvent) { @@ -45,7 +56,6 @@ export class PlayerInfoOverlay extends LitElement implements Layer { const worldCoord = this.transform.screenToWorldCoordinates(event.x, event.y); if (!this.game.isOnMap(worldCoord)) { return; - return; } const tile = this.game.tile(worldCoord); @@ -70,9 +80,13 @@ export class PlayerInfoOverlay extends LitElement implements Layer { window.location.reload(); } + private onPauseButtonClick() { + this._isPaused = !this._isPaused; + this.eventBus.emit(new PauseGameEvent(this._isPaused)); + } + tick() { - this.requestUpdate() - // Implementation for Layer interface + this.requestUpdate(); } renderLayer(context: CanvasRenderingContext2D) { @@ -84,7 +98,7 @@ export class PlayerInfoOverlay extends LitElement implements Layer { } setVisible(visible: boolean) { - this._isVisible = visible; + this._isInfoVisible = visible; this.requestUpdate(); } @@ -109,25 +123,31 @@ export class PlayerInfoOverlay extends LitElement implements Layer { private renderUnitInfo(unit: Unit) { const isAlly = (unit.owner() == this.myPlayer() || this.myPlayer()?.isAlliedWith(unit.owner())) ?? false; return html` -
-
${unit.owner().name()}
-
-
${unit.type()}
- ${unit.hasHealth() ? html` -
Health: ${unit.health()}
- ` : ''} +
+
${unit.owner().name()}
+
+
${unit.type()}
+ ${unit.hasHealth() ? html` +
Health: ${unit.health()}
+ ` : ''} +
-
- ` + `; } render() { + if (!this._isActive) { + return html`` + } return html`
- + +
-
+
${this.player != null ? this.renderPlayerInfo(this.player) : ''} ${this.unit != null ? this.renderUnitInfo(this.unit) : ''}
@@ -153,6 +173,31 @@ export class PlayerInfoOverlay extends LitElement implements Layer { align-self: flex-end; margin-bottom: 4px; z-index: 2; + display: flex; + gap: 8px; + } + + .control-button { + background: rgba(30, 30, 30, 0.7); + border: none; + color: white; + font-size: 24px; + cursor: pointer; + padding: 4px 8px; + border-radius: 4px; + opacity: 0.7; + transition: opacity 0.2s, background-color 0.2s; + backdrop-filter: blur(5px); + } + + .control-button:hover { + opacity: 1; + background: rgba(40, 40, 40, 0.8); + } + + .pause-button { + font-size: 20px; + padding: 4px 10px; } .player-info { @@ -167,6 +212,7 @@ export class PlayerInfoOverlay extends LitElement implements Layer { min-width: 120px; text-align: left; } + .hidden { opacity: 0; visibility: hidden; @@ -176,6 +222,7 @@ export class PlayerInfoOverlay extends LitElement implements Layer { .info-content { margin-top: 8px; } + .player-name { font-weight: bold; margin-bottom: 4px; @@ -194,35 +241,6 @@ export class PlayerInfoOverlay extends LitElement implements Layer { margin-top: 4px; } - .health-bar { - height: 4px; - background-color: rgba(255, 255, 255, 0.2); - border-radius: 2px; - margin-top: 4px; - } - - .health-fill { - height: 100%; - background-color: #4CAF50; - border-radius: 2px; - transition: width 0.2s ease-out; - } - - .exit-button { - background: none; - border: none; - color: white; - font-size: 40px; - cursor: pointer; - padding: 4px; - opacity: 0.7; - transition: opacity 0.2s; - } - - .exit-button:hover { - opacity: 1; - } - @media (max-width: 768px) { .container { top: 5px; @@ -235,8 +253,14 @@ export class PlayerInfoOverlay extends LitElement implements Layer { min-width: 100px; } - .exit-button { + .control-button { font-size: 16px; + padding: 3px 6px; + } + + .pause-button { + font-size: 14px; + padding: 3px 8px; } .type-label { diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index 776e775bd..45fd2c2e5 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -120,6 +120,9 @@ export class GameServer { public start() { this._hasStarted = true this._startTime = Date.now() + // Set last ping to start so we don't immediately stop the game + // if no client connects/pings. + this.lastPingUpdate = Date.now() this.endTurnIntervalID = setInterval(() => this.endTurn(), this.config.turnIntervalMs()); this.activeClients.forEach(c => {