format codebase with prettier

This commit is contained in:
Evan
2025-01-30 19:46:36 -08:00
parent cd121a5cd4
commit 4ee37323f9
98 changed files with 12191 additions and 10234 deletions
+261 -196
View File
@@ -1,240 +1,305 @@
import { Executor } from "../core/execution/ExecutionManager";
import { Cell, Game, PlayerID, GameMapType, Difficulty, GameType } from "../core/game/Game";
import {
Cell,
Game,
PlayerID,
GameMapType,
Difficulty,
GameType,
} from "../core/game/Game";
import { EventBus } from "../core/EventBus";
import { createRenderer, GameRenderer } from "./graphics/GameRenderer";
import { InputHandler, MouseUpEvent, ZoomEvent, DragEvent, MouseDownEvent } from "./InputHandler"
import { ClientID, ClientIntentMessageSchema, ClientJoinMessageSchema, ClientMessageSchema, GameConfig, GameID, Intent, ServerMessage, ServerMessageSchema, ServerSyncMessage, Turn } from "../core/Schemas";
import { loadTerrainFromFile, loadTerrainMap } from "../core/game/TerrainMapLoader";
import { SendAttackIntentEvent, SendSpawnIntentEvent, Transport } from "./Transport";
import {
InputHandler,
MouseUpEvent,
ZoomEvent,
DragEvent,
MouseDownEvent,
} from "./InputHandler";
import {
ClientID,
ClientIntentMessageSchema,
ClientJoinMessageSchema,
ClientMessageSchema,
GameConfig,
GameID,
Intent,
ServerMessage,
ServerMessageSchema,
ServerSyncMessage,
Turn,
} from "../core/Schemas";
import {
loadTerrainFromFile,
loadTerrainMap,
} from "../core/game/TerrainMapLoader";
import {
SendAttackIntentEvent,
SendSpawnIntentEvent,
Transport,
} from "./Transport";
import { createCanvas } from "./Utils";
import { MessageType } from '../core/game/Game';
import { MessageType } from "../core/game/Game";
import { DisplayMessageUpdate, ErrorUpdate } from "../core/game/GameUpdates";
import { WorkerClient } from "../core/worker/WorkerClient";
import { consolex, initRemoteSender } from "../core/Consolex";
import { getConfig, getServerConfig } from "../core/configuration/Config";
import { GameView, PlayerView } from "../core/game/GameView";
import { GameUpdateViewData } from '../core/game/GameUpdates';
import { GameUpdateViewData } from "../core/game/GameUpdates";
export interface LobbyConfig {
playerName: () => string
clientID: ClientID,
playerID: PlayerID,
persistentID: string,
gameType: GameType
gameID: GameID,
map: GameMapType | null
difficulty: Difficulty | null
playerName: () => string;
clientID: ClientID;
playerID: PlayerID;
persistentID: string;
gameType: GameType;
gameID: GameID;
map: GameMapType | null;
difficulty: Difficulty | null;
}
export function joinLobby(lobbyConfig: LobbyConfig, onjoin: () => void): () => void {
const eventBus = new EventBus()
initRemoteSender(eventBus)
export function joinLobby(
lobbyConfig: LobbyConfig,
onjoin: () => void,
): () => void {
const eventBus = new EventBus();
initRemoteSender(eventBus);
consolex.log(`joinging lobby: gameID: ${lobbyConfig.gameID}, clientID: ${lobbyConfig.clientID}, persistentID: ${lobbyConfig.persistentID}`)
consolex.log(
`joinging lobby: gameID: ${lobbyConfig.gameID}, clientID: ${lobbyConfig.clientID}, persistentID: ${lobbyConfig.persistentID}`,
);
const serverConfig = getServerConfig()
const serverConfig = getServerConfig();
let gameConfig: GameConfig = null
if (lobbyConfig.gameType == GameType.Singleplayer) {
gameConfig = {
gameType: GameType.Singleplayer,
gameMap: lobbyConfig.map,
difficulty: lobbyConfig.difficulty,
}
}
const transport = new Transport(
lobbyConfig,
gameConfig,
eventBus,
serverConfig,
)
const onconnect = () => {
consolex.log(`Joined game lobby ${lobbyConfig.gameID}`);
transport.joinGame(0)
let gameConfig: GameConfig = null;
if (lobbyConfig.gameType == GameType.Singleplayer) {
gameConfig = {
gameType: GameType.Singleplayer,
gameMap: lobbyConfig.map,
difficulty: lobbyConfig.difficulty,
};
const onmessage = (message: ServerMessage) => {
if (message.type == "start") {
consolex.log('lobby: game started')
onjoin()
createClientGame(lobbyConfig, message.config, eventBus, transport).then(r => r.start())
};
}
transport.connect(onconnect, onmessage)
return () => {
consolex.log('leaving game')
transport.leaveGame()
}
const transport = new Transport(
lobbyConfig,
gameConfig,
eventBus,
serverConfig,
);
const onconnect = () => {
consolex.log(`Joined game lobby ${lobbyConfig.gameID}`);
transport.joinGame(0);
};
const onmessage = (message: ServerMessage) => {
if (message.type == "start") {
consolex.log("lobby: game started");
onjoin();
createClientGame(lobbyConfig, message.config, eventBus, transport).then(
(r) => r.start(),
);
}
};
transport.connect(onconnect, onmessage);
return () => {
consolex.log("leaving game");
transport.leaveGame();
};
}
export async function createClientGame(
lobbyConfig: LobbyConfig,
gameConfig: GameConfig,
eventBus: EventBus,
transport: Transport,
): Promise<ClientGameRunner> {
const config = getConfig(gameConfig);
export async function createClientGame(lobbyConfig: LobbyConfig, gameConfig: GameConfig, eventBus: EventBus, transport: Transport): Promise<ClientGameRunner> {
const config = getConfig(gameConfig)
const gameMap = await loadTerrainMap(gameConfig.gameMap);
const worker = new WorkerClient(lobbyConfig.gameID, gameConfig);
await worker.initialize();
const gameView = new GameView(
worker,
config,
gameMap.gameMap,
lobbyConfig.clientID,
);
const gameMap = await loadTerrainMap(gameConfig.gameMap);
const worker = new WorkerClient(lobbyConfig.gameID, gameConfig)
await worker.initialize()
const gameView = new GameView(worker, config, gameMap.gameMap, lobbyConfig.clientID)
consolex.log("going to init path finder");
consolex.log("inited path finder");
const canvas = createCanvas();
let gameRenderer = createRenderer(
canvas,
gameView,
eventBus,
lobbyConfig.clientID,
);
consolex.log(
`creating private game got difficulty: ${gameConfig.difficulty}`,
);
consolex.log('going to init path finder')
consolex.log('inited path finder')
const canvas = createCanvas()
let gameRenderer = createRenderer(canvas, gameView, eventBus, lobbyConfig.clientID)
consolex.log(`creating private game got difficulty: ${gameConfig.difficulty}`)
return new ClientGameRunner(
lobbyConfig.clientID,
eventBus,
gameRenderer,
new InputHandler(canvas, eventBus),
transport,
worker,
gameView
)
return new ClientGameRunner(
lobbyConfig.clientID,
eventBus,
gameRenderer,
new InputHandler(canvas, eventBus),
transport,
worker,
gameView,
);
}
export class ClientGameRunner {
private myPlayer: PlayerView
private isActive = false
private myPlayer: PlayerView;
private isActive = false;
private turnsSeen = 0
private hasJoined = false
private turnsSeen = 0;
private hasJoined = false;
constructor(
private clientID: ClientID,
private eventBus: EventBus,
private renderer: GameRenderer,
private input: InputHandler,
private transport: Transport,
private worker: WorkerClient,
private gameView: GameView
) { }
constructor(
private clientID: ClientID,
private eventBus: EventBus,
private renderer: GameRenderer,
private input: InputHandler,
private transport: Transport,
private worker: WorkerClient,
private gameView: GameView,
) {}
public start() {
consolex.log('starting client game')
this.isActive = true
this.eventBus.on(MouseUpEvent, (e) => this.inputEvent(e))
public start() {
consolex.log("starting client game");
this.isActive = true;
this.eventBus.on(MouseUpEvent, (e) => this.inputEvent(e));
this.renderer.initialize()
this.input.initialize()
this.worker.start((gu: GameUpdateViewData | ErrorUpdate) => {
if ('errMsg' in gu) {
showErrorModal(gu.errMsg, gu.stack, this.clientID)
return
}
this.gameView.update(gu)
this.renderer.tick()
})
const worker = this.worker
const keepWorkerAlive = () => {
worker.sendHeartbeat
requestAnimationFrame(keepWorkerAlive)
this.renderer.initialize();
this.input.initialize();
this.worker.start((gu: GameUpdateViewData | ErrorUpdate) => {
if ("errMsg" in gu) {
showErrorModal(gu.errMsg, gu.stack, this.clientID);
return;
}
this.gameView.update(gu);
this.renderer.tick();
});
const worker = this.worker;
const keepWorkerAlive = () => {
worker.sendHeartbeat;
requestAnimationFrame(keepWorkerAlive);
};
requestAnimationFrame(keepWorkerAlive);
const onconnect = () => {
consolex.log("Connected to game server!");
this.transport.joinGame(this.turnsSeen);
};
const onmessage = (message: ServerMessage) => {
if (message.type == "start") {
this.hasJoined = true;
consolex.log("starting game!");
for (const turn of message.turns) {
if (turn.turnNumber < this.turnsSeen) {
continue;
}
this.worker.sendTurn(turn);
this.turnsSeen++;
}
requestAnimationFrame(keepWorkerAlive)
}
if (message.type == "turn") {
if (!this.hasJoined) {
this.transport.joinGame(0);
return;
}
if (this.turnsSeen != message.turn.turnNumber) {
consolex.error(
`got wrong turn have turns ${this.turnsSeen}, received turn ${message.turn.turnNumber}`,
);
} else {
this.worker.sendTurn(message.turn);
this.turnsSeen++;
}
}
};
this.transport.connect(onconnect, onmessage);
}
const onconnect = () => {
consolex.log('Connected to game server!');
this.transport.joinGame(this.turnsSeen)
};
const onmessage = (message: ServerMessage) => {
if (message.type == "start") {
this.hasJoined = true
consolex.log("starting game!")
for (const turn of message.turns) {
if (turn.turnNumber < this.turnsSeen) {
continue
}
this.worker.sendTurn(turn)
this.turnsSeen++
}
}
if (message.type == "turn") {
if (!this.hasJoined) {
this.transport.joinGame(0)
return
}
if (this.turnsSeen != message.turn.turnNumber) {
consolex.error(`got wrong turn have turns ${this.turnsSeen}, received turn ${message.turn.turnNumber}`)
} else {
this.worker.sendTurn(message.turn)
this.turnsSeen++
}
}
};
this.transport.connect(onconnect, onmessage)
public stop() {
this.worker.cleanup();
this.isActive = false;
this.transport.leaveGame();
}
private inputEvent(event: MouseUpEvent) {
if (!this.isActive) {
return;
}
public stop() {
this.worker.cleanup()
this.isActive = false
this.transport.leaveGame()
const cell = this.renderer.transformHandler.screenToWorldCoordinates(
event.x,
event.y,
);
if (!this.gameView.isValidCoord(cell.x, cell.y)) {
return;
}
private inputEvent(event: MouseUpEvent) {
if (!this.isActive) {
return
}
const cell = this.renderer.transformHandler.screenToWorldCoordinates(event.x, event.y)
if (!this.gameView.isValidCoord(cell.x, cell.y)) {
return
}
consolex.log(`clicked cell ${cell}`)
const tile = this.gameView.ref(cell.x, cell.y)
if (this.gameView.isLand(tile) && !this.gameView.hasOwner(tile) && this.gameView.inSpawnPhase()) {
this.eventBus.emit(new SendSpawnIntentEvent(cell))
return
}
if (this.gameView.inSpawnPhase()) {
return
}
if (this.myPlayer == null) {
this.myPlayer = this.gameView.playerByClientID(this.clientID)
if (this.myPlayer == null) {
return
}
}
this.myPlayer.actions(tile).then(actions => {
console.log(`got actions: ${JSON.stringify(actions)}`)
if (actions.canAttack) {
this.eventBus.emit(
new SendAttackIntentEvent(
this.gameView.owner(tile).id(),
this.myPlayer.troops() * this.renderer.uiState.attackRatio
)
)
}
})
consolex.log(`clicked cell ${cell}`);
const tile = this.gameView.ref(cell.x, cell.y);
if (
this.gameView.isLand(tile) &&
!this.gameView.hasOwner(tile) &&
this.gameView.inSpawnPhase()
) {
this.eventBus.emit(new SendSpawnIntentEvent(cell));
return;
}
if (this.gameView.inSpawnPhase()) {
return;
}
if (this.myPlayer == null) {
this.myPlayer = this.gameView.playerByClientID(this.clientID);
if (this.myPlayer == null) {
return;
}
}
this.myPlayer.actions(tile).then((actions) => {
console.log(`got actions: ${JSON.stringify(actions)}`);
if (actions.canAttack) {
this.eventBus.emit(
new SendAttackIntentEvent(
this.gameView.owner(tile).id(),
this.myPlayer.troops() * this.renderer.uiState.attackRatio,
),
);
}
});
}
}
function showErrorModal(errMsg: string, stack: string, clientID: ClientID) {
const errorText = `Error: ${errMsg}\nStack: ${stack}`;
consolex.error(errorText);
const errorText = `Error: ${errMsg}\nStack: ${stack}`;
consolex.error(errorText);
const modal = document.createElement('div');
const content = `Game crashed! client id: ${clientID}\nPlease paste the following in your bug report in Discord:\n${errorText}`;
const modal = document.createElement("div");
const content = `Game crashed! client id: ${clientID}\nPlease paste the following in your bug report in Discord:\n${errorText}`;
// Create elements
const pre = document.createElement('pre');
pre.textContent = content;
// Create elements
const pre = document.createElement("pre");
pre.textContent = content;
const button = document.createElement('button');
button.textContent = 'Copy to clipboard';
button.style.cssText = 'padding: 8px 16px; margin-top: 10px; background: #4CAF50; color: white; border: none; border-radius: 4px; cursor: pointer;';
button.addEventListener('click', () => {
navigator.clipboard.writeText(content)
.then(() => button.textContent = 'Copied!')
.catch(() => button.textContent = 'Failed to copy');
});
const button = document.createElement("button");
button.textContent = "Copy to clipboard";
button.style.cssText =
"padding: 8px 16px; margin-top: 10px; background: #4CAF50; color: white; border: none; border-radius: 4px; cursor: pointer;";
button.addEventListener("click", () => {
navigator.clipboard
.writeText(content)
.then(() => (button.textContent = "Copied!"))
.catch(() => (button.textContent = "Failed to copy"));
});
// Add to modal
modal.style.cssText = 'position:fixed; padding:20px; background:white; border:1px solid black; top:50%; left:50%; transform:translate(-50%,-50%); z-index:9999;';
modal.appendChild(pre);
modal.appendChild(button);
// Add to modal
modal.style.cssText =
"position:fixed; padding:20px; background:white; border:1px solid black; top:50%; left:50%; transform:translate(-50%,-50%); z-index:9999;";
modal.appendChild(pre);
modal.appendChild(button);
document.body.appendChild(modal);
}
document.body.appendChild(modal);
}
+118 -78
View File
@@ -1,19 +1,19 @@
import { LitElement, html, css } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { Difficulty, GameMapType, GameType } from '../core/game/Game';
import { Lobby } from '../core/Schemas';
import { consolex } from '../core/Consolex';
import { LitElement, html, css } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { Difficulty, GameMapType, GameType } from "../core/game/Game";
import { Lobby } from "../core/Schemas";
import { consolex } from "../core/Consolex";
@customElement('host-lobby-modal')
@customElement("host-lobby-modal")
export class HostLobbyModal extends LitElement {
@state() private isModalOpen = false;
@state() private selectedMap: GameMapType = GameMapType.World;
@state() private selectedDiffculty: Difficulty = Difficulty.Medium;
@state() private lobbyId = '';
@state() private lobbyId = "";
@state() private copySuccess = false;
@state() private players: string[] = []
@state() private players: string[] = [];
private playersInterval = null
private playersInterval = null;
static styles = css`
.modal-overlay {
@@ -101,113 +101,154 @@ export class HostLobbyModal extends LitElement {
render() {
return html`
<div class="modal-overlay" style="display: ${this.isModalOpen ? 'block' : 'none'}">
<div
class="modal-overlay"
style="display: ${this.isModalOpen ? "block" : "none"}"
>
<div class="modal-content">
<span class="close" @click=${this.close}>&times;</span>
<h2>Private Lobby</h2>
<div class="lobby-id-container">
<h3>Lobby ID: ${this.lobbyId}</h3>
<svg @click=${this.copyToClipboard} class="clipboard-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path>
<svg
@click=${this.copyToClipboard}
class="clipboard-icon"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"
></path>
<rect x="8" y="2" width="8" height="4" rx="1" ry="1"></rect>
</svg>
</div>
${this.copySuccess ? html`<p class="copy-success">Copied to clipboard!</p>` : ''}
${this.copySuccess
? html`<p class="copy-success">Copied to clipboard!</p>`
: ""}
<div>
<label for="map-select">Map: </label>
<select id="map-select" @change=${this.handleMapChange}>
${Object.entries(GameMapType)
.filter(([key]) => isNaN(Number(key)))
.map(([key, value]) => html`
<option value=${value} ?selected=${this.selectedMap === value}>
${key}
</option>
`)}
.filter(([key]) => isNaN(Number(key)))
.map(
([key, value]) => html`
<option
value=${value}
?selected=${this.selectedMap === value}
>
${key}
</option>
`,
)}
</select>
</div>
<div>
<label for="map-select">Difficulty: </label>
<select id="map-select" @change=${this.handleDifficultyChange}>
${Object.entries(Difficulty)
.filter(([key]) => isNaN(Number(key)))
.map(([key, value]) => html`
<option value=${value} ?selected=${this.selectedDiffculty === value}>
${key}
</option>
`)}
.filter(([key]) => isNaN(Number(key)))
.map(
([key, value]) => html`
<option
value=${value}
?selected=${this.selectedDiffculty === value}
>
${key}
</option>
`,
)}
</select>
</div>
<button @click=${this.startGame}>Start Game</button>
<div>
<p>Players: ${this.players.join(", ")}<p>
</div>
<div>
<p>Players: ${this.players.join(", ")}</p>
<p></p>
</div>
</div>
</div>
`;
}
public open() {
createLobby().then((lobby) => {
this.lobbyId = lobby.id
// join lobby
}).then(() => {
this.dispatchEvent(new CustomEvent('join-lobby', {
detail: {
gameType: GameType.Private,
lobby: {
id: this.lobbyId,
},
map: this.selectedMap,
difficulty: this.selectedDiffculty,
},
bubbles: true,
composed: true
}));
})
createLobby()
.then((lobby) => {
this.lobbyId = lobby.id;
// join lobby
})
.then(() => {
this.dispatchEvent(
new CustomEvent("join-lobby", {
detail: {
gameType: GameType.Private,
lobby: {
id: this.lobbyId,
},
map: this.selectedMap,
difficulty: this.selectedDiffculty,
},
bubbles: true,
composed: true,
}),
);
});
this.isModalOpen = true;
this.playersInterval = setInterval(() => this.pollPlayers(), 1000)
this.playersInterval = setInterval(() => this.pollPlayers(), 1000);
}
public close() {
this.isModalOpen = false;
this.copySuccess = false;
if (this.playersInterval) {
clearInterval(this.playersInterval)
this.playersInterval = null
clearInterval(this.playersInterval);
this.playersInterval = null;
}
}
private async handleMapChange(e: Event) {
this.selectedMap = String((e.target as HTMLSelectElement).value) as GameMapType;
consolex.log(`updating map to ${this.selectedMap}`)
this.putGameConfig()
this.selectedMap = String(
(e.target as HTMLSelectElement).value,
) as GameMapType;
consolex.log(`updating map to ${this.selectedMap}`);
this.putGameConfig();
}
private async handleDifficultyChange(e: Event) {
this.selectedDiffculty = String((e.target as HTMLSelectElement).value) as Difficulty;
consolex.log(`updating difficulty to ${this.selectedDiffculty}`)
this.putGameConfig()
this.selectedDiffculty = String(
(e.target as HTMLSelectElement).value,
) as Difficulty;
consolex.log(`updating difficulty to ${this.selectedDiffculty}`);
this.putGameConfig();
}
private async putGameConfig() {
const response = await fetch(`/private_lobby/${this.lobbyId}`, {
method: 'PUT',
method: "PUT",
headers: {
'Content-Type': 'application/json',
"Content-Type": "application/json",
},
body: JSON.stringify({ gameMap: this.selectedMap, difficulty: this.selectedDiffculty })
body: JSON.stringify({
gameMap: this.selectedMap,
difficulty: this.selectedDiffculty,
}),
});
}
private async startGame() {
consolex.log(`Starting private game with map: ${GameMapType[this.selectedMap]}`);
consolex.log(
`Starting private game with map: ${GameMapType[this.selectedMap]}`,
);
this.close();
const response = await fetch(`/start_private_lobby/${this.lobbyId}`, {
method: 'POST',
method: "POST",
headers: {
'Content-Type': 'application/json',
}
"Content-Type": "application/json",
},
});
}
@@ -219,31 +260,31 @@ export class HostLobbyModal extends LitElement {
this.copySuccess = false;
}, 2000);
} catch (err) {
consolex.error('Failed to copy text: ', err);
consolex.error("Failed to copy text: ", err);
}
}
private async pollPlayers() {
fetch(`/lobby/${this.lobbyId}`, {
method: 'GET',
method: "GET",
headers: {
'Content-Type': 'application/json',
}
}).then(response => response.json())
.then(data => {
console.log(`got response: ${data}`)
this.players = data.players.map(p => p.username)
})
"Content-Type": "application/json",
},
})
.then((response) => response.json())
.then((data) => {
console.log(`got response: ${data}`);
this.players = data.players.map((p) => p.username);
});
}
}
async function createLobby(): Promise<Lobby> {
try {
const response = await fetch('/private_lobby', {
method: 'POST',
const response = await fetch("/private_lobby", {
method: "POST",
headers: {
'Content-Type': 'application/json',
"Content-Type": "application/json",
},
// body: JSON.stringify(data), // Include this if you need to send data
});
@@ -253,7 +294,7 @@ async function createLobby(): Promise<Lobby> {
}
const data = await response.json();
consolex.log('Success:', data);
consolex.log("Success:", data);
// Assuming the server returns an object with an 'id' property
const lobby: Lobby = {
@@ -263,8 +304,7 @@ async function createLobby(): Promise<Lobby> {
return lobby;
} catch (error) {
consolex.error('Error creating lobby:', error);
consolex.error("Error creating lobby:", error);
throw error; // Re-throw the error so the caller can handle it
}
}
+30 -9
View File
@@ -2,31 +2,46 @@ import { EventBus, GameEvent } from "../core/EventBus";
import { Game } from "../core/game/Game";
export class MouseUpEvent implements GameEvent {
constructor(public readonly x: number, public readonly y: number) {}
constructor(
public readonly x: number,
public readonly y: number,
) {}
}
export class MouseDownEvent implements GameEvent {
constructor(public readonly x: number, public readonly y: number) {}
constructor(
public readonly x: number,
public readonly y: number,
) {}
}
export class MouseMoveEvent implements GameEvent {
constructor(public readonly x: number, public readonly y: number) {}
constructor(
public readonly x: number,
public readonly y: number,
) {}
}
export class ContextMenuEvent implements GameEvent {
constructor(public readonly x: number, public readonly y: number) {}
constructor(
public readonly x: number,
public readonly y: number,
) {}
}
export class ZoomEvent implements GameEvent {
constructor(
public readonly x: number,
public readonly y: number,
public readonly delta: number
public readonly delta: number,
) {}
}
export class DragEvent implements GameEvent {
constructor(public readonly deltaX: number, public readonly deltaY: number) {}
constructor(
public readonly deltaX: number,
public readonly deltaY: number,
) {}
}
export class AlternateViewEvent implements GameEvent {
@@ -36,7 +51,10 @@ export class AlternateViewEvent implements GameEvent {
export class RefreshGraphicsEvent implements GameEvent {}
export class ShowBuildMenuEvent implements GameEvent {
constructor(public readonly x: number, public readonly y: number) {}
constructor(
public readonly x: number,
public readonly y: number,
) {}
}
export class InputHandler {
@@ -54,7 +72,10 @@ export class InputHandler {
private alternateView = false;
constructor(private canvas: HTMLCanvasElement, private eventBus: EventBus) {}
constructor(
private canvas: HTMLCanvasElement,
private eventBus: EventBus,
) {}
initialize() {
this.canvas.addEventListener("pointerdown", (e) => this.onPointerDown(e));
@@ -175,7 +196,7 @@ export class InputHandler {
// Threshold to avoid tiny zoom adjustments
const zoomCenter = this.getPinchCenter();
this.eventBus.emit(
new ZoomEvent(zoomCenter.x, zoomCenter.y, -pinchDelta * 2)
new ZoomEvent(zoomCenter.x, zoomCenter.y, -pinchDelta * 2),
);
this.lastPinchDistance = currentPinchDistance;
}
+87 -77
View File
@@ -1,14 +1,14 @@
import { LitElement, html, css } from 'lit';
import { customElement, property, state, query } from 'lit/decorators.js';
import { GameMapType, GameType } from '../core/game/Game';
import { consolex } from '../core/Consolex';
import { LitElement, html, css } from "lit";
import { customElement, property, state, query } from "lit/decorators.js";
import { GameMapType, GameType } from "../core/game/Game";
import { consolex } from "../core/Consolex";
@customElement('join-private-lobby-modal')
@customElement("join-private-lobby-modal")
export class JoinPrivateLobbyModal extends LitElement {
@state() private isModalOpen = false;
@state() private message: string = '';
@query('#lobbyIdInput') private lobbyIdInput!: HTMLInputElement;
@state() private hasJoined = false
@state() private message: string = "";
@query("#lobbyIdInput") private lobbyIdInput!: HTMLInputElement;
@state() private hasJoined = false;
static styles = css`
.modal-overlay {
@@ -78,51 +78,58 @@ export class JoinPrivateLobbyModal extends LitElement {
margin-top: 10px;
}
.message-area {
margin-top: 10px;
padding: 10px;
border-radius: 4px;
font-size: 14px;
transition: opacity 0.3s ease;
opacity: 0;
height: 0;
overflow: hidden;
}
.message-area {
margin-top: 10px;
padding: 10px;
border-radius: 4px;
font-size: 14px;
transition: opacity 0.3s ease;
opacity: 0;
height: 0;
overflow: hidden;
}
.message-area.show {
opacity: 1;
height: auto;
margin-bottom: 10px;
}
.message-area.show {
opacity: 1;
height: auto;
margin-bottom: 10px;
}
.message-area.error {
background-color: #ffebee;
color: #c62828;
}
.message-area.error {
background-color: #ffebee;
color: #c62828;
}
.message-area.success {
background-color: #e8f5e9;
color: #2e7d32;
}
.message-area.success {
background-color: #e8f5e9;
color: #2e7d32;
}
`;
render() {
return html`
<div class="modal-overlay" style="display: ${this.isModalOpen ? 'block' : 'none'}">
<div class="modal-content">
<span class="close" @click=${this.closeAndLeave}>&times;</span>
<h2>Join Private Lobby</h2>
<div class="lobby-id-container">
<input type="text" id="lobbyIdInput" placeholder="Enter Lobby ID">
<button @click=${this.pasteFromClipboard}>Paste</button>
<div
class="modal-overlay"
style="display: ${this.isModalOpen ? "block" : "none"}"
>
<div class="modal-content">
<span class="close" @click=${this.closeAndLeave}>&times;</span>
<h2>Join Private Lobby</h2>
<div class="lobby-id-container">
<input type="text" id="lobbyIdInput" placeholder="Enter Lobby ID" />
<button @click=${this.pasteFromClipboard}>Paste</button>
</div>
<div class="message-area ${this.message ? "show" : ""}">
${this.message}
</div>
${!this.hasJoined
? html`<button class="join-button" @click=${this.joinLobby}>
Join Lobby
</button>`
: ""}
</div>
<div class="message-area ${this.message ? 'show' : ''}">
${this.message}
</div>
${!this.hasJoined ? html`<button class="join-button" @click=${this.joinLobby}>Join Lobby</button>` : ''}
</div>
</div>
`;
`;
}
public open() {
@@ -131,18 +138,20 @@ export class JoinPrivateLobbyModal extends LitElement {
public close() {
this.isModalOpen = false;
this.lobbyIdInput.value = null
this.lobbyIdInput.value = null;
}
public closeAndLeave() {
this.close()
this.hasJoined = false
this.message = ""
this.dispatchEvent(new CustomEvent('leave-lobby', {
detail: { lobby: this.lobbyIdInput.value },
bubbles: true,
composed: true
}));
this.close();
this.hasJoined = false;
this.message = "";
this.dispatchEvent(
new CustomEvent("leave-lobby", {
detail: { lobby: this.lobbyIdInput.value },
bubbles: true,
composed: true,
}),
);
}
private async pasteFromClipboard() {
@@ -150,43 +159,44 @@ export class JoinPrivateLobbyModal extends LitElement {
const clipText = await navigator.clipboard.readText();
this.lobbyIdInput.value = clipText;
} catch (err) {
consolex.error('Failed to read clipboard contents: ', err);
consolex.error("Failed to read clipboard contents: ", err);
}
}
private joinLobby() {
const lobbyId = this.lobbyIdInput.value;
consolex.log(`Joining lobby with ID: ${lobbyId}`);
this.message = 'Checking lobby...'; // Set initial message
this.message = "Checking lobby..."; // Set initial message
fetch(`/lobby/${lobbyId}/exists`, {
method: 'GET',
method: "GET",
headers: {
'Content-Type': 'application/json',
}
"Content-Type": "application/json",
},
})
.then(response => response.json())
.then(data => {
.then((response) => response.json())
.then((data) => {
if (data.exists) {
this.message = 'Joined successfully! Waiting for game to start...';
this.hasJoined = true
this.dispatchEvent(new CustomEvent('join-lobby', {
detail: {
lobby: { id: lobbyId },
gameType: GameType.Private,
map: GameMapType.World,
},
bubbles: true,
composed: true
}));
this.message = "Joined successfully! Waiting for game to start...";
this.hasJoined = true;
this.dispatchEvent(
new CustomEvent("join-lobby", {
detail: {
lobby: { id: lobbyId },
gameType: GameType.Private,
map: GameMapType.World,
},
bubbles: true,
composed: true,
}),
);
} else {
this.message = 'Lobby not found. Please check the ID and try again.';
this.message = "Lobby not found. Please check the ID and try again.";
}
})
.catch(error => {
consolex.error('Error checking lobby existence:', error);
this.message = 'An error occurred. Please try again.';
.catch((error) => {
consolex.error("Error checking lobby existence:", error);
this.message = "An error occurred. Please try again.";
});
}
}
}
+109 -90
View File
@@ -1,111 +1,130 @@
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 {
ClientID,
ClientMessage,
ClientMessageSchema,
GameConfig,
GameID,
GameRecordSchema,
Intent,
PlayerRecord,
ServerMessage,
ServerStartGameMessageSchema,
ServerTurnMessageSchema,
Turn,
} from "../core/Schemas";
import { CreateGameRecord, generateID } from "../core/Util";
import { LobbyConfig } from "./ClientGameRunner";
import { getPersistentIDFromCookie } from "./Main";
export class LocalServer {
private turns: Turn[] = []
private intents: Intent[] = []
private startedAt: number
private turns: Turn[] = [];
private intents: Intent[] = [];
private startedAt: number;
private endTurnIntervalID
private endTurnIntervalID;
private paused = false
private paused = false;
private winner: ClientID | null = null
private winner: ClientID | null = null;
constructor(
private serverConfig: ServerConfig,
private gameConfig: GameConfig,
private lobbyConfig: LobbyConfig,
private clientConnect: () => void,
private clientMessage: (message: ServerMessage) => void,
) {}
constructor(
private serverConfig: ServerConfig,
private gameConfig: GameConfig,
private lobbyConfig: LobbyConfig,
private clientConnect: () => void,
private clientMessage: (message: ServerMessage) => void
) {
}
start() {
this.startedAt = Date.now();
this.endTurnIntervalID = setInterval(
() => this.endTurn(),
this.serverConfig.turnIntervalMs(),
);
this.clientConnect();
this.clientMessage(
ServerStartGameMessageSchema.parse({
type: "start",
config: this.gameConfig,
turns: [],
}),
);
}
start() {
this.startedAt = Date.now()
this.endTurnIntervalID = setInterval(() => this.endTurn(), this.serverConfig.turnIntervalMs());
this.clientConnect()
this.clientMessage(ServerStartGameMessageSchema.parse({
type: "start",
config: this.gameConfig,
turns: [],
}))
}
pause() {
this.paused = true;
}
pause() {
this.paused = true
}
resume() {
this.paused = false;
}
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)
}
if (clientMsg.type == "winner") {
this.winner = clientMsg.winner
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);
}
if (clientMsg.type == "winner") {
this.winner = clientMsg.winner;
}
}
private endTurn() {
if (this.paused) {
return
}
const pastTurn: Turn = {
turnNumber: this.turns.length,
gameID: this.lobbyConfig.gameID,
intents: this.intents
}
this.turns.push(pastTurn)
this.intents = []
this.clientMessage({
type: "turn",
turn: pastTurn
})
private endTurn() {
if (this.paused) {
return;
}
const pastTurn: Turn = {
turnNumber: this.turns.length,
gameID: this.lobbyConfig.gameID,
intents: this.intents,
};
this.turns.push(pastTurn);
this.intents = [];
this.clientMessage({
type: "turn",
turn: pastTurn,
});
}
public endGame() {
consolex.log('local server ending game')
clearInterval(this.endTurnIntervalID)
const players: PlayerRecord[] = [{
ip: null,
persistentID: getPersistentIDFromCookie(),
username: this.lobbyConfig.playerName(),
clientID: this.lobbyConfig.clientID
}]
const record = CreateGameRecord(
this.lobbyConfig.gameID,
this.gameConfig,
players,
this.turns,
this.startedAt,
Date.now(),
this.winner
)
// Clear turns because beacon only supports up to 64kb
record.turns = []
// For unload events, sendBeacon is the only reliable method
const blob = new Blob([JSON.stringify(GameRecordSchema.parse(record))], {
type: 'application/json'
});
navigator.sendBeacon('/archive_singleplayer_game', blob);
}
}
public endGame() {
consolex.log("local server ending game");
clearInterval(this.endTurnIntervalID);
const players: PlayerRecord[] = [
{
ip: null,
persistentID: getPersistentIDFromCookie(),
username: this.lobbyConfig.playerName(),
clientID: this.lobbyConfig.clientID,
},
];
const record = CreateGameRecord(
this.lobbyConfig.gameID,
this.gameConfig,
players,
this.turns,
this.startedAt,
Date.now(),
this.winner,
);
// Clear turns because beacon only supports up to 64kb
record.turns = [];
// For unload events, sendBeacon is the only reliable method
const blob = new Blob([JSON.stringify(GameRecordSchema.parse(record))], {
type: "application/json",
});
navigator.sendBeacon("/archive_singleplayer_game", blob);
}
}
+118 -105
View File
@@ -1,10 +1,10 @@
import { ClientGameRunner, joinLobby } from "./ClientGameRunner";
import backgroundImage from '../../resources/images/EuropeBackground.png';
import favicon from '../../resources/images/Favicon.svg';
import backgroundImage from "../../resources/images/EuropeBackground.png";
import favicon from "../../resources/images/Favicon.svg";
import './PublicLobby';
import './UsernameInput';
import './styles.css';
import "./PublicLobby";
import "./UsernameInput";
import "./styles.css";
import { UsernameInput } from "./UsernameInput";
import { SinglePlayerModal } from "./SinglePlayerModal";
import { HostLobbyModal as HostPrivateLobbyModal } from "./HostLobbyModal";
@@ -14,130 +14,143 @@ import { generateCryptoRandomUUID } from "./Utils";
import { consolex } from "../core/Consolex";
class Client {
private gameStop: () => void
private gameStop: () => void;
private usernameInput: UsernameInput | null = null;
private usernameInput: UsernameInput | null = null;
private joinModal: JoinPrivateLobbyModal
constructor() {
private joinModal: JoinPrivateLobbyModal;
constructor() {}
initialize(): void {
this.usernameInput = document.querySelector(
"username-input",
) as UsernameInput;
if (!this.usernameInput) {
consolex.warn("Username input element not found");
}
window.addEventListener("beforeunload", (event) => {
consolex.log("Browser is closing");
if (this.gameStop != null) {
this.gameStop();
}
});
initialize(): void {
this.usernameInput = document.querySelector('username-input') as UsernameInput;
if (!this.usernameInput) {
consolex.warn('Username input element not found');
setFavicon();
document.addEventListener("join-lobby", this.handleJoinLobby.bind(this));
document.addEventListener("leave-lobby", this.handleLeaveLobby.bind(this));
document.addEventListener(
"single-player",
this.handleSinglePlayer.bind(this),
);
const spModal = document.querySelector(
"single-player-modal",
) as SinglePlayerModal;
spModal instanceof SinglePlayerModal;
document.getElementById("single-player").addEventListener("click", () => {
if (this.usernameInput.isValid()) {
spModal.open();
}
});
const hostModal = document.querySelector(
"host-lobby-modal",
) as HostPrivateLobbyModal;
hostModal instanceof HostPrivateLobbyModal;
document
.getElementById("host-lobby-button")
.addEventListener("click", () => {
if (this.usernameInput.isValid()) {
hostModal.open();
}
window.addEventListener('beforeunload', (event) => {
consolex.log('Browser is closing');
if (this.gameStop != null) {
this.gameStop()
}
});
});
setFavicon()
document.addEventListener('join-lobby', this.handleJoinLobby.bind(this));
document.addEventListener('leave-lobby', this.handleLeaveLobby.bind(this));
document.addEventListener('single-player', this.handleSinglePlayer.bind(this));
const spModal = document.querySelector('single-player-modal') as SinglePlayerModal;
spModal instanceof SinglePlayerModal
document.getElementById('single-player').addEventListener('click', () => {
if (this.usernameInput.isValid()) {
spModal.open();
}
})
const hostModal = document.querySelector('host-lobby-modal') as HostPrivateLobbyModal;
hostModal instanceof HostPrivateLobbyModal
document.getElementById('host-lobby-button').addEventListener('click', () => {
if (this.usernameInput.isValid()) {
hostModal.open();
}
})
this.joinModal = document.querySelector('join-private-lobby-modal') as JoinPrivateLobbyModal;
this.joinModal instanceof JoinPrivateLobbyModal
document.getElementById('join-private-lobby-button').addEventListener('click', () => {
if (this.usernameInput.isValid()) {
this.joinModal.open();
}
})
}
private async handleJoinLobby(event: CustomEvent) {
const lobby = event.detail.lobby
consolex.log(`joining lobby ${lobby.id}`)
if (this.gameStop != null) {
consolex.log('joining lobby, stopping existing game')
this.gameStop()
this.joinModal = document.querySelector(
"join-private-lobby-modal",
) as JoinPrivateLobbyModal;
this.joinModal instanceof JoinPrivateLobbyModal;
document
.getElementById("join-private-lobby-button")
.addEventListener("click", () => {
if (this.usernameInput.isValid()) {
this.joinModal.open();
}
this.gameStop = joinLobby(
{
gameType: event.detail.gameType,
playerName: (): string => this.usernameInput.getCurrentUsername(),
gameID: lobby.id,
persistentID: getPersistentIDFromCookie(),
playerID: generateID(),
clientID: generateID(),
map: event.detail.map,
difficulty: event.detail.difficulty,
},
() => this.joinModal.close()
);
}
});
}
private async handleLeaveLobby(event: CustomEvent) {
if (this.gameStop == null) {
return
}
consolex.log('leaving lobby, cancelling game')
this.gameStop()
this.gameStop = null
private async handleJoinLobby(event: CustomEvent) {
const lobby = event.detail.lobby;
consolex.log(`joining lobby ${lobby.id}`);
if (this.gameStop != null) {
consolex.log("joining lobby, stopping existing game");
this.gameStop();
}
this.gameStop = joinLobby(
{
gameType: event.detail.gameType,
playerName: (): string => this.usernameInput.getCurrentUsername(),
gameID: lobby.id,
persistentID: getPersistentIDFromCookie(),
playerID: generateID(),
clientID: generateID(),
map: event.detail.map,
difficulty: event.detail.difficulty,
},
() => this.joinModal.close(),
);
}
private async handleSinglePlayer(event: CustomEvent) {
alert('coming soon')
private async handleLeaveLobby(event: CustomEvent) {
if (this.gameStop == null) {
return;
}
consolex.log("leaving lobby, cancelling game");
this.gameStop();
this.gameStop = null;
}
private async handleSinglePlayer(event: CustomEvent) {
alert("coming soon");
}
}
// Initialize the client when the DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
new Client().initialize();
document.addEventListener("DOMContentLoaded", () => {
new Client().initialize();
});
document.body.style.backgroundImage = `url(${backgroundImage})`;
function setFavicon(): void {
const link = document.createElement('link');
link.type = 'image/x-icon';
link.rel = 'shortcut icon';
link.href = favicon;
document.head.appendChild(link);
const link = document.createElement("link");
link.type = "image/x-icon";
link.rel = "shortcut icon";
link.href = favicon;
document.head.appendChild(link);
}
// WARNING: DO NOT EXPOSE THIS ID
export function getPersistentIDFromCookie(): string {
const COOKIE_NAME = 'player_persistent_id'
const COOKIE_NAME = "player_persistent_id";
// Try to get existing cookie
const cookies = document.cookie.split(';')
for (let cookie of cookies) {
const [cookieName, cookieValue] = cookie.split('=').map(c => c.trim())
if (cookieName === COOKIE_NAME) {
return cookieValue
}
// Try to get existing cookie
const cookies = document.cookie.split(";");
for (let cookie of cookies) {
const [cookieName, cookieValue] = cookie.split("=").map((c) => c.trim());
if (cookieName === COOKIE_NAME) {
return cookieValue;
}
}
// If no cookie exists, create new ID and set cookie
const newID = generateCryptoRandomUUID()
document.cookie = [
`${COOKIE_NAME}=${newID}`,
`max-age=${5 * 365 * 24 * 60 * 60}`, // 5 years
'path=/',
'SameSite=Strict',
'Secure'
].join(';')
// If no cookie exists, create new ID and set cookie
const newID = generateCryptoRandomUUID();
document.cookie = [
`${COOKIE_NAME}=${newID}`,
`max-age=${5 * 365 * 24 * 60 * 60}`, // 5 years
"path=/",
"SameSite=Strict",
"Secure",
].join(";");
return newID
}
return newID;
}
+98 -89
View File
@@ -1,100 +1,109 @@
import { LitElement, html } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import { LitElement, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { Lobby } from "../core/Schemas";
import { Difficulty, GameMapType, GameType } from '../core/game/Game';
import { consolex } from '../core/Consolex';
import { Difficulty, GameMapType, GameType } from "../core/game/Game";
import { consolex } from "../core/Consolex";
@customElement('public-lobby')
@customElement("public-lobby")
export class PublicLobby extends LitElement {
@state() private lobbies: Lobby[] = [];
@state() private isLobbyHighlighted: boolean = false;
private lobbiesInterval: number | null = null;
private currLobby: Lobby = null;
@state() private lobbies: Lobby[] = [];
@state() private isLobbyHighlighted: boolean = false;
private lobbiesInterval: number | null = null;
private currLobby: Lobby = null;
createRenderRoot() {
return this;
createRenderRoot() {
return this;
}
connectedCallback() {
super.connectedCallback();
this.fetchAndUpdateLobbies();
this.lobbiesInterval = window.setInterval(
() => this.fetchAndUpdateLobbies(),
1000,
);
}
disconnectedCallback() {
super.disconnectedCallback();
if (this.lobbiesInterval !== null) {
clearInterval(this.lobbiesInterval);
this.lobbiesInterval = null;
}
}
connectedCallback() {
super.connectedCallback();
this.fetchAndUpdateLobbies();
this.lobbiesInterval = window.setInterval(() => this.fetchAndUpdateLobbies(), 1000);
private async fetchAndUpdateLobbies(): Promise<void> {
try {
const lobbies = await this.fetchLobbies();
this.lobbies = lobbies;
} catch (error) {
consolex.error("Error fetching lobbies:", error);
}
}
disconnectedCallback() {
super.disconnectedCallback();
if (this.lobbiesInterval !== null) {
clearInterval(this.lobbiesInterval);
this.lobbiesInterval = null;
}
async fetchLobbies(): Promise<Lobby[]> {
try {
const response = await fetch("/lobbies");
if (!response.ok)
throw new Error(`HTTP error! status: ${response.status}`);
const data = await response.json();
return data.lobbies;
} catch (error) {
consolex.error("Error fetching lobbies:", error);
throw error;
}
}
private async fetchAndUpdateLobbies(): Promise<void> {
try {
const lobbies = await this.fetchLobbies();
this.lobbies = lobbies;
} catch (error) {
consolex.error('Error fetching lobbies:', error);
}
render() {
if (this.lobbies.length === 0) return html``;
const lobby = this.lobbies[0];
const timeRemaining = Math.max(0, Math.floor(lobby.msUntilStart / 1000));
return html`
<button
@click=${() => this.lobbyClicked(lobby)}
class="w-full mx-auto p-4 md:p-6 ${this.isLobbyHighlighted
? "bg-gradient-to-r from-green-600 to-green-500"
: "bg-gradient-to-r from-blue-600 to-blue-500"} text-white font-medium rounded-xl transition-opacity duration-200 hover:opacity-90"
>
<div class="text-lg md:text-2xl font-semibold mb-2">Next Game</div>
<div
class="flex flex-col gap-1 md:gap-2 text-blue-100 text-s md:text-lg"
>
<div>Starts in: ${timeRemaining}s</div>
<div>Players: ${lobby.numClients}</div>
<div>ID: ${lobby.id}</div>
</div>
</button>
`;
}
private lobbyClicked(lobby: Lobby) {
this.isLobbyHighlighted = !this.isLobbyHighlighted;
if (this.currLobby == null) {
this.currLobby = lobby;
this.dispatchEvent(
new CustomEvent("join-lobby", {
detail: {
lobby,
gameType: GameType.Public,
map: GameMapType.World,
difficulty: Difficulty.Medium,
},
bubbles: true,
composed: true,
}),
);
} else {
this.dispatchEvent(
new CustomEvent("leave-lobby", {
detail: { lobby: this.currLobby },
bubbles: true,
composed: true,
}),
);
this.currLobby = null;
}
async fetchLobbies(): Promise<Lobby[]> {
try {
const response = await fetch('/lobbies');
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const data = await response.json();
return data.lobbies;
} catch (error) {
consolex.error('Error fetching lobbies:', error);
throw error;
}
}
render() {
if (this.lobbies.length === 0) return html``;
const lobby = this.lobbies[0];
const timeRemaining = Math.max(0, Math.floor(lobby.msUntilStart / 1000));
return html`
<button
@click=${() => this.lobbyClicked(lobby)}
class="w-full mx-auto p-4 md:p-6 ${this.isLobbyHighlighted
? 'bg-gradient-to-r from-green-600 to-green-500'
: 'bg-gradient-to-r from-blue-600 to-blue-500'
} text-white font-medium rounded-xl transition-opacity duration-200 hover:opacity-90"
>
<div class="text-lg md:text-2xl font-semibold mb-2">Next Game</div>
<div class="flex flex-col gap-1 md:gap-2 text-blue-100 text-s md:text-lg">
<div>Starts in: ${timeRemaining}s</div>
<div>Players: ${lobby.numClients}</div>
<div>ID: ${lobby.id}</div>
</div>
</button>
`;
}
private lobbyClicked(lobby: Lobby) {
this.isLobbyHighlighted = !this.isLobbyHighlighted;
if (this.currLobby == null) {
this.currLobby = lobby;
this.dispatchEvent(new CustomEvent('join-lobby', {
detail: {
lobby,
gameType: GameType.Public,
map: GameMapType.World,
difficulty: Difficulty.Medium,
},
bubbles: true,
composed: true
}));
} else {
this.dispatchEvent(new CustomEvent('leave-lobby', {
detail: { lobby: this.currLobby },
bubbles: true,
composed: true
}));
this.currLobby = null;
}
}
}
}
}
+55 -34
View File
@@ -1,10 +1,10 @@
import { LitElement, html, css } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { Difficulty, GameMapType, GameType } from '../core/game/Game';
import { generateID as generateID } from '../core/Util';
import { consolex } from '../core/Consolex';
import { LitElement, html, css } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { Difficulty, GameMapType, GameType } from "../core/game/Game";
import { generateID as generateID } from "../core/Util";
import { consolex } from "../core/Consolex";
@customElement('single-player-modal')
@customElement("single-player-modal")
export class SinglePlayerModal extends LitElement {
@state() private isModalOpen = false;
@state() private selectedMap: GameMapType = GameMapType.World;
@@ -74,7 +74,10 @@ export class SinglePlayerModal extends LitElement {
render() {
return html`
<div class="modal-overlay" style="display: ${this.isModalOpen ? 'block' : 'none'}">
<div
class="modal-overlay"
style="display: ${this.isModalOpen ? "block" : "none"}"
>
<div class="modal-content">
<span class="close" @click=${this.close}>&times;</span>
<h2>Start Single Player Game</h2>
@@ -82,24 +85,34 @@ export class SinglePlayerModal extends LitElement {
<label for="map-select">Map: </label>
<select id="map-select" @change=${this.handleMapChange}>
${Object.entries(GameMapType)
.filter(([key]) => isNaN(Number(key)))
.map(([key, value]) => html`
<option value=${value} ?selected=${this.selectedMap === value}>
${value}
</option>
`)}
.filter(([key]) => isNaN(Number(key)))
.map(
([key, value]) => html`
<option
value=${value}
?selected=${this.selectedMap === value}
>
${value}
</option>
`,
)}
</select>
</div>
<div>
<label for="map-select">Difficulty: </label>
<select id="map-select" @change=${this.handleDifficultyChange}>
${Object.entries(Difficulty)
.filter(([key]) => isNaN(Number(key)))
.map(([key, value]) => html`
<option value=${value} ?selected=${this.selectedDifficulty === value}>
${value}
</option>
`)}
.filter(([key]) => isNaN(Number(key)))
.map(
([key, value]) => html`
<option
value=${value}
?selected=${this.selectedDifficulty === value}
>
${value}
</option>
`,
)}
</select>
</div>
@@ -118,25 +131,33 @@ export class SinglePlayerModal extends LitElement {
}
private handleMapChange(e: Event) {
this.selectedMap = String((e.target as HTMLSelectElement).value) as GameMapType;
this.selectedMap = String(
(e.target as HTMLSelectElement).value,
) as GameMapType;
}
private handleDifficultyChange(e: Event) {
this.selectedDifficulty = String((e.target as HTMLSelectElement).value) as Difficulty;
this.selectedDifficulty = String(
(e.target as HTMLSelectElement).value,
) as Difficulty;
}
private startGame() {
consolex.log(`Starting single player game with map: ${GameMapType[this.selectedMap]}`);
this.dispatchEvent(new CustomEvent('join-lobby', {
detail: {
gameType: GameType.Singleplayer,
lobby: {
id: generateID(),
consolex.log(
`Starting single player game with map: ${GameMapType[this.selectedMap]}`,
);
this.dispatchEvent(
new CustomEvent("join-lobby", {
detail: {
gameType: GameType.Singleplayer,
lobby: {
id: generateID(),
},
map: this.selectedMap,
difficulty: this.selectedDifficulty,
},
map: this.selectedMap,
difficulty: this.selectedDifficulty
},
bubbles: true,
composed: true
}));
bubbles: true,
composed: true,
}),
);
this.close();
}
}
}
+438 -381
View File
@@ -1,448 +1,505 @@
import { Config, ServerConfig } from "../core/configuration/Config"
import { SendLogEvent } from "../core/Consolex"
import { EventBus, GameEvent } from "../core/EventBus"
import { AllianceRequest, AllPlayers, Cell, GameType, Player, PlayerID, PlayerType, UnitType } from "../core/game/Game"
import { ClientID, ClientIntentMessageSchema, ClientJoinMessageSchema, GameID, Intent, ServerMessage, ServerMessageSchema, ClientPingMessageSchema, GameConfig, ClientLogMessageSchema, ClientSendWinnerSchema } from "../core/Schemas"
import { LobbyConfig } from "./ClientGameRunner"
import { LocalServer } from "./LocalServer"
import { Config, ServerConfig } from "../core/configuration/Config";
import { SendLogEvent } from "../core/Consolex";
import { EventBus, GameEvent } from "../core/EventBus";
import {
AllianceRequest,
AllPlayers,
Cell,
GameType,
Player,
PlayerID,
PlayerType,
UnitType,
} from "../core/game/Game";
import {
ClientID,
ClientIntentMessageSchema,
ClientJoinMessageSchema,
GameID,
Intent,
ServerMessage,
ServerMessageSchema,
ClientPingMessageSchema,
GameConfig,
ClientLogMessageSchema,
ClientSendWinnerSchema,
} from "../core/Schemas";
import { LobbyConfig } from "./ClientGameRunner";
import { LocalServer } from "./LocalServer";
import { UsernameInput } from "./UsernameInput";
import { HostLobbyModal as HostPrivateLobbyModal } from "./HostLobbyModal";
import { JoinPrivateLobbyModal } from "./JoinPrivateLobbyModal";
import { SinglePlayerModal } from "./SinglePlayerModal";
import { PlayerView } from "../core/game/GameView"
import { PlayerView } from "../core/game/GameView";
export class PauseGameEvent implements GameEvent {
constructor(public readonly paused: boolean) { }
constructor(public readonly paused: boolean) {}
}
export class SendAllianceRequestIntentEvent implements GameEvent {
constructor(
public readonly requestor: PlayerView,
public readonly recipient: PlayerView
) { }
constructor(
public readonly requestor: PlayerView,
public readonly recipient: PlayerView,
) {}
}
export class SendBreakAllianceIntentEvent implements GameEvent {
constructor(
public readonly requestor: PlayerView,
public readonly recipient: PlayerView
) { }
constructor(
public readonly requestor: PlayerView,
public readonly recipient: PlayerView,
) {}
}
export class SendAllianceReplyIntentEvent implements GameEvent {
constructor(
// The original alliance requestor
public readonly requestor: PlayerView,
public readonly recipient: PlayerView,
public readonly accepted: boolean
) { }
constructor(
// The original alliance requestor
public readonly requestor: PlayerView,
public readonly recipient: PlayerView,
public readonly accepted: boolean,
) {}
}
export class SendSpawnIntentEvent implements GameEvent {
constructor(
public readonly cell: Cell,
) { }
constructor(public readonly cell: Cell) {}
}
export class SendAttackIntentEvent implements GameEvent {
constructor(
public readonly targetID: PlayerID,
public readonly troops: number,
) { }
constructor(
public readonly targetID: PlayerID,
public readonly troops: number,
) {}
}
export class SendBoatAttackIntentEvent implements GameEvent {
constructor(
public readonly targetID: PlayerID,
public readonly cell: Cell,
public readonly troops: number
) { }
constructor(
public readonly targetID: PlayerID,
public readonly cell: Cell,
public readonly troops: number,
) {}
}
export class BuildUnitIntentEvent implements GameEvent {
constructor(
public readonly unit: UnitType,
public readonly cell: Cell,
) { }
constructor(
public readonly unit: UnitType,
public readonly cell: Cell,
) {}
}
export class SendTargetPlayerIntentEvent implements GameEvent {
constructor(
public readonly targetID: PlayerID,
) { }
constructor(public readonly targetID: PlayerID) {}
}
export class SendEmojiIntentEvent implements GameEvent {
constructor(
public readonly recipient: PlayerView | typeof AllPlayers,
public readonly emoji: string
) { }
constructor(
public readonly recipient: PlayerView | typeof AllPlayers,
public readonly emoji: string,
) {}
}
export class SendDonateIntentEvent implements GameEvent {
constructor(
public readonly sender: PlayerView,
public readonly recipient: PlayerView,
public readonly troops: number | null,
) { }
constructor(
public readonly sender: PlayerView,
public readonly recipient: PlayerView,
public readonly troops: number | null,
) {}
}
export class SendSetTargetTroopRatioEvent implements GameEvent {
constructor(
public readonly ratio: number,
) { }
constructor(public readonly ratio: number) {}
}
export class SendWinnerEvent implements GameEvent {
constructor(
public readonly winner: ClientID
) { }
constructor(public readonly winner: ClientID) {}
}
export class Transport {
private socket: WebSocket;
private socket: WebSocket
private localServer: LocalServer;
private localServer: LocalServer
private buffer: string[] = [];
private buffer: string[] = []
private onconnect: () => void;
private onmessage: (msg: ServerMessage) => void;
private pingInterval: number | null = null;
private isLocal: boolean;
private onconnect: () => void
private onmessage: (msg: ServerMessage) => void
constructor(
private lobbyConfig: LobbyConfig,
// gameConfig only set on private games
private gameConfig: GameConfig | null,
private eventBus: EventBus,
private serverConfig: ServerConfig,
) {
this.isLocal = lobbyConfig.gameType == GameType.Singleplayer;
this.eventBus.on(SendAllianceRequestIntentEvent, (e) =>
this.onSendAllianceRequest(e),
);
this.eventBus.on(SendAllianceReplyIntentEvent, (e) =>
this.onAllianceRequestReplyUIEvent(e),
);
this.eventBus.on(SendBreakAllianceIntentEvent, (e) =>
this.onBreakAllianceRequestUIEvent(e),
);
this.eventBus.on(SendSpawnIntentEvent, (e) =>
this.onSendSpawnIntentEvent(e),
);
this.eventBus.on(SendAttackIntentEvent, (e) => this.onSendAttackIntent(e));
this.eventBus.on(SendBoatAttackIntentEvent, (e) =>
this.onSendBoatAttackIntent(e),
);
this.eventBus.on(SendTargetPlayerIntentEvent, (e) =>
this.onSendTargetPlayerIntent(e),
);
this.eventBus.on(SendEmojiIntentEvent, (e) => this.onSendEmojiIntent(e));
this.eventBus.on(SendDonateIntentEvent, (e) => this.onSendDonateIntent(e));
this.eventBus.on(SendSetTargetTroopRatioEvent, (e) =>
this.onSendSetTargetTroopRatioEvent(e),
);
this.eventBus.on(BuildUnitIntentEvent, (e) => this.onBuildUnitIntent(e));
private pingInterval: number | null = null
private isLocal: boolean
this.eventBus.on(SendLogEvent, (e) => this.onSendLogEvent(e));
this.eventBus.on(PauseGameEvent, (e) => this.onPauseGameEvent(e));
this.eventBus.on(SendWinnerEvent, (e) => this.onSendWinnerEvent(e));
}
constructor(
private lobbyConfig: LobbyConfig,
// gameConfig only set on private games
private gameConfig: GameConfig | null,
private eventBus: EventBus,
private serverConfig: ServerConfig,
) {
this.isLocal = lobbyConfig.gameType == GameType.Singleplayer
this.eventBus.on(SendAllianceRequestIntentEvent, (e) => this.onSendAllianceRequest(e))
this.eventBus.on(SendAllianceReplyIntentEvent, (e) => this.onAllianceRequestReplyUIEvent(e))
this.eventBus.on(SendBreakAllianceIntentEvent, (e) => this.onBreakAllianceRequestUIEvent(e))
this.eventBus.on(SendSpawnIntentEvent, (e) => this.onSendSpawnIntentEvent(e))
this.eventBus.on(SendAttackIntentEvent, (e) => this.onSendAttackIntent(e))
this.eventBus.on(SendBoatAttackIntentEvent, (e) => this.onSendBoatAttackIntent(e))
this.eventBus.on(SendTargetPlayerIntentEvent, (e) => this.onSendTargetPlayerIntent(e))
this.eventBus.on(SendEmojiIntentEvent, (e) => this.onSendEmojiIntent(e))
this.eventBus.on(SendDonateIntentEvent, (e) => this.onSendDonateIntent(e))
this.eventBus.on(SendSetTargetTroopRatioEvent, (e) => this.onSendSetTargetTroopRatioEvent(e))
this.eventBus.on(BuildUnitIntentEvent, (e) => this.onBuildUnitIntent(e))
this.eventBus.on(SendLogEvent, (e) => this.onSendLogEvent(e))
this.eventBus.on(PauseGameEvent, (e) => this.onPauseGameEvent(e))
this.eventBus.on(SendWinnerEvent, (e) => this.onSendWinnerEvent(e))
}
private startPing() {
if (this.isLocal || this.pingInterval) return;
if (this.pingInterval == null) {
this.pingInterval = window.setInterval(() => {
if (this.socket != null && this.socket.readyState === WebSocket.OPEN) {
this.sendMsg(JSON.stringify(ClientPingMessageSchema.parse({
type: 'ping',
clientID: this.lobbyConfig.clientID,
gameID: this.lobbyConfig.gameID,
})))
}
}, 5 * 1000);
}
}
private stopPing() {
if (this.pingInterval) {
window.clearInterval(this.pingInterval);
this.pingInterval = null;
}
}
public connect(onconnect: () => void, onmessage: (message: ServerMessage) => void) {
if (this.isLocal) {
this.connectLocal(onconnect, onmessage)
} else {
this.connectRemote(onconnect, onmessage)
}
}
private connectLocal(onconnect: () => void, onmessage: (message: ServerMessage) => void) {
this.localServer = new LocalServer(this.serverConfig, this.gameConfig, this.lobbyConfig, onconnect, onmessage)
this.localServer.start()
}
private connectRemote(onconnect: () => void, onmessage: (message: ServerMessage) => void) {
this.startPing()
this.maybeKillSocket()
const wsHost = process.env.WEBSOCKET_URL || window.location.host;
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
this.socket = new WebSocket(`${wsProtocol}//${wsHost}`)
this.onconnect = onconnect
this.onmessage = onmessage
this.socket.onopen = () => {
console.log('Connected to game server!');
while (this.buffer.length > 0) {
console.log('sending dropped message')
this.sendMsg(this.buffer.pop())
}
onconnect()
};
this.socket.onmessage = (event: MessageEvent) => {
try {
const serverMsg = ServerMessageSchema.parse(JSON.parse(event.data));
this.onmessage(serverMsg);
} catch (error) {
console.error('Failed to process server message:', error);
}
};
this.socket.onerror = (err) => {
console.error('Socket encountered error: ', err, 'Closing socket');
this.socket.close();
};
this.socket.onclose = (event: CloseEvent) => {
console.log(`WebSocket closed. Code: ${event.code}, Reason: ${event.reason}`);
if (event.code != 1000) {
console.log(`reconnecting`)
this.connect(onconnect, onmessage)
}
};
}
private onSendLogEvent(event: SendLogEvent) {
this.sendMsg(
private startPing() {
if (this.isLocal || this.pingInterval) return;
if (this.pingInterval == null) {
this.pingInterval = window.setInterval(() => {
if (this.socket != null && this.socket.readyState === WebSocket.OPEN) {
this.sendMsg(
JSON.stringify(
ClientLogMessageSchema.parse({
type: "log",
gameID: this.lobbyConfig.gameID,
clientID: this.lobbyConfig.clientID,
persistentID: this.lobbyConfig.persistentID,
log: event.log,
severity: event.severity,
})
)
)
}
joinGame(numTurns: number) {
this.sendMsg(
JSON.stringify(
ClientJoinMessageSchema.parse({
type: "join",
gameID: this.lobbyConfig.gameID,
clientID: this.lobbyConfig.clientID,
lastTurn: numTurns,
persistentID: this.lobbyConfig.persistentID,
username: this.lobbyConfig.playerName()
})
)
)
}
leaveGame() {
if (this.isLocal) {
this.localServer.endGame()
return
}
this.stopPing()
if (this.socket.readyState === WebSocket.OPEN) {
console.log('on stop: leaving game')
this.socket.close()
} else {
console.log('WebSocket is not open. Current state:', this.socket.readyState);
console.error('attempting reconnect')
}
this.socket.onclose = (event: CloseEvent) => { }
}
private onSendAllianceRequest(event: SendAllianceRequestIntentEvent) {
this.sendIntent({
type: "allianceRequest",
clientID: this.lobbyConfig.clientID,
requestor: event.requestor.id(),
recipient: event.recipient.id(),
})
}
private onAllianceRequestReplyUIEvent(event: SendAllianceReplyIntentEvent) {
this.sendIntent({
type: "allianceRequestReply",
clientID: this.lobbyConfig.clientID,
requestor: event.requestor.id(),
recipient: event.recipient.id(),
accept: event.accepted,
})
}
private onBreakAllianceRequestUIEvent(event: SendBreakAllianceIntentEvent) {
this.sendIntent({
type: "breakAlliance",
clientID: this.lobbyConfig.clientID,
requestor: event.requestor.id(),
recipient: event.recipient.id(),
})
}
private onSendSpawnIntentEvent(event: SendSpawnIntentEvent) {
this.sendIntent({
type: "spawn",
clientID: this.lobbyConfig.clientID,
playerID: this.lobbyConfig.playerID,
name: this.lobbyConfig.playerName(),
playerType: PlayerType.Human,
x: event.cell.x,
y: event.cell.y
})
}
private onSendAttackIntent(event: SendAttackIntentEvent) {
this.sendIntent({
type: "attack",
clientID: this.lobbyConfig.clientID,
attackerID: this.lobbyConfig.playerID,
targetID: event.targetID,
troops: event.troops,
})
}
private onSendBoatAttackIntent(event: SendBoatAttackIntentEvent) {
this.sendIntent({
type: "boat",
clientID: this.lobbyConfig.clientID,
attackerID: this.lobbyConfig.playerID,
targetID: event.targetID,
troops: event.troops,
x: event.cell.x,
y: event.cell.y,
})
}
private onSendTargetPlayerIntent(event: SendTargetPlayerIntentEvent) {
this.sendIntent({
type: "targetPlayer",
clientID: this.lobbyConfig.clientID,
requestor: this.lobbyConfig.playerID,
target: event.targetID,
})
}
private onSendEmojiIntent(event: SendEmojiIntentEvent) {
this.sendIntent({
type: "emoji",
clientID: this.lobbyConfig.clientID,
sender: this.lobbyConfig.playerID,
recipient: event.recipient == AllPlayers ? AllPlayers : event.recipient.id(),
emoji: event.emoji
})
}
private onSendDonateIntent(event: SendDonateIntentEvent) {
this.sendIntent({
type: "donate",
clientID: this.lobbyConfig.clientID,
sender: event.sender.id(),
recipient: event.recipient.id(),
troops: event.troops,
})
}
private onSendSetTargetTroopRatioEvent(event: SendSetTargetTroopRatioEvent) {
this.sendIntent({
type: "troop_ratio",
clientID: this.lobbyConfig.clientID,
player: this.lobbyConfig.playerID,
ratio: event.ratio,
})
}
private onBuildUnitIntent(event: BuildUnitIntentEvent) {
this.sendIntent({
type: "build_unit",
clientID: this.lobbyConfig.clientID,
player: this.lobbyConfig.playerID,
unit: event.unit,
x: event.cell.x,
y: event.cell.y,
})
}
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 onSendWinnerEvent(event: SendWinnerEvent) {
if (this.isLocal || this.socket.readyState === WebSocket.OPEN) {
const msg = ClientSendWinnerSchema.parse({
type: "winner",
ClientPingMessageSchema.parse({
type: "ping",
clientID: this.lobbyConfig.clientID,
gameID: this.lobbyConfig.gameID,
winner: event.winner,
})
this.sendMsg(JSON.stringify(msg))
} else {
console.log('WebSocket is not open. Current state:', this.socket.readyState);
console.log('attempting reconnect')
}),
),
);
}
}, 5 * 1000);
}
}
private sendIntent(intent: Intent) {
if (this.isLocal || this.socket.readyState === WebSocket.OPEN) {
const msg = ClientIntentMessageSchema.parse({
type: "intent",
clientID: this.lobbyConfig.clientID,
gameID: this.lobbyConfig.gameID,
intent: intent
})
this.sendMsg(JSON.stringify(msg))
} else {
console.log('WebSocket is not open. Current state:', this.socket.readyState);
console.log('attempting reconnect')
}
private stopPing() {
if (this.pingInterval) {
window.clearInterval(this.pingInterval);
this.pingInterval = null;
}
}
private sendMsg(msg: string) {
if (this.isLocal) {
this.localServer.onMessage(msg)
} else {
if (this.socket.readyState == WebSocket.CLOSED || this.socket.readyState == WebSocket.CLOSED) {
console.warn('socket not ready, closing and trying later')
this.socket.close()
this.socket = null
this.connectRemote(this.onconnect, this.onmessage)
this.buffer.push(msg)
} else {
this.socket.send(msg)
}
}
public connect(
onconnect: () => void,
onmessage: (message: ServerMessage) => void,
) {
if (this.isLocal) {
this.connectLocal(onconnect, onmessage);
} else {
this.connectRemote(onconnect, onmessage);
}
}
private maybeKillSocket(): void {
if (this.socket == null) {
return
}
// Remove all event listeners
this.socket.onmessage = null;
this.socket.onopen = null;
this.socket.onclose = null;
this.socket.onerror = null;
private connectLocal(
onconnect: () => void,
onmessage: (message: ServerMessage) => void,
) {
this.localServer = new LocalServer(
this.serverConfig,
this.gameConfig,
this.lobbyConfig,
onconnect,
onmessage,
);
this.localServer.start();
}
// Close the connection if it's still open
if (this.socket.readyState === WebSocket.OPEN) {
this.socket.close();
}
this.socket = null
private connectRemote(
onconnect: () => void,
onmessage: (message: ServerMessage) => void,
) {
this.startPing();
this.maybeKillSocket();
const wsHost = process.env.WEBSOCKET_URL || window.location.host;
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
this.socket = new WebSocket(`${wsProtocol}//${wsHost}`);
this.onconnect = onconnect;
this.onmessage = onmessage;
this.socket.onopen = () => {
console.log("Connected to game server!");
while (this.buffer.length > 0) {
console.log("sending dropped message");
this.sendMsg(this.buffer.pop());
}
onconnect();
};
this.socket.onmessage = (event: MessageEvent) => {
try {
const serverMsg = ServerMessageSchema.parse(JSON.parse(event.data));
this.onmessage(serverMsg);
} catch (error) {
console.error("Failed to process server message:", error);
}
};
this.socket.onerror = (err) => {
console.error("Socket encountered error: ", err, "Closing socket");
this.socket.close();
};
this.socket.onclose = (event: CloseEvent) => {
console.log(
`WebSocket closed. Code: ${event.code}, Reason: ${event.reason}`,
);
if (event.code != 1000) {
console.log(`reconnecting`);
this.connect(onconnect, onmessage);
}
};
}
private onSendLogEvent(event: SendLogEvent) {
this.sendMsg(
JSON.stringify(
ClientLogMessageSchema.parse({
type: "log",
gameID: this.lobbyConfig.gameID,
clientID: this.lobbyConfig.clientID,
persistentID: this.lobbyConfig.persistentID,
log: event.log,
severity: event.severity,
}),
),
);
}
joinGame(numTurns: number) {
this.sendMsg(
JSON.stringify(
ClientJoinMessageSchema.parse({
type: "join",
gameID: this.lobbyConfig.gameID,
clientID: this.lobbyConfig.clientID,
lastTurn: numTurns,
persistentID: this.lobbyConfig.persistentID,
username: this.lobbyConfig.playerName(),
}),
),
);
}
leaveGame() {
if (this.isLocal) {
this.localServer.endGame();
return;
}
this.stopPing();
if (this.socket.readyState === WebSocket.OPEN) {
console.log("on stop: leaving game");
this.socket.close();
} else {
console.log(
"WebSocket is not open. Current state:",
this.socket.readyState,
);
console.error("attempting reconnect");
}
this.socket.onclose = (event: CloseEvent) => {};
}
private onSendAllianceRequest(event: SendAllianceRequestIntentEvent) {
this.sendIntent({
type: "allianceRequest",
clientID: this.lobbyConfig.clientID,
requestor: event.requestor.id(),
recipient: event.recipient.id(),
});
}
private onAllianceRequestReplyUIEvent(event: SendAllianceReplyIntentEvent) {
this.sendIntent({
type: "allianceRequestReply",
clientID: this.lobbyConfig.clientID,
requestor: event.requestor.id(),
recipient: event.recipient.id(),
accept: event.accepted,
});
}
private onBreakAllianceRequestUIEvent(event: SendBreakAllianceIntentEvent) {
this.sendIntent({
type: "breakAlliance",
clientID: this.lobbyConfig.clientID,
requestor: event.requestor.id(),
recipient: event.recipient.id(),
});
}
private onSendSpawnIntentEvent(event: SendSpawnIntentEvent) {
this.sendIntent({
type: "spawn",
clientID: this.lobbyConfig.clientID,
playerID: this.lobbyConfig.playerID,
name: this.lobbyConfig.playerName(),
playerType: PlayerType.Human,
x: event.cell.x,
y: event.cell.y,
});
}
private onSendAttackIntent(event: SendAttackIntentEvent) {
this.sendIntent({
type: "attack",
clientID: this.lobbyConfig.clientID,
attackerID: this.lobbyConfig.playerID,
targetID: event.targetID,
troops: event.troops,
});
}
private onSendBoatAttackIntent(event: SendBoatAttackIntentEvent) {
this.sendIntent({
type: "boat",
clientID: this.lobbyConfig.clientID,
attackerID: this.lobbyConfig.playerID,
targetID: event.targetID,
troops: event.troops,
x: event.cell.x,
y: event.cell.y,
});
}
private onSendTargetPlayerIntent(event: SendTargetPlayerIntentEvent) {
this.sendIntent({
type: "targetPlayer",
clientID: this.lobbyConfig.clientID,
requestor: this.lobbyConfig.playerID,
target: event.targetID,
});
}
private onSendEmojiIntent(event: SendEmojiIntentEvent) {
this.sendIntent({
type: "emoji",
clientID: this.lobbyConfig.clientID,
sender: this.lobbyConfig.playerID,
recipient:
event.recipient == AllPlayers ? AllPlayers : event.recipient.id(),
emoji: event.emoji,
});
}
private onSendDonateIntent(event: SendDonateIntentEvent) {
this.sendIntent({
type: "donate",
clientID: this.lobbyConfig.clientID,
sender: event.sender.id(),
recipient: event.recipient.id(),
troops: event.troops,
});
}
private onSendSetTargetTroopRatioEvent(event: SendSetTargetTroopRatioEvent) {
this.sendIntent({
type: "troop_ratio",
clientID: this.lobbyConfig.clientID,
player: this.lobbyConfig.playerID,
ratio: event.ratio,
});
}
private onBuildUnitIntent(event: BuildUnitIntentEvent) {
this.sendIntent({
type: "build_unit",
clientID: this.lobbyConfig.clientID,
player: this.lobbyConfig.playerID,
unit: event.unit,
x: event.cell.x,
y: event.cell.y,
});
}
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 onSendWinnerEvent(event: SendWinnerEvent) {
if (this.isLocal || this.socket.readyState === WebSocket.OPEN) {
const msg = ClientSendWinnerSchema.parse({
type: "winner",
clientID: this.lobbyConfig.clientID,
gameID: this.lobbyConfig.gameID,
winner: event.winner,
});
this.sendMsg(JSON.stringify(msg));
} else {
console.log(
"WebSocket is not open. Current state:",
this.socket.readyState,
);
console.log("attempting reconnect");
}
}
private sendIntent(intent: Intent) {
if (this.isLocal || this.socket.readyState === WebSocket.OPEN) {
const msg = ClientIntentMessageSchema.parse({
type: "intent",
clientID: this.lobbyConfig.clientID,
gameID: this.lobbyConfig.gameID,
intent: intent,
});
this.sendMsg(JSON.stringify(msg));
} else {
console.log(
"WebSocket is not open. Current state:",
this.socket.readyState,
);
console.log("attempting reconnect");
}
}
private sendMsg(msg: string) {
if (this.isLocal) {
this.localServer.onMessage(msg);
} else {
if (
this.socket.readyState == WebSocket.CLOSED ||
this.socket.readyState == WebSocket.CLOSED
) {
console.warn("socket not ready, closing and trying later");
this.socket.close();
this.socket = null;
this.connectRemote(this.onconnect, this.onmessage);
this.buffer.push(msg);
} else {
this.socket.send(msg);
}
}
}
private maybeKillSocket(): void {
if (this.socket == null) {
return;
}
// Remove all event listeners
this.socket.onmessage = null;
this.socket.onopen = null;
this.socket.onclose = null;
this.socket.onerror = null;
// Close the connection if it's still open
if (this.socket.readyState === WebSocket.OPEN) {
this.socket.close();
}
this.socket = null;
}
}
+95 -86
View File
@@ -1,100 +1,109 @@
import { LitElement, html } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { v4 as uuidv4 } from 'uuid';
import { MAX_USERNAME_LENGTH, validateUsername } from '../core/validations/username';
import { LitElement, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { v4 as uuidv4 } from "uuid";
import {
MAX_USERNAME_LENGTH,
validateUsername,
} from "../core/validations/username";
const usernameKey: string = 'username';
const usernameKey: string = "username";
@customElement('username-input')
@customElement("username-input")
export class UsernameInput extends LitElement {
@state() private username: string = '';
@property({ type: String }) validationError: string = '';
private _isValid: boolean = true;
@state() private username: string = "";
@property({ type: String }) validationError: string = "";
private _isValid: boolean = true;
// Remove static styles since we're using Tailwind
// Remove static styles since we're using Tailwind
createRenderRoot() {
// Disable shadow DOM to allow Tailwind classes to work
return this;
createRenderRoot() {
// Disable shadow DOM to allow Tailwind classes to work
return this;
}
public getCurrentUsername(): string {
return this.username;
}
connectedCallback() {
super.connectedCallback();
this.username = this.getStoredUsername();
this.dispatchUsernameEvent();
}
render() {
return html`
<input
type="text"
.value=${this.username}
@input=${this.handleChange}
@change=${this.handleChange}
placeholder="Enter your username"
maxlength="${MAX_USERNAME_LENGTH}"
class="w-full px-4 py-2 bg-white border border-gray-300 rounded-xl shadow-sm text-2xl text-gray-900 text-center focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
${this.validationError
? html`<div
class="mt-2 px-3 py-1 text-lg text-red-600 bg-white border border-red-600 rounded"
>
${this.validationError}
</div>`
: null}
`;
}
private handleChange(e: Event) {
const input = e.target as HTMLInputElement;
this.username = input.value.trim();
const result = validateUsername(this.username);
this._isValid = result.isValid;
if (result.isValid) {
this.storeUsername(this.username);
this.validationError = "";
} else {
this.validationError = result.error;
}
}
public getCurrentUsername(): string {
return this.username;
private getStoredUsername(): string {
const storedUsername = localStorage.getItem(usernameKey);
if (storedUsername) {
return storedUsername;
}
return this.generateNewUsername();
}
connectedCallback() {
super.connectedCallback();
this.username = this.getStoredUsername();
this.dispatchUsernameEvent();
private storeUsername(username: string) {
if (username) {
localStorage.setItem(usernameKey, username);
}
}
render() {
return html`
<input
type="text"
.value=${this.username}
@input=${this.handleChange}
@change=${this.handleChange}
placeholder="Enter your username"
maxlength="${MAX_USERNAME_LENGTH}"
class="w-full px-4 py-2 bg-white border border-gray-300 rounded-xl shadow-sm text-2xl text-gray-900 text-center focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
${this.validationError
? html`<div class="mt-2 px-3 py-1 text-lg text-red-600 bg-white border border-red-600 rounded">${this.validationError}</div>`
: null}
`;
}
private dispatchUsernameEvent() {
this.dispatchEvent(
new CustomEvent("username-change", {
detail: { username: this.username },
bubbles: true,
composed: true,
}),
);
}
private handleChange(e: Event) {
const input = e.target as HTMLInputElement;
this.username = input.value.trim();
const result = validateUsername(this.username)
this._isValid = result.isValid
if (result.isValid) {
this.storeUsername(this.username);
this.validationError = ''
} else {
this.validationError = result.error
}
}
private generateNewUsername(): string {
const newUsername = "Anon" + this.uuidToThreeDigits();
this.storeUsername(newUsername);
return newUsername;
}
private getStoredUsername(): string {
const storedUsername = localStorage.getItem(usernameKey);
if (storedUsername) {
return storedUsername;
}
return this.generateNewUsername();
}
private uuidToThreeDigits(): string {
const uuid = uuidv4();
const cleanUuid = uuid.replace(/-/g, "").toLowerCase();
const decimal = BigInt(`0x${cleanUuid}`);
const threeDigits = decimal % 1000n;
return threeDigits.toString().padStart(3, "0");
}
private storeUsername(username: string) {
if (username) {
localStorage.setItem(usernameKey, username);
}
}
private dispatchUsernameEvent() {
this.dispatchEvent(new CustomEvent('username-change', {
detail: { username: this.username },
bubbles: true,
composed: true
}));
}
private generateNewUsername(): string {
const newUsername = "Anon" + this.uuidToThreeDigits();
this.storeUsername(newUsername);
return newUsername;
}
private uuidToThreeDigits(): string {
const uuid = uuidv4();
const cleanUuid = uuid.replace(/-/g, '').toLowerCase();
const decimal = BigInt(`0x${cleanUuid}`);
const threeDigits = decimal % 1000n;
return threeDigits.toString().padStart(3, '0');
}
public isValid(): boolean {
return this._isValid;
}
}
public isValid(): boolean {
return this._isValid;
}
}
+51 -49
View File
@@ -1,66 +1,68 @@
export function renderTroops(troops: number): string {
return renderNumber(troops / 10)
return renderNumber(troops / 10);
}
export function renderNumber(num: number) {
let numStr = ''
if (num >= 10_000_000) {
numStr = (num / 1000000).toFixed(1) + "M"
} else if (num >= 1_000_000) {
numStr = (num / 1000000).toFixed(2) + "M"
} else if (num >= 100000) {
numStr = Math.floor(num / 1000) + "K"
} else if (num >= 10000) {
numStr = (num / 1000).toFixed(1) + "K"
} else if (num >= 1000) {
numStr = (num / 1000).toFixed(2) + "K"
} else {
numStr = Math.floor(num).toString()
}
return numStr
let numStr = "";
if (num >= 10_000_000) {
numStr = (num / 1000000).toFixed(1) + "M";
} else if (num >= 1_000_000) {
numStr = (num / 1000000).toFixed(2) + "M";
} else if (num >= 100000) {
numStr = Math.floor(num / 1000) + "K";
} else if (num >= 10000) {
numStr = (num / 1000).toFixed(1) + "K";
} else if (num >= 1000) {
numStr = (num / 1000).toFixed(2) + "K";
} else {
numStr = Math.floor(num).toString();
}
return numStr;
}
export function createCanvas(): HTMLCanvasElement {
const canvas = document.createElement('canvas');
const canvas = document.createElement("canvas");
// Set canvas style to fill the screen
canvas.style.position = 'fixed';
canvas.style.left = '0';
canvas.style.top = '0';
canvas.style.width = '100%';
canvas.style.height = '100%';
canvas.style.touchAction = 'none';
// Set canvas style to fill the screen
canvas.style.position = "fixed";
canvas.style.left = "0";
canvas.style.top = "0";
canvas.style.width = "100%";
canvas.style.height = "100%";
canvas.style.touchAction = "none";
return canvas
return canvas;
}
/**
* A polyfill for crypto.randomUUID that provides fallback implementations
* for older browsers, particularly Safari versions < 15.4
*/
export function generateCryptoRandomUUID(): string {
// Type guard to check if randomUUID is available
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
return crypto.randomUUID();
}
// Type guard to check if randomUUID is available
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
// Fallback using crypto.getRandomValues
if (typeof crypto !== 'undefined' && 'getRandomValues' in crypto) {
return ([1e7] as any + -1e3 + -4e3 + -8e3 + -1e11).replace(
/[018]/g,
(c: number): string =>
(c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4)).toString(16)
);
}
// Last resort fallback using Math.random
// Note: This is less cryptographically secure but ensures functionality
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(
/[xy]/g,
(c: string): string => {
const r: number = Math.random() * 16 | 0;
const v: number = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
}
// Fallback using crypto.getRandomValues
if (typeof crypto !== "undefined" && "getRandomValues" in crypto) {
return (([1e7] as any) + -1e3 + -4e3 + -8e3 + -1e11).replace(
/[018]/g,
(c: number): string =>
(
c ^
(crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))
).toString(16),
);
}
}
// Last resort fallback using Math.random
// Note: This is less cryptographically secure but ensures functionality
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(
/[xy]/g,
(c: string): string => {
const r: number = (Math.random() * 16) | 0;
const v: number = c === "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
},
);
}
+8 -8
View File
@@ -28,7 +28,7 @@ export function createRenderer(
canvas: HTMLCanvasElement,
game: GameView,
eventBus: EventBus,
clientID: ClientID
clientID: ClientID,
): GameRenderer {
const transformHandler = new TransformHandler(game, eventBus, canvas);
@@ -64,7 +64,7 @@ export function createRenderer(
controlPanel.game = game;
const eventsDisplay = document.querySelector(
"events-display"
"events-display",
) as EventsDisplay;
if (!(eventsDisplay instanceof EventsDisplay)) {
consolex.error("events display not found");
@@ -74,7 +74,7 @@ export function createRenderer(
eventsDisplay.clientID = clientID;
const playerInfo = document.querySelector(
"player-info-overlay"
"player-info-overlay",
) as PlayerInfoOverlay;
if (!(playerInfo instanceof PlayerInfoOverlay)) {
consolex.error("player info overlay not found");
@@ -119,7 +119,7 @@ export function createRenderer(
emojiTable as EmojiTable,
buildMenu,
uiState,
playerInfo
playerInfo,
),
new SpawnTimer(game, transformHandler),
leaderboard,
@@ -136,7 +136,7 @@ export function createRenderer(
canvas,
transformHandler,
uiState,
layers
layers,
);
}
@@ -149,7 +149,7 @@ export class GameRenderer {
private canvas: HTMLCanvasElement,
public transformHandler: TransformHandler,
public uiState: UIState,
private layers: Layer[]
private layers: Layer[],
) {
this.context = canvas.getContext("2d");
}
@@ -172,7 +172,7 @@ export class GameRenderer {
this.transformHandler = new TransformHandler(
this.game,
this.eventBus,
this.canvas
this.canvas,
);
requestAnimationFrame(() => this.renderGame());
@@ -218,7 +218,7 @@ export class GameRenderer {
const duration = performance.now() - start;
if (duration > 50) {
console.warn(
`tick ${this.game.ticks()} took ${duration}ms to render frame`
`tick ${this.game.ticks()} took ${duration}ms to render frame`,
);
}
}
+135 -123
View File
@@ -1,158 +1,170 @@
import { Game, Player, Cell } from '../../core/game/Game';
import { NameViewData } from '../../core/game/Game';
import { calculateBoundingBox, within } from '../../core/Util';
import { Game, Player, Cell } from "../../core/game/Game";
import { NameViewData } from "../../core/game/Game";
import { calculateBoundingBox, within } from "../../core/Util";
export interface Point {
x: number;
y: number;
x: number;
y: number;
}
export interface Rectangle {
x: number;
y: number;
width: number;
height: number;
x: number;
y: number;
width: number;
height: number;
}
export function placeName(game: Game, player: Player): NameViewData {
const boundingBox =
player.largestClusterBoundingBox ??
calculateBoundingBox(game, player.borderTiles());
const boundingBox = player.largestClusterBoundingBox ?? calculateBoundingBox(game, player.borderTiles());
let scalingFactor = 1;
const width = boundingBox.max.x - boundingBox.min.x;
const height = boundingBox.max.y - boundingBox.min.y;
const size = Math.min(width, height);
if (size < 25) {
scalingFactor = 1;
} else if (size < 50) {
scalingFactor = 2;
} else if (size < 100) {
scalingFactor = 4;
} else if (size < 250) {
scalingFactor = 8;
} else if (size < 500) {
scalingFactor = 16;
} else {
scalingFactor = 32;
}
let scalingFactor = 1
const width = boundingBox.max.x - boundingBox.min.x
const height = boundingBox.max.y - boundingBox.min.y
const size = Math.min(width, height)
if (size < 25) {
scalingFactor = 1
} else if (size < 50) {
scalingFactor = 2
} else if (size < 100) {
scalingFactor = 4
} else if (size < 250) {
scalingFactor = 8
} else if (size < 500) {
scalingFactor = 16
} else {
scalingFactor = 32
}
const grid = createGrid(game, player, boundingBox, scalingFactor);
const largestRectangle = findLargestInscribedRectangle(grid);
largestRectangle.x = largestRectangle.x * scalingFactor;
largestRectangle.y = largestRectangle.y * scalingFactor;
largestRectangle.width = largestRectangle.width * scalingFactor;
largestRectangle.height = largestRectangle.height * scalingFactor;
const grid = createGrid(game, player, boundingBox, scalingFactor);
const largestRectangle = findLargestInscribedRectangle(grid);
largestRectangle.x = largestRectangle.x * scalingFactor
largestRectangle.y = largestRectangle.y * scalingFactor
largestRectangle.width = largestRectangle.width * scalingFactor
largestRectangle.height = largestRectangle.height * scalingFactor
let center = new Cell(
Math.floor(
largestRectangle.x + largestRectangle.width / 2 + boundingBox.min.x,
),
Math.floor(
largestRectangle.y + largestRectangle.height / 2 + boundingBox.min.y,
),
);
let center = new Cell(
Math.floor(largestRectangle.x + largestRectangle.width / 2 + boundingBox.min.x),
Math.floor(largestRectangle.y + largestRectangle.height / 2 + boundingBox.min.y),
)
const fontSize = calculateFontSize(largestRectangle, player.name());
center = new Cell(center.x, center.y - fontSize / 3);
const fontSize = calculateFontSize(largestRectangle, player.name());
center = new Cell(center.x, center.y - fontSize / 3)
return {
x: Math.ceil(center.x),
y: Math.ceil(center.y),
size: fontSize,
}
return {
x: Math.ceil(center.x),
y: Math.ceil(center.y),
size: fontSize,
};
}
export function createGrid(game: Game, player: Player, boundingBox: { min: Point; max: Point }, scalingFactor: number): boolean[][] {
const scaledBoundingBox: { min: Point; max: Point } = {
min: {
x: Math.floor(boundingBox.min.x / scalingFactor),
y: Math.floor(boundingBox.min.y / scalingFactor)
},
max: {
x: Math.floor(boundingBox.max.x / scalingFactor),
y: Math.floor(boundingBox.max.y / scalingFactor)
}
export function createGrid(
game: Game,
player: Player,
boundingBox: { min: Point; max: Point },
scalingFactor: number,
): boolean[][] {
const scaledBoundingBox: { min: Point; max: Point } = {
min: {
x: Math.floor(boundingBox.min.x / scalingFactor),
y: Math.floor(boundingBox.min.y / scalingFactor),
},
max: {
x: Math.floor(boundingBox.max.x / scalingFactor),
y: Math.floor(boundingBox.max.y / scalingFactor),
},
};
const width = scaledBoundingBox.max.x - scaledBoundingBox.min.x + 1;
const height = scaledBoundingBox.max.y - scaledBoundingBox.min.y + 1;
const grid: boolean[][] = Array(width)
.fill(null)
.map(() => Array(height).fill(false));
for (let x = scaledBoundingBox.min.x; x <= scaledBoundingBox.max.x; x++) {
for (let y = scaledBoundingBox.min.y; y <= scaledBoundingBox.max.y; y++) {
const cell = new Cell(x * scalingFactor, y * scalingFactor);
if (game.isOnMap(cell)) {
const tile = game.ref(cell.x, cell.y);
grid[x - scaledBoundingBox.min.x][y - scaledBoundingBox.min.y] =
game.isLake(tile) || game.owner(tile) === player; // TODO: okay if lake
}
}
}
const width = scaledBoundingBox.max.x - scaledBoundingBox.min.x + 1;
const height = scaledBoundingBox.max.y - scaledBoundingBox.min.y + 1;
const grid: boolean[][] = Array(width).fill(null).map(() => Array(height).fill(false));
for (let x = scaledBoundingBox.min.x; x <= scaledBoundingBox.max.x; x++) {
for (let y = scaledBoundingBox.min.y; y <= scaledBoundingBox.max.y; y++) {
const cell = new Cell(x * scalingFactor, y * scalingFactor);
if (game.isOnMap(cell)) {
const tile = game.ref(cell.x, cell.y);
grid[x - scaledBoundingBox.min.x][y - scaledBoundingBox.min.y] = game.isLake(tile) || game.owner(tile) === player; // TODO: okay if lake
}
}
}
return grid;
return grid;
}
export function findLargestInscribedRectangle(grid: boolean[][]): Rectangle {
const rows = grid[0].length;
const cols = grid.length;
const heights: number[] = new Array(cols).fill(0);
let largestRect: Rectangle = { x: 0, y: 0, width: 0, height: 0 };
const rows = grid[0].length;
const cols = grid.length;
const heights: number[] = new Array(cols).fill(0);
let largestRect: Rectangle = { x: 0, y: 0, width: 0, height: 0 };
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
if (grid[col][row]) {
heights[col]++;
} else {
heights[col] = 0;
}
}
const rectForRow = largestRectangleInHistogram(heights);
if (rectForRow.width * rectForRow.height > largestRect.width * largestRect.height) {
largestRect = {
x: rectForRow.x,
y: row - rectForRow.height + 1,
width: rectForRow.width,
height: rectForRow.height
};
}
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
if (grid[col][row]) {
heights[col]++;
} else {
heights[col] = 0;
}
}
return largestRect;
const rectForRow = largestRectangleInHistogram(heights);
if (
rectForRow.width * rectForRow.height >
largestRect.width * largestRect.height
) {
largestRect = {
x: rectForRow.x,
y: row - rectForRow.height + 1,
width: rectForRow.width,
height: rectForRow.height,
};
}
}
return largestRect;
}
export function largestRectangleInHistogram(widths: number[]): Rectangle {
const stack: number[] = [];
let maxArea = 0;
let largestRect: Rectangle = { x: 0, y: 0, width: 0, height: 0 };
const stack: number[] = [];
let maxArea = 0;
let largestRect: Rectangle = { x: 0, y: 0, width: 0, height: 0 };
for (let i = 0; i <= widths.length; i++) {
const h = i === widths.length ? 0 : widths[i];
for (let i = 0; i <= widths.length; i++) {
const h = i === widths.length ? 0 : widths[i];
while (stack.length > 0 && h < widths[stack[stack.length - 1]]) {
const height = widths[stack.pop()!];
const width = stack.length === 0 ? i : i - stack[stack.length - 1] - 1;
while (stack.length > 0 && h < widths[stack[stack.length - 1]]) {
const height = widths[stack.pop()!];
const width = stack.length === 0 ? i : i - stack[stack.length - 1] - 1;
if (height * width > maxArea) {
maxArea = height * width;
largestRect = {
x: stack.length === 0 ? 0 : stack[stack.length - 1] + 1,
y: 0,
width: width,
height: height
};
}
}
stack.push(i);
if (height * width > maxArea) {
maxArea = height * width;
largestRect = {
x: stack.length === 0 ? 0 : stack[stack.length - 1] + 1,
y: 0,
width: width,
height: height,
};
}
}
return largestRect;
stack.push(i);
}
return largestRect;
}
export function calculateFontSize(rectangle: Rectangle, name: string): number {
// This is a simplified calculation. You might want to adjust it based on your specific font and rendering system.
const widthConstrained = rectangle.width / name.length * 2;
const heightConstrained = rectangle.height / 3;
return Math.min(widthConstrained, heightConstrained);
// This is a simplified calculation. You might want to adjust it based on your specific font and rendering system.
const widthConstrained = (rectangle.width / name.length) * 2;
const heightConstrained = rectangle.height / 3;
return Math.min(widthConstrained, heightConstrained);
}
+210 -183
View File
@@ -1,198 +1,225 @@
import { colord } from "colord";
import { EventBus } from "../../core/EventBus"
import { EventBus } from "../../core/EventBus";
import { Cell, Game, Player } from "../../core/game/Game";
import { calculateBoundingBox, calculateBoundingBoxCenter } from "../../core/Util";
import {
calculateBoundingBox,
calculateBoundingBoxCenter,
} from "../../core/Util";
import { ZoomEvent, DragEvent } from "../InputHandler";
import { GoToPlayerEvent } from "./layers/Leaderboard";
import { placeName } from "./NameBoxCalculator";
import { GameView } from "../../core/game/GameView";
export class TransformHandler {
public scale: number = 1.8
private offsetX: number = -350
private offsetY: number = -200
public scale: number = 1.8;
private offsetX: number = -350;
private offsetY: number = -200;
private target: Cell
private intervalID = null
private changed = false
private target: Cell;
private intervalID = null;
private changed = false;
constructor(private game: GameView, private eventBus: EventBus, private canvas: HTMLCanvasElement) {
this.eventBus.on(ZoomEvent, (e) => this.onZoom(e))
this.eventBus.on(DragEvent, (e) => this.onMove(e))
this.eventBus.on(GoToPlayerEvent, (e) => this.onGoToPlayer(e))
constructor(
private game: GameView,
private eventBus: EventBus,
private canvas: HTMLCanvasElement,
) {
this.eventBus.on(ZoomEvent, (e) => this.onZoom(e));
this.eventBus.on(DragEvent, (e) => this.onMove(e));
this.eventBus.on(GoToPlayerEvent, (e) => this.onGoToPlayer(e));
}
boundingRect(): DOMRect {
return this.canvas.getBoundingClientRect();
}
width(): number {
return this.boundingRect().width;
}
hasChanged(): boolean {
return this.changed;
}
handleTransform(context: CanvasRenderingContext2D) {
// Disable image smoothing for pixelated effect
context.imageSmoothingEnabled = false;
// Apply zoom and pan
context.setTransform(
this.scale,
0,
0,
this.scale,
this.game.width() / 2 - this.offsetX * this.scale,
this.game.height() / 2 - this.offsetY * this.scale,
);
this.changed = false;
}
worldToScreenCoordinates(cell: Cell): { x: number; y: number } {
// Step 1: Convert from Cell coordinates to game coordinates
// (reverse of Math.floor operation - we'll use the exact values)
const gameX = cell.x;
const gameY = cell.y;
// Step 2: Reverse the game center offset calculation
// Original: gameX = centerX + this.game.width() / 2
// Therefore: centerX = gameX - this.game.width() / 2
const centerX = gameX - this.game.width() / 2;
const centerY = gameY - this.game.height() / 2;
// Step 3: Reverse the world point calculation
// Original: centerX = (canvasX - this.game.width() / 2) / this.scale + this.offsetX
// Therefore: canvasX = (centerX - this.offsetX) * this.scale + this.game.width() / 2
const canvasX =
(centerX - this.offsetX) * this.scale + this.game.width() / 2;
const canvasY =
(centerY - this.offsetY) * this.scale + this.game.height() / 2;
// Step 4: Convert canvas coordinates back to screen coordinates
const canvasRect = this.boundingRect();
const screenX = canvasX + canvasRect.left;
const screenY = canvasY + canvasRect.top;
return { x: screenX, y: screenY };
}
screenToWorldCoordinates(screenX: number, screenY: number): Cell {
const canvasRect = this.boundingRect();
const canvasX = screenX - canvasRect.left;
const canvasY = screenY - canvasRect.top;
// Calculate the world point we want to zoom towards
const centerX =
(canvasX - this.game.width() / 2) / this.scale + this.offsetX;
const centerY =
(canvasY - this.game.height() / 2) / this.scale + this.offsetY;
const gameX = centerX + this.game.width() / 2;
const gameY = centerY + this.game.height() / 2;
return new Cell(Math.floor(gameX), Math.floor(gameY));
}
screenBoundingRect(): [Cell, Cell] {
const LeftX = -this.game.width() / 2 / this.scale + this.offsetX;
const TopY = -this.game.height() / 2 / this.scale + this.offsetY;
const gameLeftX = LeftX + this.game.width() / 2;
const gameTopY = TopY + this.game.height() / 2;
const rightX =
(screen.width - this.game.width() / 2) / this.scale + this.offsetX;
const rightY =
(screen.height - this.game.height() / 2) / this.scale + this.offsetY;
const gameRightX = rightX + this.game.width() / 2;
const gameBottomY = rightY + this.game.height() / 2;
return [
new Cell(Math.floor(gameLeftX), Math.floor(gameTopY)),
new Cell(Math.floor(gameRightX), Math.floor(gameBottomY)),
];
}
isOnScreen(cell: Cell): boolean {
const [topLeft, bottomRight] = this.screenBoundingRect();
return (
cell.x > topLeft.x &&
cell.x < bottomRight.x &&
cell.y > topLeft.y &&
cell.y < bottomRight.y
);
}
screenCenter(): { screenX: number; screenY: number } {
const [upperLeft, bottomRight] = this.screenBoundingRect();
return {
screenX: upperLeft.x + Math.floor((bottomRight.x - upperLeft.x) / 2),
screenY: upperLeft.y + Math.floor((bottomRight.y - upperLeft.y) / 2),
};
}
onGoToPlayer(event: GoToPlayerEvent) {
this.clearTarget();
this.target = new Cell(
event.player.nameLocation().x,
event.player.nameLocation().y,
);
this.intervalID = setInterval(() => this.goTo(), 1);
}
private goTo() {
const { screenX, screenY } = this.screenCenter();
const screenMapCenter = new Cell(screenX, screenY);
if (
this.game.manhattanDist(
this.game.ref(screenX, screenY),
this.game.ref(this.target.x, this.target.y),
) < 2
) {
this.clearTarget();
return;
}
boundingRect(): DOMRect {
return this.canvas.getBoundingClientRect()
const dX = Math.abs(screenMapCenter.x - this.target.x);
if (dX > 2) {
const offsetDx = Math.max(1, Math.floor(dX / 25));
if (screenMapCenter.x > this.target.x) {
this.offsetX -= offsetDx;
} else {
this.offsetX += offsetDx;
}
}
width(): number {
return this.boundingRect().width
const dY = Math.abs(screenMapCenter.y - this.target.y);
if (dY > 2) {
const offsetDy = Math.max(1, Math.floor(dY / 25));
if (screenMapCenter.y > this.target.y) {
this.offsetY -= offsetDy;
} else {
this.offsetY += offsetDy;
}
}
hasChanged(): boolean {
return this.changed
this.changed = true;
}
onZoom(event: ZoomEvent) {
this.clearTarget();
const oldScale = this.scale;
const zoomFactor = 1 + event.delta / 600;
this.scale /= zoomFactor;
// Clamp the scale to prevent extreme zooming
this.scale = Math.max(0.5, Math.min(20, this.scale));
const canvasRect = this.boundingRect();
const canvasX = event.x - canvasRect.left;
const canvasY = event.y - canvasRect.top;
// Calculate the world point we want to zoom towards
const zoomPointX =
(canvasX - this.game.width() / 2) / oldScale + this.offsetX;
const zoomPointY =
(canvasY - this.game.height() / 2) / oldScale + this.offsetY;
// Adjust the offset
this.offsetX = zoomPointX - (canvasX - this.game.width() / 2) / this.scale;
this.offsetY = zoomPointY - (canvasY - this.game.height() / 2) / this.scale;
this.changed = true;
}
onMove(event: DragEvent) {
this.clearTarget();
this.offsetX -= event.deltaX / this.scale;
this.offsetY -= event.deltaY / this.scale;
this.changed = true;
}
private clearTarget() {
if (this.intervalID != null) {
clearInterval(this.intervalID);
this.intervalID = null;
}
handleTransform(context: CanvasRenderingContext2D) {
// Disable image smoothing for pixelated effect
context.imageSmoothingEnabled = false;
// Apply zoom and pan
context.setTransform(
this.scale,
0,
0,
this.scale,
this.game.width() / 2 - this.offsetX * this.scale,
this.game.height() / 2 - this.offsetY * this.scale
);
this.changed = false
}
worldToScreenCoordinates(cell: Cell): { x: number, y: number } {
// Step 1: Convert from Cell coordinates to game coordinates
// (reverse of Math.floor operation - we'll use the exact values)
const gameX = cell.x;
const gameY = cell.y;
// Step 2: Reverse the game center offset calculation
// Original: gameX = centerX + this.game.width() / 2
// Therefore: centerX = gameX - this.game.width() / 2
const centerX = gameX - this.game.width() / 2;
const centerY = gameY - this.game.height() / 2;
// Step 3: Reverse the world point calculation
// Original: centerX = (canvasX - this.game.width() / 2) / this.scale + this.offsetX
// Therefore: canvasX = (centerX - this.offsetX) * this.scale + this.game.width() / 2
const canvasX = (centerX - this.offsetX) * this.scale + this.game.width() / 2;
const canvasY = (centerY - this.offsetY) * this.scale + this.game.height() / 2;
// Step 4: Convert canvas coordinates back to screen coordinates
const canvasRect = this.boundingRect();
const screenX = canvasX + canvasRect.left;
const screenY = canvasY + canvasRect.top;
return { x: screenX, y: screenY }
}
screenToWorldCoordinates(screenX: number, screenY: number): Cell {
const canvasRect = this.boundingRect();
const canvasX = screenX - canvasRect.left;
const canvasY = screenY - canvasRect.top;
// Calculate the world point we want to zoom towards
const centerX = (canvasX - this.game.width() / 2) / this.scale + this.offsetX;
const centerY = (canvasY - this.game.height() / 2) / this.scale + this.offsetY;
const gameX = centerX + this.game.width() / 2
const gameY = centerY + this.game.height() / 2
return new Cell(Math.floor(gameX), Math.floor(gameY));
}
screenBoundingRect(): [Cell, Cell] {
const LeftX = (- this.game.width() / 2) / this.scale + this.offsetX;
const TopY = (- this.game.height() / 2) / this.scale + this.offsetY;
const gameLeftX = LeftX + this.game.width() / 2
const gameTopY = TopY + this.game.height() / 2
const rightX = (screen.width - this.game.width() / 2) / this.scale + this.offsetX;
const rightY = (screen.height - this.game.height() / 2) / this.scale + this.offsetY;
const gameRightX = rightX + this.game.width() / 2
const gameBottomY = rightY + this.game.height() / 2
return [new Cell(Math.floor(gameLeftX), Math.floor(gameTopY)), new Cell(Math.floor(gameRightX), Math.floor(gameBottomY))]
}
isOnScreen(cell: Cell): boolean {
const [topLeft, bottomRight] = this.screenBoundingRect()
return cell.x > topLeft.x && cell.x < bottomRight.x && cell.y > topLeft.y && cell.y < bottomRight.y
}
screenCenter(): { screenX: number, screenY: number } {
const [upperLeft, bottomRight] = this.screenBoundingRect()
return {
screenX: upperLeft.x + Math.floor((bottomRight.x - upperLeft.x) / 2),
screenY: upperLeft.y + Math.floor((bottomRight.y - upperLeft.y) / 2)
}
}
onGoToPlayer(event: GoToPlayerEvent) {
this.clearTarget();
this.target = new Cell(event.player.nameLocation().x, event.player.nameLocation().y)
this.intervalID = setInterval(() => this.goTo(), 1)
}
private goTo() {
const { screenX, screenY } = this.screenCenter()
const screenMapCenter = new Cell(screenX, screenY)
if (this.game.manhattanDist(this.game.ref(screenX, screenY), this.game.ref(this.target.x, this.target.y)) < 2) {
this.clearTarget()
return
}
const dX = Math.abs(screenMapCenter.x - this.target.x)
if (dX > 2) {
const offsetDx = Math.max(1, Math.floor(dX / 25))
if (screenMapCenter.x > this.target.x) {
this.offsetX -= offsetDx
} else {
this.offsetX += offsetDx
}
}
const dY = Math.abs(screenMapCenter.y - this.target.y)
if (dY > 2) {
const offsetDy = Math.max(1, Math.floor(dY / 25))
if (screenMapCenter.y > this.target.y) {
this.offsetY -= offsetDy
} else {
this.offsetY += offsetDy
}
}
this.changed = true
}
onZoom(event: ZoomEvent) {
this.clearTarget()
const oldScale = this.scale;
const zoomFactor = 1 + event.delta / 600;
this.scale /= zoomFactor;
// Clamp the scale to prevent extreme zooming
this.scale = Math.max(0.5, Math.min(20, this.scale));
const canvasRect = this.boundingRect()
const canvasX = event.x - canvasRect.left;
const canvasY = event.y - canvasRect.top;
// Calculate the world point we want to zoom towards
const zoomPointX = (canvasX - this.game.width() / 2) / oldScale + this.offsetX;
const zoomPointY = (canvasY - this.game.height() / 2) / oldScale + this.offsetY;
// Adjust the offset
this.offsetX = zoomPointX - (canvasX - this.game.width() / 2) / this.scale;
this.offsetY = zoomPointY - (canvasY - this.game.height() / 2) / this.scale;
this.changed = true
}
onMove(event: DragEvent) {
this.clearTarget()
this.offsetX -= event.deltaX / this.scale;
this.offsetY -= event.deltaY / this.scale;
this.changed = true
}
private clearTarget() {
if (this.intervalID != null) {
clearInterval(this.intervalID)
this.intervalID = null
}
this.target = null
}
}
this.target = null;
}
}
+2 -2
View File
@@ -1,3 +1,3 @@
export interface UIState {
attackRatio: number
}
attackRatio: number;
}
+12 -12
View File
@@ -125,10 +125,10 @@ export class EventsDisplay extends LitElement implements Layer {
}
const requestor = this.game.playerBySmallID(
update.requestorID
update.requestorID,
) as PlayerView;
const recipient = this.game.playerBySmallID(
update.recipientID
update.recipientID,
) as PlayerView;
this.addEvent({
@@ -139,7 +139,7 @@ export class EventsDisplay extends LitElement implements Layer {
className: "btn",
action: () =>
this.eventBus.emit(
new SendAllianceReplyIntentEvent(requestor, recipient, true)
new SendAllianceReplyIntentEvent(requestor, recipient, true),
),
},
{
@@ -147,7 +147,7 @@ export class EventsDisplay extends LitElement implements Layer {
className: "btn-info",
action: () =>
this.eventBus.emit(
new SendAllianceReplyIntentEvent(requestor, recipient, false)
new SendAllianceReplyIntentEvent(requestor, recipient, false),
),
},
],
@@ -156,7 +156,7 @@ export class EventsDisplay extends LitElement implements Layer {
createdAt: this.game.ticks(),
onDelete: () =>
this.eventBus.emit(
new SendAllianceReplyIntentEvent(requestor, recipient, false)
new SendAllianceReplyIntentEvent(requestor, recipient, false),
),
});
}
@@ -168,7 +168,7 @@ export class EventsDisplay extends LitElement implements Layer {
}
const recipient = this.game.playerBySmallID(
update.request.recipientID
update.request.recipientID,
) as PlayerView;
this.addEvent({
@@ -213,8 +213,8 @@ export class EventsDisplay extends LitElement implements Layer {
update.player1ID === myPlayer.smallID()
? update.player2ID
: update.player2ID === myPlayer.smallID()
? update.player1ID
: null;
? update.player1ID
: null;
const other = this.game.playerBySmallID(otherID) as PlayerView;
if (!other || !myPlayer.isAlive() || !other.isAlive()) return;
@@ -250,7 +250,7 @@ export class EventsDisplay extends LitElement implements Layer {
? AllPlayers
: this.game.playerBySmallID(update.emoji.recipientID);
const sender = this.game.playerBySmallID(
update.emoji.senderID
update.emoji.senderID,
) as PlayerView;
if (recipient == myPlayer) {
@@ -306,7 +306,7 @@ export class EventsDisplay extends LitElement implements Layer {
(event, index) => html`
<tr
class="border-b border-opacity-0 ${this.getMessageTypeClasses(
event.type
event.type,
)}"
>
<td class="lg:p-3 p-1 text-left">
@@ -331,14 +331,14 @@ export class EventsDisplay extends LitElement implements Layer {
>
${btn.text}
</button>
`
`,
)}
</div>
`
: ""}
</td>
</tr>
`
`,
)}
</tbody>
</table>
+7 -7
View File
@@ -1,9 +1,9 @@
import { Game } from "../../../core/game/Game"
import { Game } from "../../../core/game/Game";
export interface Layer {
init?()
tick?()
renderLayer?(context: CanvasRenderingContext2D)
shouldTransform?(): boolean
redraw?(): void
}
init?();
tick?();
renderLayer?(context: CanvasRenderingContext2D);
shouldTransform?(): boolean;
redraw?(): void;
}
+130 -127
View File
@@ -1,167 +1,169 @@
import { LitElement, html, css } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { Layer } from './Layer';
import { ClientID } from '../../../core/Schemas';
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
import { EventBus, GameEvent } from '../../../core/EventBus';
import { renderNumber } from '../../Utils';
import { GameView, PlayerView } from '../../../core/game/GameView';
import { LitElement, html, css } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { Layer } from "./Layer";
import { ClientID } from "../../../core/Schemas";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { EventBus, GameEvent } from "../../../core/EventBus";
import { renderNumber } from "../../Utils";
import { GameView, PlayerView } from "../../../core/game/GameView";
interface Entry {
name: string
position: number
score: string
gold: string
isMyPlayer: boolean
player: PlayerView
name: string;
position: number;
score: string;
gold: string;
isMyPlayer: boolean;
player: PlayerView;
}
export class GoToPlayerEvent implements GameEvent {
constructor(public player: PlayerView) { }
constructor(public player: PlayerView) {}
}
@customElement('leader-board')
@customElement("leader-board")
export class Leaderboard extends LitElement implements Layer {
public game: GameView;
public clientID: ClientID;
public eventBus: EventBus;
public game: GameView
public clientID: ClientID
public eventBus: EventBus
init() {
}
init() {}
tick() {
if (this._hidden && !this.game.inSpawnPhase()) {
this.showLeaderboard()
this.updateLeaderboard()
this.showLeaderboard();
this.updateLeaderboard();
}
if (this._hidden) {
return
return;
}
if (this.game.ticks() % 10 == 0) {
this.updateLeaderboard()
this.updateLeaderboard();
}
}
private updateLeaderboard() {
if (this.clientID == null) {
return
return;
}
const myPlayer = this.game.playerViews().find(p => p.clientID() == this.clientID)
const myPlayer = this.game
.playerViews()
.find((p) => p.clientID() == this.clientID);
const sorted = this.game.playerViews()
.sort((a, b) => b.numTilesOwned() - a.numTilesOwned())
const sorted = this.game
.playerViews()
.sort((a, b) => b.numTilesOwned() - a.numTilesOwned());
const numTilesWithoutFallout = this.game.numLandTiles() - this.game.numTilesWithFallout()
const numTilesWithoutFallout =
this.game.numLandTiles() - this.game.numTilesWithFallout();
this.players = sorted
.slice(0, 5)
.map((player, index) => ({
name: player.displayName(),
position: index + 1,
score: formatPercentage(player.numTilesOwned() / numTilesWithoutFallout),
gold: renderNumber(player.gold()),
isMyPlayer: player == myPlayer,
player: player
}));
this.players = sorted.slice(0, 5).map((player, index) => ({
name: player.displayName(),
position: index + 1,
score: formatPercentage(player.numTilesOwned() / numTilesWithoutFallout),
gold: renderNumber(player.gold()),
isMyPlayer: player == myPlayer,
player: player,
}));
if (myPlayer != null && this.players.find(p => p.isMyPlayer) == null) {
let place = 0
if (myPlayer != null && this.players.find((p) => p.isMyPlayer) == null) {
let place = 0;
for (const p of sorted) {
place++
place++;
if (p == myPlayer) {
break
break;
}
}
this.players.pop()
this.players.pop();
this.players.push({
name: myPlayer.displayName(),
position: place,
score: formatPercentage(myPlayer.numTilesOwned() / this.game.numLandTiles()),
score: formatPercentage(
myPlayer.numTilesOwned() / this.game.numLandTiles(),
),
gold: renderNumber(myPlayer.gold()),
isMyPlayer: true,
player: myPlayer
})
player: myPlayer,
});
}
this.requestUpdate()
this.requestUpdate();
}
private handleRowClick(player: PlayerView) {
this.eventBus.emit(new GoToPlayerEvent(player))
this.eventBus.emit(new GoToPlayerEvent(player));
}
renderLayer(context: CanvasRenderingContext2D) {
}
renderLayer(context: CanvasRenderingContext2D) {}
shouldTransform(): boolean {
return false
return false;
}
static styles = css`
:host {
display: block;
}
img.emoji {
height: 1em;
width: auto;
}
.leaderboard {
position: fixed;
top: 10px;
left: 10px;
z-index: 9999;
background-color: rgba(30, 30, 30, 0.7);
padding: 10px;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.5);
border-radius: 10px;
max-width: 300px;
max-height: 80vh;
overflow-y: auto;
width: 300px;
backdrop-filter: blur(5px);
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 5px;
text-align: center;
border-bottom: 1px solid rgba(51, 51, 51, 0.2);
color: white;
}
th {
background-color: rgba(44, 44, 44, 0.5);
color: white;
}
.myPlayer {
font-weight: bold;
font-size: 1.2em;
}
.otherPlayer {
font-size: 1.0em;
}
tr:nth-child(even) {
background-color: rgba(44, 44, 44, 0.5);
}
tbody tr {
cursor: pointer;
transition: background-color 0.2s;
}
tbody tr:hover {
background-color: rgba(78, 78, 78, 0.8);
}
.hidden {
display: none !important;
}
@media (max-width: 1000px) {
:host {
display: block;
}
img.emoji {
height: 1em;
width: auto;
}
.leaderboard {
position: fixed;
top: 10px;
left: 10px;
z-index: 9999;
background-color: rgba(30, 30, 30, 0.7);
padding: 10px;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.5);
border-radius: 10px;
max-width: 300px;
max-height: 80vh;
overflow-y: auto;
width: 300px;
backdrop-filter: blur(5px);
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
padding: 5px;
text-align: center;
border-bottom: 1px solid rgba(51, 51, 51, 0.2);
color: white;
}
th {
background-color: rgba(44, 44, 44, 0.5);
color: white;
}
.myPlayer {
font-weight: bold;
font-size: 1.2em;
}
.otherPlayer {
font-size: 1em;
}
tr:nth-child(even) {
background-color: rgba(44, 44, 44, 0.5);
}
tbody tr {
cursor: pointer;
transition: background-color 0.2s;
}
tbody tr:hover {
background-color: rgba(78, 78, 78, 0.8);
}
.hidden {
display: none !important;
}
}
`;
@media (max-width: 1000px) {
.leaderboard {
display: none !important;
}
}
`;
players: Entry[] = [];
@@ -178,7 +180,7 @@ export class Leaderboard extends LitElement implements Layer {
// </div>
// `;
return html`
<div class="leaderboard ${this._hidden ? 'hidden' : ''}">
<div class="leaderboard ${this._hidden ? "hidden" : ""}">
<table>
<thead>
<tr>
@@ -189,10 +191,10 @@ export class Leaderboard extends LitElement implements Layer {
</tr>
</thead>
<tbody>
${this.players
.map((player) => html`
<tr
class="${player.isMyPlayer ? 'myPlayer' : 'otherPlayer'}"
${this.players.map(
(player) => html`
<tr
class="${player.isMyPlayer ? "myPlayer" : "otherPlayer"}"
@click=${() => this.handleRowClick(player.player)}
>
<td>${player.position}</td>
@@ -200,7 +202,8 @@ export class Leaderboard extends LitElement implements Layer {
<td>${player.score}</td>
<td>${player.gold}</td>
</tr>
`)}
`,
)}
</tbody>
</table>
</div>
@@ -223,15 +226,15 @@ export class Leaderboard extends LitElement implements Layer {
}
function formatPercentage(value: number): string {
const perc = value * 100
const perc = value * 100;
if (perc > 99.5) {
return "100%"
return "100%";
}
if (perc < .01) {
return "0%"
if (perc < 0.01) {
return "0%";
}
if (perc < .1) {
return (perc).toPrecision(1) + '%'
if (perc < 0.1) {
return perc.toPrecision(1) + "%";
}
return perc.toPrecision(2) + '%';
}
return perc.toPrecision(2) + "%";
}
+24 -14
View File
@@ -25,7 +25,7 @@ class RenderInfo {
public lastRenderCalc: number,
public location: Cell,
public fontSize: number,
public element: HTMLElement
public element: HTMLElement,
) {}
}
@@ -49,7 +49,7 @@ export class NameLayer implements Layer {
private game: GameView,
private theme: Theme,
private transformHandler: TransformHandler,
private clientID: ClientID
private clientID: ClientID,
) {
this.traitorIconImage = new Image();
this.traitorIconImage.src = traitorIcon;
@@ -100,7 +100,13 @@ export class NameLayer implements Layer {
if (!this.seenPlayers.has(player)) {
this.seenPlayers.add(player);
this.renders.push(
new RenderInfo(player, 0, null, 0, this.createPlayerElement(player))
new RenderInfo(
player,
0,
null,
0,
this.createPlayerElement(player),
),
);
}
}
@@ -109,11 +115,11 @@ export class NameLayer implements Layer {
public renderLayer(mainContex: CanvasRenderingContext2D) {
const screenPosOld = this.transformHandler.worldToScreenCoordinates(
new Cell(0, 0)
new Cell(0, 0),
);
const screenPos = new Cell(
screenPosOld.x - window.innerWidth / 2,
screenPosOld.y - window.innerHeight / 2
screenPosOld.y - window.innerHeight / 2,
);
this.container.style.transform = `translate(${screenPos.x}px, ${screenPos.y}px) scale(${this.transformHandler.scale})`;
@@ -130,7 +136,7 @@ export class NameLayer implements Layer {
0,
0,
mainContex.canvas.width,
mainContex.canvas.height
mainContex.canvas.height,
);
}
@@ -185,7 +191,7 @@ export class NameLayer implements Layer {
const oldLocation = render.location;
render.location = new Cell(
render.player.nameLocation().x,
render.player.nameLocation().y
render.player.nameLocation().y,
);
// Calculate base size and scale
@@ -224,7 +230,7 @@ export class NameLayer implements Layer {
if (render.player === this.firstPlace) {
if (!existingCrown) {
iconsDiv.appendChild(
this.createIconElement(this.crownIconImage.src, iconSize, "crown")
this.createIconElement(this.crownIconImage.src, iconSize, "crown"),
);
}
} else if (existingCrown) {
@@ -236,7 +242,11 @@ export class NameLayer implements Layer {
if (render.player.isTraitor()) {
if (!existingTraitor) {
iconsDiv.appendChild(
this.createIconElement(this.traitorIconImage.src, iconSize, "traitor")
this.createIconElement(
this.traitorIconImage.src,
iconSize,
"traitor",
),
);
}
} else if (existingTraitor) {
@@ -251,8 +261,8 @@ export class NameLayer implements Layer {
this.createIconElement(
this.allianceIconImage.src,
iconSize,
"alliance"
)
"alliance",
),
);
}
} else if (existingAlliance) {
@@ -267,7 +277,7 @@ export class NameLayer implements Layer {
) {
if (!existingTarget) {
iconsDiv.appendChild(
this.createIconElement(this.targetIconImage.src, iconSize, "target")
this.createIconElement(this.targetIconImage.src, iconSize, "target"),
);
}
} else if (existingTarget) {
@@ -281,7 +291,7 @@ export class NameLayer implements Layer {
.filter(
(emoji) =>
emoji.recipientID == AllPlayers ||
emoji.recipientID == myPlayer?.smallID()
emoji.recipientID == myPlayer?.smallID(),
);
if (emojis.length > 0) {
@@ -314,7 +324,7 @@ export class NameLayer implements Layer {
private createIconElement(
src: string,
size: number,
id: string
id: string,
): HTMLImageElement {
const icon = document.createElement("img");
icon.src = src;
@@ -22,7 +22,7 @@ import { renderNumber, renderTroops } from "../../Utils";
function euclideanDistWorld(
coord: { x: number; y: number },
tileRef: TileRef,
game: GameView
game: GameView,
): number {
const x = game.x(tileRef);
const y = game.y(tileRef);
@@ -71,7 +71,7 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
init() {
this.eventBus.on(MouseMoveEvent, (e: MouseMoveEvent) =>
this.onMouseEvent(e)
this.onMouseEvent(e),
);
this._isActive = true;
}
+24 -24
View File
@@ -1,34 +1,34 @@
import { GameView } from '../../../core/game/GameView';
import { TransformHandler } from '../TransformHandler';
import { Layer } from './Layer';
import { GameView } from "../../../core/game/GameView";
import { TransformHandler } from "../TransformHandler";
import { Layer } from "./Layer";
export class SpawnTimer implements Layer {
constructor(
private game: GameView,
private transformHandler: TransformHandler,
) {}
constructor(private game: GameView, private transformHandler: TransformHandler) { }
init() {}
tick() {}
shouldTransform(): boolean {
return false;
}
init() {
}
tick() {
}
shouldTransform(): boolean {
return false
renderLayer(context: CanvasRenderingContext2D) {
if (!this.game.inSpawnPhase()) {
return;
}
renderLayer(context: CanvasRenderingContext2D) {
if (!this.game.inSpawnPhase()) {
return
}
const barHeight = 15;
const barBackgroundWidth = this.transformHandler.width();
const barHeight = 15;
const barBackgroundWidth = this.transformHandler.width();
const ratio = this.game.ticks() / this.game.config().numSpawnPhaseTurns();
const ratio = this.game.ticks() / this.game.config().numSpawnPhaseTurns()
// Draw bar background
context.fillStyle = "rgba(0, 0, 0, 0.5)";
context.fillRect(0, 0, barBackgroundWidth, barHeight);
// Draw bar background
context.fillStyle = 'rgba(0, 0, 0, 0.5)';
context.fillRect(0, 0, barBackgroundWidth, barHeight);
context.fillStyle = 'rgba(0, 128, 255, 0.7)';
context.fillRect(0, 0, barBackgroundWidth * ratio, barHeight);
}
context.fillStyle = "rgba(0, 128, 255, 0.7)";
context.fillRect(0, 0, barBackgroundWidth * ratio, barHeight);
}
}
+184 -160
View File
@@ -3,192 +3,216 @@ import { Theme } from "../../../core/configuration/Config";
import { Layer } from "./Layer";
import { EventBus } from "../../../core/EventBus";
import anchorIcon from '../../../../resources/images/AnchorIcon.png';
import missileSiloIcon from '../../../../resources/images/MissileSiloUnit.png';
import shieldIcon from '../../../../resources/images/ShieldIcon.png';
import cityIcon from '../../../../resources/images/CityIcon.png';
import anchorIcon from "../../../../resources/images/AnchorIcon.png";
import missileSiloIcon from "../../../../resources/images/MissileSiloUnit.png";
import shieldIcon from "../../../../resources/images/ShieldIcon.png";
import cityIcon from "../../../../resources/images/CityIcon.png";
import { GameView, UnitView } from "../../../core/game/GameView";
import { Cell, Unit, UnitType } from "../../../core/game/Game";
import { GameUpdateType } from "../../../core/game/GameUpdates";
import { euclDistFN } from "../../../core/game/GameMap";
interface UnitRenderConfig {
icon: string;
borderRadius: number;
territoryRadius: number;
icon: string;
borderRadius: number;
territoryRadius: number;
}
export class StructureLayer implements Layer {
private canvas: HTMLCanvasElement;
private context: CanvasRenderingContext2D;
private unitIcons: Map<string, ImageData> = new Map();
private theme: Theme = null;
private canvas: HTMLCanvasElement;
private context: CanvasRenderingContext2D;
private unitIcons: Map<string, ImageData> = new Map();
private theme: Theme = null;
// Configuration for supported unit types only
private readonly unitConfigs: Partial<Record<UnitType, UnitRenderConfig>> = {
[UnitType.Port]: {
icon: anchorIcon,
borderRadius: 8,
territoryRadius: 6,
},
[UnitType.MissileSilo]: {
icon: missileSiloIcon,
borderRadius: 8,
territoryRadius: 6,
},
[UnitType.DefensePost]: {
icon: shieldIcon,
borderRadius: 8,
territoryRadius: 6,
},
[UnitType.City]: {
icon: cityIcon,
borderRadius: 8,
territoryRadius: 6,
},
};
// Configuration for supported unit types only
private readonly unitConfigs: Partial<Record<UnitType, UnitRenderConfig>> = {
[UnitType.Port]: {
icon: anchorIcon,
borderRadius: 8,
territoryRadius: 6
},
[UnitType.MissileSilo]: {
icon: missileSiloIcon,
borderRadius: 8,
territoryRadius: 6
},
[UnitType.DefensePost]: {
icon: shieldIcon,
borderRadius: 8,
territoryRadius: 6
},
[UnitType.City]: {
icon: cityIcon,
borderRadius: 8,
territoryRadius: 6
}
};
constructor(
private game: GameView,
private eventBus: EventBus,
) {
this.theme = game.config().theme();
this.loadIconData();
}
constructor(private game: GameView, private eventBus: EventBus) {
this.theme = game.config().theme();
this.loadIconData();
}
private loadIconData() {
Object.entries(this.unitConfigs).forEach(([unitType, config]) => {
const image = new Image();
image.src = config.icon;
image.onload = () => {
// Create temporary canvas for icon processing
const tempCanvas = document.createElement("canvas");
const tempContext = tempCanvas.getContext("2d");
tempCanvas.width = image.width;
tempCanvas.height = image.height;
private loadIconData() {
Object.entries(this.unitConfigs).forEach(([unitType, config]) => {
const image = new Image();
image.src = config.icon;
image.onload = () => {
// Create temporary canvas for icon processing
const tempCanvas = document.createElement('canvas');
const tempContext = tempCanvas.getContext('2d');
tempCanvas.width = image.width;
tempCanvas.height = image.height;
// Draw the unit icon
tempContext.drawImage(image, 0, 0);
const iconData = tempContext.getImageData(0, 0, tempCanvas.width, tempCanvas.height);
this.unitIcons.set(unitType, iconData)
console.log(`icond data width height: ${iconData.width}, ${iconData.height}`)
};
});
}
shouldTransform(): boolean {
return true;
}
tick() {
this.game.updatesSinceLastTick()[GameUpdateType.Unit]
.forEach(u => this.handleUnitRendering(this.game.unit(u.id)));
}
init() {
this.redraw();
}
redraw() {
console.log('structure layer redrawing');
this.canvas = document.createElement('canvas');
this.context = this.canvas.getContext("2d", { alpha: true });
this.canvas.width = this.game.width();
this.canvas.height = this.game.height();
this.game.units().forEach(u => this.handleUnitRendering(u));
}
renderLayer(context: CanvasRenderingContext2D) {
context.drawImage(
this.canvas,
-this.game.width() / 2,
-this.game.height() / 2,
this.game.width(),
this.game.height()
// Draw the unit icon
tempContext.drawImage(image, 0, 0);
const iconData = tempContext.getImageData(
0,
0,
tempCanvas.width,
tempCanvas.height,
);
this.unitIcons.set(unitType, iconData);
console.log(
`icond data width height: ${iconData.width}, ${iconData.height}`,
);
};
});
}
shouldTransform(): boolean {
return true;
}
tick() {
this.game
.updatesSinceLastTick()
[
GameUpdateType.Unit
].forEach((u) => this.handleUnitRendering(this.game.unit(u.id)));
}
init() {
this.redraw();
}
redraw() {
console.log("structure layer redrawing");
this.canvas = document.createElement("canvas");
this.context = this.canvas.getContext("2d", { alpha: true });
this.canvas.width = this.game.width();
this.canvas.height = this.game.height();
this.game.units().forEach((u) => this.handleUnitRendering(u));
}
renderLayer(context: CanvasRenderingContext2D) {
context.drawImage(
this.canvas,
-this.game.width() / 2,
-this.game.height() / 2,
this.game.width(),
this.game.height(),
);
}
private isUnitTypeSupported(unitType: UnitType): boolean {
return unitType in this.unitConfigs;
}
private handleUnitRendering(unit: UnitView) {
const unitType = unit.type();
if (!this.isUnitTypeSupported(unitType)) return;
const config = this.unitConfigs[unitType];
const icon = this.unitIcons.get(unitType);
if (!config || !icon) return;
// Clear previous rendering
for (const tile of this.game.bfs(
unit.tile(),
euclDistFN(unit.tile(), config.borderRadius),
)) {
this.clearCell(new Cell(this.game.x(tile), this.game.y(tile)));
}
private isUnitTypeSupported(unitType: UnitType): boolean {
return unitType in this.unitConfigs;
if (!unit.isActive()) {
return;
}
private handleUnitRendering(unit: UnitView) {
const unitType = unit.type();
if (!this.isUnitTypeSupported(unitType)) return;
// Draw border and territory
for (const tile of this.game.bfs(
unit.tile(),
euclDistFN(unit.tile(), config.borderRadius),
)) {
this.paintCell(
new Cell(this.game.x(tile), this.game.y(tile)),
this.theme.borderColor(unit.owner().info()),
255,
);
}
for (const tile of this.game.bfs(
unit.tile(),
euclDistFN(unit.tile(), config.territoryRadius),
)) {
this.paintCell(
new Cell(this.game.x(tile), this.game.y(tile)),
this.theme.territoryColor(unit.owner().info()),
130,
);
}
const config = this.unitConfigs[unitType];
const icon = this.unitIcons.get(unitType);
const startX = this.game.x(unit.tile()) - Math.floor(icon.width / 2);
const startY = this.game.y(unit.tile()) - Math.floor(icon.height / 2);
// Draw the icon
this.renderIcon(icon, startX, startY, icon.width, icon.height, unit);
}
if (!config || !icon) return;
private renderIcon(
iconData: ImageData,
startX: number,
startY: number,
width: number,
height: number,
unit: UnitView,
) {
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const iconIndex = (y * width + x) * 4;
const alpha = iconData.data[iconIndex + 3];
// Clear previous rendering
for (const tile of this.game.bfs(unit.tile(), euclDistFN(unit.tile(), config.borderRadius))) {
this.clearCell(new Cell(this.game.x(tile), this.game.y(tile)));
}
if (alpha > 0) {
const targetX = startX + x;
const targetY = startY + y;
if (!unit.isActive()) {
return
}
// Draw border and territory
for (const tile of this.game.bfs(unit.tile(), euclDistFN(unit.tile(), config.borderRadius))) {
if (
targetX >= 0 &&
targetX < this.game.width() &&
targetY >= 0 &&
targetY < this.game.height()
) {
this.paintCell(
new Cell(this.game.x(tile), this.game.y(tile)),
this.theme.borderColor(unit.owner().info()),
255
new Cell(targetX, targetY),
this.theme.borderColor(unit.owner().info()),
alpha,
);
}
}
for (const tile of this.game.bfs(unit.tile(), euclDistFN(unit.tile(), config.territoryRadius))) {
this.paintCell(
new Cell(this.game.x(tile), this.game.y(tile)),
this.theme.territoryColor(unit.owner().info()),
130
);
}
const startX = this.game.x(unit.tile()) - Math.floor(icon.width / 2);
const startY = this.game.y(unit.tile()) - Math.floor(icon.height / 2);
// Draw the icon
this.renderIcon(icon, startX, startY, icon.width, icon.height, unit);
}
}
}
private renderIcon(
iconData: ImageData,
startX: number,
startY: number,
width: number,
height: number,
unit: UnitView
) {
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const iconIndex = (y * width + x) * 4;
const alpha = iconData.data[iconIndex + 3];
paintCell(cell: Cell, color: Colord, alpha: number) {
this.clearCell(cell);
this.context.fillStyle = color.alpha(alpha / 255).toRgbString();
this.context.fillRect(cell.x, cell.y, 1, 1);
}
if (alpha > 0) {
const targetX = startX + x;
const targetY = startY + y;
if (targetX >= 0 && targetX < this.game.width() &&
targetY >= 0 && targetY < this.game.height()) {
this.paintCell(
new Cell(targetX, targetY),
this.theme.borderColor(unit.owner().info()),
alpha
);
}
}
}
}
}
paintCell(cell: Cell, color: Colord, alpha: number) {
this.clearCell(cell);
this.context.fillStyle = color.alpha(alpha / 255).toRgbString();
this.context.fillRect(cell.x, cell.y, 1, 1);
}
clearCell(cell: Cell) {
this.context.clearRect(cell.x, cell.y, 1, 1);
}
}
clearCell(cell: Cell) {
this.context.clearRect(cell.x, cell.y, 1, 1);
}
}
+49 -46
View File
@@ -2,55 +2,58 @@ import { Layer } from "./Layer";
import { GameView } from "../../../core/game/GameView";
export class TerrainLayer implements Layer {
private canvas: HTMLCanvasElement
private context: CanvasRenderingContext2D
private imageData: ImageData
private canvas: HTMLCanvasElement;
private context: CanvasRenderingContext2D;
private imageData: ImageData;
constructor(private game: GameView) {}
shouldTransform(): boolean {
return true;
}
tick() {}
constructor(private game: GameView) { }
shouldTransform(): boolean {
return true
}
tick() {
}
init() {
console.log("redrew terrain layer");
this.redraw();
}
init() {
console.log('redrew terrain layer')
this.redraw()
}
redraw(): void {
this.canvas = document.createElement("canvas");
this.context = this.canvas.getContext("2d");
redraw(): void {
this.canvas = document.createElement('canvas');
this.context = this.canvas.getContext("2d")
this.imageData = this.context.getImageData(
0,
0,
this.game.width(),
this.game.height(),
);
this.initImageData();
this.canvas.width = this.game.width();
this.canvas.height = this.game.height();
this.context.putImageData(this.imageData, 0, 0);
}
this.imageData = this.context.getImageData(0, 0, this.game.width(), this.game.height())
this.initImageData()
this.canvas.width = this.game.width();
this.canvas.height = this.game.height();
this.context.putImageData(this.imageData, 0, 0);
}
initImageData() {
const theme = this.game.config().theme();
this.game.forEachTile((tile) => {
let terrainColor = theme.terrainColor(this.game, tile);
// TODO: isn'te tileref and index the same?
const index = this.game.y(tile) * this.game.width() + this.game.x(tile);
const offset = index * 4;
this.imageData.data[offset] = terrainColor.rgba.r;
this.imageData.data[offset + 1] = terrainColor.rgba.g;
this.imageData.data[offset + 2] = terrainColor.rgba.b;
this.imageData.data[offset + 3] = (terrainColor.rgba.a * 255) | 0;
});
}
initImageData() {
const theme = this.game.config().theme()
this.game.forEachTile((tile) => {
let terrainColor = theme.terrainColor(this.game, tile)
// TODO: isn'te tileref and index the same?
const index = (this.game.y(tile) * this.game.width()) + this.game.x(tile)
const offset = index * 4
this.imageData.data[offset] = terrainColor.rgba.r;
this.imageData.data[offset + 1] = terrainColor.rgba.g;
this.imageData.data[offset + 2] = terrainColor.rgba.b;
this.imageData.data[offset + 3] = terrainColor.rgba.a * 255 | 0
})
}
renderLayer(context: CanvasRenderingContext2D) {
context.drawImage(
this.canvas,
-this.game.width() / 2,
-this.game.height() / 2,
this.game.width(),
this.game.height()
)
}
}
renderLayer(context: CanvasRenderingContext2D) {
context.drawImage(
this.canvas,
-this.game.width() / 2,
-this.game.height() / 2,
this.game.width(),
this.game.height(),
);
}
}
+232 -192
View File
@@ -1,222 +1,262 @@
import { PriorityQueue } from "@datastructures-js/priority-queue";
import { Cell, Game, Player, PlayerType, Unit, UnitType } from "../../../core/game/Game";
import {
Cell,
Game,
Player,
PlayerType,
Unit,
UnitType,
} from "../../../core/game/Game";
import { UnitUpdate } from "../../../core/game/GameUpdates";
import { PseudoRandom } from "../../../core/PseudoRandom";
import { colord, Colord } from "colord";
import { Theme } from "../../../core/configuration/Config";
import { Layer } from "./Layer";
import { EventBus } from "../../../core/EventBus";
import { AlternateViewEvent, DragEvent, MouseDownEvent } from "../../InputHandler";
import {
AlternateViewEvent,
DragEvent,
MouseDownEvent,
} from "../../InputHandler";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { euclDistFN, TileRef } from "../../../core/game/GameMap";
export class TerritoryLayer implements Layer {
private canvas: HTMLCanvasElement;
private context: CanvasRenderingContext2D;
private imageData: ImageData;
private canvas: HTMLCanvasElement;
private context: CanvasRenderingContext2D;
private imageData: ImageData;
private tileToRenderQueue: PriorityQueue<{ tile: TileRef, lastUpdate: number }> = new PriorityQueue((a, b) => { return a.lastUpdate - b.lastUpdate });
private random = new PseudoRandom(123);
private theme: Theme = null;
private tileToRenderQueue: PriorityQueue<{
tile: TileRef;
lastUpdate: number;
}> = new PriorityQueue((a, b) => {
return a.lastUpdate - b.lastUpdate;
});
private random = new PseudoRandom(123);
private theme: Theme = null;
// Used for spawn highlighting
private highlightCanvas: HTMLCanvasElement;
private highlightContext: CanvasRenderingContext2D;
// Used for spawn highlighting
private highlightCanvas: HTMLCanvasElement;
private highlightContext: CanvasRenderingContext2D;
private alternativeView = false;
private lastDragTime = 0;
private nodrawDragDuration = 200;
private alternativeView = false;
private lastDragTime = 0;
private nodrawDragDuration = 200;
private refreshRate = 50;
private lastRefresh = 0;
private refreshRate = 50;
private lastRefresh = 0;
constructor(private game: GameView, private eventBus: EventBus) {
this.theme = game.config().theme();
constructor(
private game: GameView,
private eventBus: EventBus,
) {
this.theme = game.config().theme();
}
shouldTransform(): boolean {
return true;
}
tick() {
this.game.recentlyUpdatedTiles().forEach((t) => this.enqueueTile(t));
if (!this.game.inSpawnPhase()) {
return;
}
if (this.game.ticks() % 5 == 0) {
return;
}
shouldTransform(): boolean {
return true;
}
this.highlightContext.clearRect(
0,
0,
this.game.width(),
this.game.height(),
);
const humans = this.game
.playerViews()
.filter((p) => p.type() == PlayerType.Human);
tick() {
this.game.recentlyUpdatedTiles()
.forEach(t => this.enqueueTile(t));
if (!this.game.inSpawnPhase()) {
return;
}
if (this.game.ticks() % 5 == 0) {
return;
}
this.highlightContext.clearRect(0, 0, this.game.width(), this.game.height());
const humans = this.game.playerViews()
.filter(p => p.type() == PlayerType.Human);
for (const human of humans) {
const center = human.nameLocation();
if (!center) {
continue;
}
const centerTile = this.game.ref(center.x, center.y)
if (!centerTile) {
continue;
}
for (const tile of this.game.bfs(centerTile, euclDistFN(centerTile, 9))) {
if (!this.game.hasOwner(tile)) {
this.paintHighlightCell(
new Cell(this.game.x(tile), this.game.y(tile)),
this.theme.spawnHighlightColor(),
255
);
}
}
}
}
init() {
this.eventBus.on(AlternateViewEvent, e => { this.alternativeView = e.alternateView; });
this.eventBus.on(DragEvent, e => { this.lastDragTime = Date.now(); });
this.redraw();
}
redraw() {
console.log('redrew territory layer');
this.canvas = document.createElement('canvas');
this.context = this.canvas.getContext("2d");
this.imageData = this.context.getImageData(0, 0, this.game.width(), this.game.height());
this.initImageData();
this.canvas.width = this.game.width();
this.canvas.height = this.game.height();
this.context.putImageData(this.imageData, 0, 0);
// Add a second canvas for highlights
this.highlightCanvas = document.createElement('canvas');
this.highlightContext = this.highlightCanvas.getContext("2d", { alpha: true });
this.highlightCanvas.width = this.game.width();
this.highlightCanvas.height = this.game.height();
this.game.forEachTile(t => {
this.paintTerritory(t);
});
}
initImageData() {
this.game.forEachTile((tile) => {
const cell = new Cell(this.game.x(tile), this.game.y(tile));
const index = (cell.y * this.game.width()) + cell.x;
const offset = index * 4;
this.imageData.data[offset + 3] = 0;
});
}
renderLayer(context: CanvasRenderingContext2D) {
if (Date.now() > this.lastDragTime + this.nodrawDragDuration && Date.now() > this.lastRefresh + this.refreshRate) {
this.lastRefresh = Date.now();
this.renderTerritory();
this.context.putImageData(this.imageData, 0, 0);
}
if (this.alternativeView) {
return;
}
context.drawImage(
this.canvas,
-this.game.width() / 2,
-this.game.height() / 2,
this.game.width(),
this.game.height()
);
if (this.game.inSpawnPhase()) {
context.drawImage(
this.highlightCanvas,
-this.game.width() / 2,
-this.game.height() / 2,
this.game.width(),
this.game.height()
);
}
}
renderTerritory() {
let numToRender = Math.floor(this.tileToRenderQueue.size() / 5);
if (numToRender == 0 || this.game.inSpawnPhase()) {
numToRender = this.tileToRenderQueue.size();
}
while (numToRender > 0) {
numToRender--;
const tile = this.tileToRenderQueue.pop().tile;
this.paintTerritory(tile);
for (const neighbor of this.game.neighbors(tile)) {
this.paintTerritory(neighbor, true);
}
}
}
paintTerritory(tile: TileRef, isBorder: boolean = false) {
if (isBorder && !this.game.hasOwner(tile)) {
return;
}
for (const human of humans) {
const center = human.nameLocation();
if (!center) {
continue;
}
const centerTile = this.game.ref(center.x, center.y);
if (!centerTile) {
continue;
}
for (const tile of this.game.bfs(centerTile, euclDistFN(centerTile, 9))) {
if (!this.game.hasOwner(tile)) {
if (this.game.hasFallout(tile)) {
this.paintCell(
this.game.x(tile),
this.game.y(tile),
this.theme.falloutColor(),
150
);
return;
}
this.clearCell(new Cell(this.game.x(tile), this.game.y(tile)));
return;
}
const owner = this.game.owner(tile) as Player;
if (this.game.isBorder(tile)) {
this.paintCell(
this.game.x(tile), this.game.y(tile),
this.theme.borderColor(owner.info()),
255
);
} else {
this.paintCell(
this.game.x(tile), this.game.y(tile),
this.theme.territoryColor(owner.info()),
150
);
this.paintHighlightCell(
new Cell(this.game.x(tile), this.game.y(tile)),
this.theme.spawnHighlightColor(),
255,
);
}
}
}
}
init() {
this.eventBus.on(AlternateViewEvent, (e) => {
this.alternativeView = e.alternateView;
});
this.eventBus.on(DragEvent, (e) => {
this.lastDragTime = Date.now();
});
this.redraw();
}
redraw() {
console.log("redrew territory layer");
this.canvas = document.createElement("canvas");
this.context = this.canvas.getContext("2d");
this.imageData = this.context.getImageData(
0,
0,
this.game.width(),
this.game.height(),
);
this.initImageData();
this.canvas.width = this.game.width();
this.canvas.height = this.game.height();
this.context.putImageData(this.imageData, 0, 0);
// Add a second canvas for highlights
this.highlightCanvas = document.createElement("canvas");
this.highlightContext = this.highlightCanvas.getContext("2d", {
alpha: true,
});
this.highlightCanvas.width = this.game.width();
this.highlightCanvas.height = this.game.height();
this.game.forEachTile((t) => {
this.paintTerritory(t);
});
}
initImageData() {
this.game.forEachTile((tile) => {
const cell = new Cell(this.game.x(tile), this.game.y(tile));
const index = cell.y * this.game.width() + cell.x;
const offset = index * 4;
this.imageData.data[offset + 3] = 0;
});
}
renderLayer(context: CanvasRenderingContext2D) {
if (
Date.now() > this.lastDragTime + this.nodrawDragDuration &&
Date.now() > this.lastRefresh + this.refreshRate
) {
this.lastRefresh = Date.now();
this.renderTerritory();
this.context.putImageData(this.imageData, 0, 0);
}
if (this.alternativeView) {
return;
}
paintCell(x: number, y: number, color: Colord, alpha: number) {
const index = (y * this.game.width()) + x;
const offset = index * 4;
this.imageData.data[offset] = color.rgba.r;
this.imageData.data[offset + 1] = color.rgba.g;
this.imageData.data[offset + 2] = color.rgba.b;
this.imageData.data[offset + 3] = alpha;
context.drawImage(
this.canvas,
-this.game.width() / 2,
-this.game.height() / 2,
this.game.width(),
this.game.height(),
);
if (this.game.inSpawnPhase()) {
context.drawImage(
this.highlightCanvas,
-this.game.width() / 2,
-this.game.height() / 2,
this.game.width(),
this.game.height(),
);
}
}
renderTerritory() {
let numToRender = Math.floor(this.tileToRenderQueue.size() / 5);
if (numToRender == 0 || this.game.inSpawnPhase()) {
numToRender = this.tileToRenderQueue.size();
}
clearCell(cell: Cell) {
const index = (cell.y * this.game.width()) + cell.x;
const offset = index * 4;
this.imageData.data[offset + 3] = 0; // Set alpha to 0 (fully transparent)
while (numToRender > 0) {
numToRender--;
const tile = this.tileToRenderQueue.pop().tile;
this.paintTerritory(tile);
for (const neighbor of this.game.neighbors(tile)) {
this.paintTerritory(neighbor, true);
}
}
}
enqueueTile(tile: TileRef) {
this.tileToRenderQueue.push({
tile: tile,
lastUpdate: this.game.ticks() + this.random.nextFloat(0, .5)
});
paintTerritory(tile: TileRef, isBorder: boolean = false) {
if (isBorder && !this.game.hasOwner(tile)) {
return;
}
if (!this.game.hasOwner(tile)) {
if (this.game.hasFallout(tile)) {
this.paintCell(
this.game.x(tile),
this.game.y(tile),
this.theme.falloutColor(),
150,
);
return;
}
this.clearCell(new Cell(this.game.x(tile), this.game.y(tile)));
return;
}
const owner = this.game.owner(tile) as Player;
if (this.game.isBorder(tile)) {
this.paintCell(
this.game.x(tile),
this.game.y(tile),
this.theme.borderColor(owner.info()),
255,
);
} else {
this.paintCell(
this.game.x(tile),
this.game.y(tile),
this.theme.territoryColor(owner.info()),
150,
);
}
}
paintHighlightCell(cell: Cell, color: Colord, alpha: number) {
this.clearCell(cell);
this.highlightContext.fillStyle = color.alpha(alpha / 255).toRgbString();
this.highlightContext.fillRect(cell.x, cell.y, 1, 1);
}
paintCell(x: number, y: number, color: Colord, alpha: number) {
const index = y * this.game.width() + x;
const offset = index * 4;
this.imageData.data[offset] = color.rgba.r;
this.imageData.data[offset + 1] = color.rgba.g;
this.imageData.data[offset + 2] = color.rgba.b;
this.imageData.data[offset + 3] = alpha;
}
clearHighlightCell(cell: Cell) {
this.highlightContext.clearRect(cell.x, cell.y, 1, 1);
}
}
clearCell(cell: Cell) {
const index = cell.y * this.game.width() + cell.x;
const offset = index * 4;
this.imageData.data[offset + 3] = 0; // Set alpha to 0 (fully transparent)
}
enqueueTile(tile: TileRef) {
this.tileToRenderQueue.push({
tile: tile,
lastUpdate: this.game.ticks() + this.random.nextFloat(0, 0.5),
});
}
paintHighlightCell(cell: Cell, color: Colord, alpha: number) {
this.clearCell(cell);
this.highlightContext.fillStyle = color.alpha(alpha / 255).toRgbString();
this.highlightContext.fillRect(cell.x, cell.y, 1, 1);
}
clearHighlightCell(cell: Cell) {
this.highlightContext.clearRect(cell.x, cell.y, 1, 1);
}
}
+356 -310
View File
@@ -7,358 +7,404 @@ import { EventBus } from "../../../core/EventBus";
import { AlternateViewEvent } from "../../InputHandler";
import { ClientID } from "../../../core/Schemas";
import { GameView, PlayerView, UnitView } from "../../../core/game/GameView";
import { euclDistFN, manhattanDistFN, TileRef } from "../../../core/game/GameMap";
import {
euclDistFN,
manhattanDistFN,
TileRef,
} from "../../../core/game/GameMap";
enum Relationship {
Self,
Ally,
Enemy
Self,
Ally,
Enemy,
}
export class UnitLayer implements Layer {
private canvas: HTMLCanvasElement;
private context: CanvasRenderingContext2D;
private canvas: HTMLCanvasElement;
private context: CanvasRenderingContext2D;
private boatToTrail = new Map<UnitView, Set<TileRef>>();
private boatToTrail = new Map<UnitView, Set<TileRef>>();
private theme: Theme = null;
private theme: Theme = null;
private alternateView = false;
private alternateView = false;
private myPlayer: PlayerView | null = null;
private myPlayer: PlayerView | null = null;
private oldShellTile = new Map<UnitView, TileRef>();
private oldShellTile = new Map<UnitView, TileRef>();
constructor(private game: GameView, private eventBus: EventBus, private clientID: ClientID) {
this.theme = game.config().theme();
constructor(
private game: GameView,
private eventBus: EventBus,
private clientID: ClientID,
) {
this.theme = game.config().theme();
}
shouldTransform(): boolean {
return true;
}
tick() {
if (this.myPlayer == null) {
this.myPlayer = this.game.playerByClientID(this.clientID);
}
for (const unit of this.game.units()) {
if (unit.wasUpdated()) this.onUnitEvent(unit);
}
}
init() {
this.eventBus.on(AlternateViewEvent, (e) => this.onAlternativeViewEvent(e));
this.redraw();
}
renderLayer(context: CanvasRenderingContext2D) {
context.drawImage(
this.canvas,
-this.game.width() / 2,
-this.game.height() / 2,
this.game.width(),
this.game.height(),
);
}
onAlternativeViewEvent(event: AlternateViewEvent) {
this.alternateView = event.alternateView;
this.redraw();
}
redraw() {
this.canvas = document.createElement("canvas");
this.context = this.canvas.getContext("2d");
this.canvas.width = this.game.width();
this.canvas.height = this.game.height();
for (const unit of this.game.units()) {
// this.onUnitEvent(new UnitEvent(unit, unit.tile()))
}
}
private relationship(unit: UnitView): Relationship {
if (this.myPlayer == null) {
return Relationship.Enemy;
}
if (this.myPlayer == unit.owner()) {
return Relationship.Self;
}
if (this.myPlayer.isAlliedWith(unit.owner())) {
return Relationship.Ally;
}
return Relationship.Enemy;
}
onUnitEvent(unit: UnitView) {
switch (unit.type()) {
case UnitType.TransportShip:
this.handleBoatEvent(unit);
break;
case UnitType.Destroyer:
this.handleDestroyerEvent(unit);
break;
case UnitType.Battleship:
this.handleBattleshipEvent(unit);
break;
case UnitType.Shell:
this.handleShellEvent(unit);
break;
case UnitType.TradeShip:
this.handleTradeShipEvent(unit);
break;
case UnitType.AtomBomb:
case UnitType.HydrogenBomb:
this.handleNuke(unit);
break;
}
}
private handleDestroyerEvent(unit: UnitView) {
const rel = this.relationship(unit);
// Clear previous area
for (const t of this.game.bfs(
unit.lastTile(),
euclDistFN(unit.lastTile(), 4),
)) {
this.clearCell(this.game.x(t), this.game.y(t));
}
shouldTransform(): boolean {
return true;
if (!unit.isActive()) {
return;
}
tick() {
if (this.myPlayer == null) {
this.myPlayer = this.game.playerByClientID(this.clientID);
}
for (const unit of this.game.units()) {
if (unit.wasUpdated())
this.onUnitEvent(unit);
}
// Paint border
for (const t of this.game.bfs(unit.tile(), euclDistFN(unit.tile(), 4))) {
this.paintCell(
this.game.x(t),
this.game.y(t),
rel,
this.theme.borderColor(unit.owner().info()),
255,
);
}
init() {
this.eventBus.on(AlternateViewEvent, e => this.onAlternativeViewEvent(e));
this.redraw();
// Paint territory
for (const t of this.game.bfs(
unit.tile(),
manhattanDistFN(unit.tile(), 3),
)) {
this.paintCell(
this.game.x(t),
this.game.y(t),
rel,
this.theme.territoryColor(unit.owner().info()),
255,
);
}
}
private handleBattleshipEvent(unit: UnitView) {
const rel = this.relationship(unit);
// Clear previous area
for (const t of this.game.bfs(
unit.lastTile(),
euclDistFN(unit.lastTile(), 6),
)) {
this.clearCell(this.game.x(t), this.game.y(t));
}
renderLayer(context: CanvasRenderingContext2D) {
context.drawImage(
this.canvas,
-this.game.width() / 2,
-this.game.height() / 2,
this.game.width(),
this.game.height()
);
if (!unit.isActive()) {
return;
}
onAlternativeViewEvent(event: AlternateViewEvent) {
this.alternateView = event.alternateView;
this.redraw();
// Paint outer territory
for (const t of this.game.bfs(unit.tile(), euclDistFN(unit.tile(), 5))) {
this.paintCell(
this.game.x(t),
this.game.y(t),
rel,
this.theme.territoryColor(unit.owner().info()),
255,
);
}
redraw() {
this.canvas = document.createElement('canvas');
this.context = this.canvas.getContext("2d");
this.canvas.width = this.game.width();
this.canvas.height = this.game.height();
for (const unit of this.game.units()) {
// this.onUnitEvent(new UnitEvent(unit, unit.tile()))
}
// Paint border
for (const t of this.game.bfs(
unit.tile(),
manhattanDistFN(unit.tile(), 4),
)) {
this.paintCell(
this.game.x(t),
this.game.y(t),
rel,
this.theme.borderColor(unit.owner().info()),
255,
);
}
private relationship(unit: UnitView): Relationship {
if (this.myPlayer == null) {
return Relationship.Enemy;
}
if (this.myPlayer == unit.owner()) {
return Relationship.Self;
}
if (this.myPlayer.isAlliedWith(unit.owner())) {
return Relationship.Ally;
}
return Relationship.Enemy;
// Paint inner territory
for (const t of this.game.bfs(unit.tile(), euclDistFN(unit.tile(), 1))) {
this.paintCell(
this.game.x(t),
this.game.y(t),
rel,
this.theme.territoryColor(unit.owner().info()),
255,
);
}
}
private handleShellEvent(unit: UnitView) {
const rel = this.relationship(unit);
// Clear current and previous positions
this.clearCell(this.game.x(unit.lastTile()), this.game.y(unit.lastTile()));
if (this.oldShellTile.has(unit)) {
const oldTile = this.oldShellTile.get(unit);
this.clearCell(this.game.x(oldTile), this.game.y(oldTile));
}
onUnitEvent(unit: UnitView) {
switch (unit.type()) {
case UnitType.TransportShip:
this.handleBoatEvent(unit);
break;
case UnitType.Destroyer:
this.handleDestroyerEvent(unit);
break;
case UnitType.Battleship:
this.handleBattleshipEvent(unit);
break;
case UnitType.Shell:
this.handleShellEvent(unit);
break;
case UnitType.TradeShip:
this.handleTradeShipEvent(unit);
break;
case UnitType.AtomBomb:
case UnitType.HydrogenBomb:
this.handleNuke(unit);
break;
}
this.oldShellTile.set(unit, unit.lastTile());
if (!unit.isActive()) {
return;
}
private handleDestroyerEvent(unit: UnitView) {
const rel = this.relationship(unit);
// Paint current and previous positions
this.paintCell(
this.game.x(unit.tile()),
this.game.y(unit.tile()),
rel,
this.theme.borderColor(unit.owner().info()),
255,
);
this.paintCell(
this.game.x(unit.lastTile()),
this.game.y(unit.lastTile()),
rel,
this.theme.borderColor(unit.owner().info()),
255,
);
}
// Clear previous area
for (const t of this.game.bfs(unit.lastTile(), euclDistFN(unit.lastTile(), 4))) {
this.clearCell(this.game.x(t), this.game.y(t));
}
private handleNuke(unit: UnitView) {
const rel = this.relationship(unit);
if (!unit.isActive()) {
return;
}
// Paint border
for (const t of this.game.bfs(unit.tile(), euclDistFN(unit.tile(), 4))) {
this.paintCell(
this.game.x(t),
this.game.y(t),
rel,
this.theme.borderColor(unit.owner().info()),
255
);
}
// Paint territory
for (const t of this.game.bfs(unit.tile(), manhattanDistFN(unit.tile(), 3))) {
this.paintCell(
this.game.x(t),
this.game.y(t),
rel,
this.theme.territoryColor(unit.owner().info()),
255
);
}
// Clear previous area
for (const t of this.game.bfs(
unit.lastTile(),
euclDistFN(unit.lastTile(), 2),
)) {
this.clearCell(this.game.x(t), this.game.y(t));
}
private handleBattleshipEvent(unit: UnitView) {
const rel = this.relationship(unit);
// Clear previous area
for (const t of this.game.bfs(unit.lastTile(), euclDistFN(unit.lastTile(), 6))) {
this.clearCell(this.game.x(t), this.game.y(t));
}
if (!unit.isActive()) {
return;
}
// Paint outer territory
for (const t of this.game.bfs(unit.tile(), euclDistFN(unit.tile(), 5))) {
this.paintCell(
this.game.x(t),
this.game.y(t),
rel,
this.theme.territoryColor(unit.owner().info()),
255
);
}
// Paint border
for (const t of this.game.bfs(unit.tile(), manhattanDistFN(unit.tile(), 4))) {
this.paintCell(
this.game.x(t),
this.game.y(t),
rel,
this.theme.borderColor(unit.owner().info()),
255
);
}
// Paint inner territory
for (const t of this.game.bfs(unit.tile(), euclDistFN(unit.tile(), 1))) {
this.paintCell(
this.game.x(t),
this.game.y(t),
rel,
this.theme.territoryColor(unit.owner().info()),
255
);
}
}
private handleShellEvent(unit: UnitView) {
const rel = this.relationship(unit);
// Clear current and previous positions
this.clearCell(this.game.x(unit.lastTile()), this.game.y(unit.lastTile()));
if (this.oldShellTile.has(unit)) {
const oldTile = this.oldShellTile.get(unit);
this.clearCell(this.game.x(oldTile), this.game.y(oldTile));
}
this.oldShellTile.set(unit, unit.lastTile());
if (!unit.isActive()) {
return;
}
// Paint current and previous positions
if (unit.isActive()) {
// Paint area
for (const t of this.game.bfs(unit.tile(), euclDistFN(unit.tile(), 2))) {
this.paintCell(
this.game.x(unit.tile()),
this.game.y(unit.tile()),
rel,
this.theme.borderColor(unit.owner().info()),
255
this.game.x(t),
this.game.y(t),
rel,
this.theme.borderColor(unit.owner().info()),
255,
);
}
}
}
private handleTradeShipEvent(unit: UnitView) {
const rel = this.relationship(unit);
// Clear previous area
for (const t of this.game.bfs(
unit.lastTile(),
euclDistFN(unit.lastTile(), 3),
)) {
this.clearCell(this.game.x(t), this.game.y(t));
}
if (unit.isActive()) {
// Paint territory
for (const t of this.game.bfs(
unit.tile(),
manhattanDistFN(unit.tile(), 2),
)) {
this.paintCell(
this.game.x(unit.lastTile()),
this.game.y(unit.lastTile()),
rel,
this.theme.borderColor(unit.owner().info()),
255
this.game.x(t),
this.game.y(t),
rel,
this.theme.territoryColor(unit.owner().info()),
255,
);
}
// Paint border
for (const t of this.game.bfs(
unit.tile(),
manhattanDistFN(unit.tile(), 1),
)) {
this.paintCell(
this.game.x(t),
this.game.y(t),
rel,
this.theme.borderColor(unit.owner().info()),
255,
);
}
}
}
private handleBoatEvent(unit: UnitView) {
const rel = this.relationship(unit);
if (!this.boatToTrail.has(unit)) {
this.boatToTrail.set(unit, new Set<TileRef>());
}
const trail = this.boatToTrail.get(unit);
trail.add(unit.lastTile());
// Clear previous area
for (const t of this.game.bfs(
unit.lastTile(),
manhattanDistFN(unit.lastTile(), 3),
)) {
this.clearCell(this.game.x(t), this.game.y(t));
}
private handleNuke(unit: UnitView) {
const rel = this.relationship(unit);
if (unit.isActive()) {
// Paint trail
for (const t of trail) {
this.paintCell(
this.game.x(t),
this.game.y(t),
rel,
this.theme.territoryColor(unit.owner().info()),
150,
);
}
// Clear previous area
for (const t of this.game.bfs(unit.lastTile(), euclDistFN(unit.lastTile(), 2))) {
this.clearCell(this.game.x(t), this.game.y(t));
}
// Paint border
for (const t of this.game.bfs(
unit.tile(),
manhattanDistFN(unit.tile(), 2),
)) {
this.paintCell(
this.game.x(t),
this.game.y(t),
rel,
this.theme.borderColor(unit.owner().info()),
255,
);
}
if (unit.isActive()) {
// Paint area
for (const t of this.game.bfs(unit.tile(), euclDistFN(unit.tile(), 2))) {
this.paintCell(
this.game.x(t),
this.game.y(t),
rel,
this.theme.borderColor(unit.owner().info()),
255
);
}
}
// Paint territory
for (const t of this.game.bfs(
unit.tile(),
manhattanDistFN(unit.tile(), 1),
)) {
this.paintCell(
this.game.x(t),
this.game.y(t),
rel,
this.theme.territoryColor(unit.owner().info()),
255,
);
}
} else {
for (const t of trail) {
this.clearCell(this.game.x(t), this.game.y(t));
}
this.boatToTrail.delete(unit);
}
}
private handleTradeShipEvent(unit: UnitView) {
const rel = this.relationship(unit);
// Clear previous area
for (const t of this.game.bfs(unit.lastTile(), euclDistFN(unit.lastTile(), 3))) {
this.clearCell(this.game.x(t), this.game.y(t));
}
if (unit.isActive()) {
// Paint territory
for (const t of this.game.bfs(unit.tile(), manhattanDistFN(unit.tile(), 2))) {
this.paintCell(
this.game.x(t),
this.game.y(t),
rel,
this.theme.territoryColor(unit.owner().info()),
255
);
}
// Paint border
for (const t of this.game.bfs(unit.tile(), manhattanDistFN(unit.tile(), 1))) {
this.paintCell(
this.game.x(t),
this.game.y(t),
rel,
this.theme.borderColor(unit.owner().info()),
255
);
}
}
paintCell(
x: number,
y: number,
relationship: Relationship,
color: Colord,
alpha: number,
) {
this.clearCell(x, y);
if (this.alternateView) {
switch (relationship) {
case Relationship.Self:
this.context.fillStyle = this.theme.selfColor().toRgbString();
break;
case Relationship.Ally:
this.context.fillStyle = this.theme.allyColor().toRgbString();
break;
case Relationship.Enemy:
this.context.fillStyle = this.theme.enemyColor().toRgbString();
break;
}
} else {
this.context.fillStyle = color.alpha(alpha / 255).toRgbString();
}
this.context.fillRect(x, y, 1, 1);
}
private handleBoatEvent(unit: UnitView) {
const rel = this.relationship(unit);
if (!this.boatToTrail.has(unit)) {
this.boatToTrail.set(unit, new Set<TileRef>());
}
const trail = this.boatToTrail.get(unit);
trail.add(unit.lastTile());
// Clear previous area
for (const t of this.game.bfs(unit.lastTile(), manhattanDistFN(unit.lastTile(), 3))) {
this.clearCell(this.game.x(t), this.game.y(t));
}
if (unit.isActive()) {
// Paint trail
for (const t of trail) {
this.paintCell(
this.game.x(t),
this.game.y(t),
rel,
this.theme.territoryColor(unit.owner().info()),
150
);
}
// Paint border
for (const t of this.game.bfs(unit.tile(), manhattanDistFN(unit.tile(), 2))) {
this.paintCell(
this.game.x(t),
this.game.y(t),
rel,
this.theme.borderColor(unit.owner().info()),
255
);
}
// Paint territory
for (const t of this.game.bfs(unit.tile(), manhattanDistFN(unit.tile(), 1))) {
this.paintCell(
this.game.x(t),
this.game.y(t),
rel,
this.theme.territoryColor(unit.owner().info()),
255
);
}
} else {
for (const t of trail) {
this.clearCell(this.game.x(t), this.game.y(t));
}
this.boatToTrail.delete(unit);
}
}
paintCell(x: number, y: number, relationship: Relationship, color: Colord, alpha: number) {
this.clearCell(x, y);
if (this.alternateView) {
switch (relationship) {
case Relationship.Self:
this.context.fillStyle = this.theme.selfColor().toRgbString();
break;
case Relationship.Ally:
this.context.fillStyle = this.theme.allyColor().toRgbString();
break;
case Relationship.Enemy:
this.context.fillStyle = this.theme.enemyColor().toRgbString();
break;
}
} else {
this.context.fillStyle = color.alpha(alpha / 255).toRgbString();
}
this.context.fillRect(x, y, 1, 1);
}
clearCell(x: number, y: number) {
this.context.clearRect(x, y, 1, 1);
}
}
clearCell(x: number, y: number) {
this.context.clearRect(x, y, 1, 1);
}
}
+289 -289
View File
@@ -1,328 +1,328 @@
import { LitElement, html, css } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { Player } from '../../../core/game/Game';
import { ClientID } from '../../../core/Schemas';
import { GameView, PlayerView } from '../../../core/game/GameView';
import { Layer } from './Layer';
import { GameUpdateType } from '../../../core/game/GameUpdates';
import { PseudoRandom } from '../../../core/PseudoRandom';
import { simpleHash } from '../../../core/Util';
import { EventBus } from '../../../core/EventBus';
import { SendWinnerEvent } from '../../Transport';
import { LitElement, html, css } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { Player } from "../../../core/game/Game";
import { ClientID } from "../../../core/Schemas";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { Layer } from "./Layer";
import { GameUpdateType } from "../../../core/game/GameUpdates";
import { PseudoRandom } from "../../../core/PseudoRandom";
import { simpleHash } from "../../../core/Util";
import { EventBus } from "../../../core/EventBus";
import { SendWinnerEvent } from "../../Transport";
const lowRadiationVictoryQuotes = [
"Victory is mine. The world endures - under new management.",
"They thought they could stop me. Now they serve me.",
"The old order has fallen. My reign begins.",
"Not every victory requires destruction.",
"From this day forward, all will know who rules.",
"The war is over. Long live the victor.",
"A new empire rises - with me at its helm.",
"The throne is claimed. The crown is mine.",
"Today marks the beginning of my dynasty.",
"They feared my wrath. Now they'll know my rule.",
"Victory was inevitable. Surrender was optional.",
"A new era dawns - under my command.",
"The pieces are in place. The victory is complete.",
"Let history remember who conquered all.",
"Their resistance only delayed the inevitable.",
"Power shifts. Empires fall. I remain.",
"The world has a new master now.",
"Their armies fell. Their nations surrendered. I prevailed.",
"All paths led to this victory.",
"The world bows to its new ruler.",
"From the ashes of their defeat, my victory rises.",
"Destiny called. I answered. The world followed.",
"They called me tyrant. Now they call me emperor.",
"The old powers have fallen. Mine endures.",
"Every empire needs a beginning. This is mine.",
"Their defiance crumbled before my ambition.",
"No more rebels. No more resistance. Only order.",
"The final piece falls into place.",
"Let them write of this day in their histories.",
"My vision becomes reality.",
"Their surrender was wise. My victory was certain.",
"The wheels of fate turn in my favor.",
"A new chapter begins - written by the victor.",
"They fought the inevitable. The inevitable won.",
"All that was theirs is now mine.",
"The gods themselves bow before my triumph.",
"Their kingdoms shatter. My empire rises.",
"Victory tastes sweeter than wine.",
"The crown suits me well, don't you think?",
"Behold the dawn of my eternal reign."
"Victory is mine. The world endures - under new management.",
"They thought they could stop me. Now they serve me.",
"The old order has fallen. My reign begins.",
"Not every victory requires destruction.",
"From this day forward, all will know who rules.",
"The war is over. Long live the victor.",
"A new empire rises - with me at its helm.",
"The throne is claimed. The crown is mine.",
"Today marks the beginning of my dynasty.",
"They feared my wrath. Now they'll know my rule.",
"Victory was inevitable. Surrender was optional.",
"A new era dawns - under my command.",
"The pieces are in place. The victory is complete.",
"Let history remember who conquered all.",
"Their resistance only delayed the inevitable.",
"Power shifts. Empires fall. I remain.",
"The world has a new master now.",
"Their armies fell. Their nations surrendered. I prevailed.",
"All paths led to this victory.",
"The world bows to its new ruler.",
"From the ashes of their defeat, my victory rises.",
"Destiny called. I answered. The world followed.",
"They called me tyrant. Now they call me emperor.",
"The old powers have fallen. Mine endures.",
"Every empire needs a beginning. This is mine.",
"Their defiance crumbled before my ambition.",
"No more rebels. No more resistance. Only order.",
"The final piece falls into place.",
"Let them write of this day in their histories.",
"My vision becomes reality.",
"Their surrender was wise. My victory was certain.",
"The wheels of fate turn in my favor.",
"A new chapter begins - written by the victor.",
"They fought the inevitable. The inevitable won.",
"All that was theirs is now mine.",
"The gods themselves bow before my triumph.",
"Their kingdoms shatter. My empire rises.",
"Victory tastes sweeter than wine.",
"The crown suits me well, don't you think?",
"Behold the dawn of my eternal reign.",
];
const highRadiationVictoryQuotes = [
"Let the world burn. I just want to rule the ashes.",
"The old world died screaming. My new one rises from its bones.",
"They could have surrendered. Now they glow.",
"Everything burns. The throne of ashes awaits.",
"A wasteland needs a king. I have answered the call.",
"Their cities turned to glass. My victory endures.",
"Who needs a pristine world when you can rule its ruins?",
"They feared the fire. I embraced it.",
"The radiation clears the way for my reign.",
"A dead world makes for quiet subjects.",
"From atomic fire, my kingdom rises.",
"Nothing left but ashes and victory.",
"They wanted war. I gave them annihilation.",
"The world burns green. My empire glows eternal.",
"All thrones are built on ashes. Mine just glows.",
"Look upon my works and despair - if you can still see.",
"The mushroom clouds herald my coronation.",
"A crown of thorns for a radioactive realm.",
"They chose extinction. I chose supremacy.",
"The wasteland's throne is mine to claim.",
"The Geiger counter clicks. The masses bow.",
"Atoms split. Nations fall. I reign supreme.",
"My kingdom radiates with possibility.",
"The isotopes of victory decay slowly.",
"Critical mass achieved. Dominion secured.",
"Chain reaction complete: world falls, empire rises.",
"In nuclear fire, I forge my legacy."
"Let the world burn. I just want to rule the ashes.",
"The old world died screaming. My new one rises from its bones.",
"They could have surrendered. Now they glow.",
"Everything burns. The throne of ashes awaits.",
"A wasteland needs a king. I have answered the call.",
"Their cities turned to glass. My victory endures.",
"Who needs a pristine world when you can rule its ruins?",
"They feared the fire. I embraced it.",
"The radiation clears the way for my reign.",
"A dead world makes for quiet subjects.",
"From atomic fire, my kingdom rises.",
"Nothing left but ashes and victory.",
"They wanted war. I gave them annihilation.",
"The world burns green. My empire glows eternal.",
"All thrones are built on ashes. Mine just glows.",
"Look upon my works and despair - if you can still see.",
"The mushroom clouds herald my coronation.",
"A crown of thorns for a radioactive realm.",
"They chose extinction. I chose supremacy.",
"The wasteland's throne is mine to claim.",
"The Geiger counter clicks. The masses bow.",
"Atoms split. Nations fall. I reign supreme.",
"My kingdom radiates with possibility.",
"The isotopes of victory decay slowly.",
"Critical mass achieved. Dominion secured.",
"Chain reaction complete: world falls, empire rises.",
"In nuclear fire, I forge my legacy.",
];
export const defeatQuotes = [
// Last words and final thoughts
"The flame of our nation flickers out...",
"History will remember we fought to the last.",
"Our glory fades into darkness.",
"The end comes for all nations. Today, it comes for us.",
"We fought. We failed. We fade.",
"Our time in the sun is done.",
"The pages of history close on our chapter.",
"So falls the dream of empire.",
"We built in stone, but even stone crumbles.",
"The stars themselves will remember our defiance.",
// Last words and final thoughts
"The flame of our nation flickers out...",
"History will remember we fought to the last.",
"Our glory fades into darkness.",
"The end comes for all nations. Today, it comes for us.",
"We fought. We failed. We fade.",
"Our time in the sun is done.",
"The pages of history close on our chapter.",
"So falls the dream of empire.",
"We built in stone, but even stone crumbles.",
"The stars themselves will remember our defiance.",
// Bitter defeats
"Treachery and fate conspired against us.",
"Our enemies dance on the graves of heroes.",
"The vultures circle what remains.",
"Let them celebrate. Dead men need no vengeance.",
"The light dies. The darkness wins.",
"Our walls fall. Our spirit breaks.",
"Victory goes to the ruthless.",
"Time claims another empire.",
"The crown shatters. The throne burns.",
"Our banners fall. Our story ends.",
// Bitter defeats
"Treachery and fate conspired against us.",
"Our enemies dance on the graves of heroes.",
"The vultures circle what remains.",
"Let them celebrate. Dead men need no vengeance.",
"The light dies. The darkness wins.",
"Our walls fall. Our spirit breaks.",
"Victory goes to the ruthless.",
"Time claims another empire.",
"The crown shatters. The throne burns.",
"Our banners fall. Our story ends.",
// Philosophical acceptance
"All great nations must face their sunset.",
"Time is the ultimate conqueror.",
"Today we join the ghosts of fallen empires.",
"What rises must also fall.",
"Our legacy scatters like dust in the wind.",
"The wheel turns. We descend.",
"From glory to ashes, as all things must.",
"The tides of fate show no mercy.",
"Let history judge if we were worthy.",
"We join the eternal silence.",
// Philosophical acceptance
"All great nations must face their sunset.",
"Time is the ultimate conqueror.",
"Today we join the ghosts of fallen empires.",
"What rises must also fall.",
"Our legacy scatters like dust in the wind.",
"The wheel turns. We descend.",
"From glory to ashes, as all things must.",
"The tides of fate show no mercy.",
"Let history judge if we were worthy.",
"We join the eternal silence.",
// Defiant last stands
"Our spirit remains unbroken.",
"They may take our lands, but not our pride.",
"Remember us as we were, not as we fell.",
"We chose death before dishonor.",
"Our courage lives beyond our defeat.",
"Let them write of how we stood fast.",
"We fall, but we fall fighting.",
"Honor guides us to our end.",
"Death before surrender.",
"The echoes of our defiance will ring eternal.",
// Defiant last stands
"Our spirit remains unbroken.",
"They may take our lands, but not our pride.",
"Remember us as we were, not as we fell.",
"We chose death before dishonor.",
"Our courage lives beyond our defeat.",
"Let them write of how we stood fast.",
"We fall, but we fall fighting.",
"Honor guides us to our end.",
"Death before surrender.",
"The echoes of our defiance will ring eternal.",
// Prophetic/Cursed
"Our shadow will haunt their victory.",
"They'll learn the price of empire.",
"Time will prove our cause was just.",
"The seeds of their downfall are sown in our ashes.",
"Our fall heralds their doom.",
"Victory today. Nemesis tomorrow.",
"The wheel turns for all.",
"They'll remember us in their nightmares.",
"Our curse follows them to their graves.",
"What rises in our place will shake the world."
// Prophetic/Cursed
"Our shadow will haunt their victory.",
"They'll learn the price of empire.",
"Time will prove our cause was just.",
"The seeds of their downfall are sown in our ashes.",
"Our fall heralds their doom.",
"Victory today. Nemesis tomorrow.",
"The wheel turns for all.",
"They'll remember us in their nightmares.",
"Our curse follows them to their graves.",
"What rises in our place will shake the world.",
];
@customElement('win-modal')
@customElement("win-modal")
export class WinModal extends LitElement implements Layer {
public game: GameView
public eventBus: EventBus
public game: GameView;
public eventBus: EventBus;
private rand: PseudoRandom;
private rand: PseudoRandom;
private hasShownDeathModal = false;
private hasShownDeathModal = false
@state()
isVisible = false;
@state()
isVisible = false
private _title: string;
private message: string;
private _title: string
private message: string
static styles = css`
:host {
display: block;
}
static styles = css`
:host {
display: block;
}
.modal {
display: none;
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color: rgba(30, 30, 30, 0.7);
padding: 25px;
border-radius: 10px;
z-index: 9999;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.5);
backdrop-filter: blur(5px);
color: white;
width: 300px;
transition:
opacity 0.3s ease-in-out,
visibility 0.3s ease-in-out;
}
.modal {
display: none;
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color: rgba(30, 30, 30, 0.7);
padding: 25px;
border-radius: 10px;
z-index: 9999;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.5);
backdrop-filter: blur(5px);
color: white;
width: 300px;
transition: opacity 0.3s ease-in-out, visibility 0.3s ease-in-out;
}
.modal.visible {
display: block;
animation: fadeIn 0.3s ease-out;
}
.modal.visible {
display: block;
animation: fadeIn 0.3s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translate(-50%, -48%);
}
to {
opacity: 1;
transform: translate(-50%, -50%);
}
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translate(-50%, -48%);
}
to {
opacity: 1;
transform: translate(-50%, -50%);
}
}
h2 {
margin: 0 0 15px 0;
font-size: 24px;
text-align: center;
color: white;
}
h2 {
margin: 0 0 15px 0;
font-size: 24px;
text-align: center;
color: white;
}
p {
margin: 0 0 20px 0;
text-align: center;
background-color: rgba(0, 0, 0, 0.3);
padding: 10px;
border-radius: 5px;
}
p {
margin: 0 0 20px 0;
text-align: center;
background-color: rgba(0, 0, 0, 0.3);
padding: 10px;
border-radius: 5px;
}
.button-container {
display: flex;
justify-content: space-between;
gap: 10px;
}
.button-container {
display: flex;
justify-content: space-between;
gap: 10px;
}
button {
flex: 1;
padding: 12px;
font-size: 16px;
cursor: pointer;
background: rgba(0, 150, 255, 0.6);
color: white;
border: none;
border-radius: 5px;
transition:
background-color 0.2s ease,
transform 0.1s ease;
}
button {
flex: 1;
padding: 12px;
font-size: 16px;
cursor: pointer;
background: rgba(0, 150, 255, 0.6);
color: white;
border: none;
border-radius: 5px;
transition: background-color 0.2s ease, transform 0.1s ease;
}
button:hover {
background: rgba(0, 150, 255, 0.8);
transform: translateY(-1px);
}
button:hover {
background: rgba(0, 150, 255, 0.8);
transform: translateY(-1px);
}
button:active {
transform: translateY(1px);
}
button:active {
transform: translateY(1px);
}
@media (max-width: 768px) {
.modal {
width: 90%;
max-width: 300px;
padding: 20px;
}
@media (max-width: 768px) {
.modal {
width: 90%;
max-width: 300px;
padding: 20px;
}
h2 {
font-size: 20px;
}
h2 {
font-size: 20px;
}
button {
padding: 10px;
font-size: 14px;
}
}
`;
button {
padding: 10px;
font-size: 14px;
}
}
render() {
return html`
<div class="modal ${this.isVisible ? "visible" : ""}">
<h2>${this._title}</h2>
<p>${this.message}</p>
<div class="button-container">
<button @click=${this._handleExit}>Exit Game</button>
<button @click=${this.hide}>Keep Playing</button>
</div>
</div>
`;
}
render() {
return html`
<div class="modal ${this.isVisible ? 'visible' : ''}">
<h2>${this._title}</h2>
<p>${this.message}</p>
<div class="button-container">
<button @click=${this._handleExit}>Exit Game</button>
<button @click=${this.hide}>Keep Playing</button>
</div>
</div>
`;
show() {
this.isVisible = true;
this.requestUpdate();
}
hide() {
this.isVisible = false;
this.requestUpdate();
}
private _handleExit() {
this.hide();
window.location.reload();
}
init() {
this.rand = new PseudoRandom(simpleHash(this.game.myClientID()));
}
tick() {
const myPlayer = this.game.myPlayer();
if (!this.hasShownDeathModal && myPlayer && !myPlayer.isAlive()) {
this.hasShownDeathModal = true;
this._title = "You died";
this.message = this.rand.randElement(defeatQuotes);
this.show();
}
show() {
this.isVisible = true;
this.requestUpdate();
}
hide() {
this.isVisible = false;
this.requestUpdate();
}
private _handleExit() {
this.hide();
window.location.reload();
}
init() {
this.rand = new PseudoRandom(simpleHash(this.game.myClientID()))
}
tick() {
const myPlayer = this.game.myPlayer()
if (!this.hasShownDeathModal && myPlayer && !myPlayer.isAlive()) {
this.hasShownDeathModal = true
this._title = 'You died'
this.message = this.rand.randElement(defeatQuotes)
this.show()
this.game.updatesSinceLastTick()[GameUpdateType.WinUpdate].forEach((wu) => {
const winner = this.game.playerBySmallID(wu.winnerID) as PlayerView;
this.eventBus.emit(new SendWinnerEvent(winner.clientID()));
if (winner == this.game.myPlayer()) {
this._title = "You Won!";
if (this.game.numTilesWithFallout() / this.game.numLandTiles() > 0.6) {
this.message = this.rand.randElement(highRadiationVictoryQuotes);
} else {
this.message = this.rand.randElement(lowRadiationVictoryQuotes);
}
this.game.updatesSinceLastTick()[GameUpdateType.WinUpdate]
.forEach(wu => {
const winner = this.game.playerBySmallID(wu.winnerID) as PlayerView
this.eventBus.emit(new SendWinnerEvent(winner.clientID()))
if (winner == this.game.myPlayer()) {
this._title = 'You Won!'
if (this.game.numTilesWithFallout() / this.game.numLandTiles() > .6) {
this.message = this.rand.randElement(highRadiationVictoryQuotes)
} else {
this.message = this.rand.randElement(lowRadiationVictoryQuotes)
}
} else {
this._title = `${winner.name()} has won!`
this.message = this.rand.randElement(defeatQuotes)
}
this.show()
})
}
} else {
this._title = `${winner.name()} has won!`;
this.message = this.rand.randElement(defeatQuotes);
}
this.show();
});
}
renderLayer(context: CanvasRenderingContext2D) {
}
renderLayer(context: CanvasRenderingContext2D) {}
shouldTransform(): boolean {
return false
}
}
shouldTransform(): boolean {
return false;
}
}
+261 -230
View File
@@ -1,246 +1,277 @@
import { LitElement, html, css } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import { EventBus } from '../../../../core/EventBus';
import { Cell, Game, Player, PlayerActions, UnitType } from '../../../../core/game/Game';
import { BuildUnitIntentEvent } from '../../../Transport';
import atomBombIcon from '../../../../../resources/images/NukeIconWhite.svg';
import hydrogenBombIcon from '../../../../../resources/images/MushroomCloudIconWhite.svg';
import destroyerIcon from '../../../../../resources/images/DestroyerIconWhite.svg';
import battleshipIcon from '../../../../../resources/images/BattleshipIconWhite.svg';
import missileSiloIcon from '../../../../../resources/images/MissileSiloIconWhite.svg';
import goldCoinIcon from '../../../../../resources/images/GoldCoinIcon.svg';
import portIcon from '../../../../../resources/images/PortIcon.svg';
import shieldIcon from '../../../../../resources/images/ShieldIconWhite.svg';
import cityIcon from '../../../../../resources/images/CityIconWhite.svg';
import { renderNumber } from '../../../Utils';
import { ContextMenuEvent } from '../../../InputHandler';
import { GameView, PlayerView } from '../../../../core/game/GameView';
import { LitElement, html, css } from "lit";
import { customElement, state } from "lit/decorators.js";
import { EventBus } from "../../../../core/EventBus";
import {
Cell,
Game,
Player,
PlayerActions,
UnitType,
} from "../../../../core/game/Game";
import { BuildUnitIntentEvent } from "../../../Transport";
import atomBombIcon from "../../../../../resources/images/NukeIconWhite.svg";
import hydrogenBombIcon from "../../../../../resources/images/MushroomCloudIconWhite.svg";
import destroyerIcon from "../../../../../resources/images/DestroyerIconWhite.svg";
import battleshipIcon from "../../../../../resources/images/BattleshipIconWhite.svg";
import missileSiloIcon from "../../../../../resources/images/MissileSiloIconWhite.svg";
import goldCoinIcon from "../../../../../resources/images/GoldCoinIcon.svg";
import portIcon from "../../../../../resources/images/PortIcon.svg";
import shieldIcon from "../../../../../resources/images/ShieldIconWhite.svg";
import cityIcon from "../../../../../resources/images/CityIconWhite.svg";
import { renderNumber } from "../../../Utils";
import { ContextMenuEvent } from "../../../InputHandler";
import { GameView, PlayerView } from "../../../../core/game/GameView";
interface BuildItemDisplay {
unitType: UnitType
icon: string;
unitType: UnitType;
icon: string;
}
const buildTable: BuildItemDisplay[][] = [
[
{ unitType: UnitType.AtomBomb, icon: atomBombIcon },
{ unitType: UnitType.HydrogenBomb, icon: hydrogenBombIcon },
{ unitType: UnitType.Destroyer, icon: destroyerIcon },
{ unitType: UnitType.Battleship, icon: battleshipIcon },
{ unitType: UnitType.Port, icon: portIcon },
{ unitType: UnitType.MissileSilo, icon: missileSiloIcon },
// { unitType: UnitType.DefensePost, icon: shieldIcon },
{ unitType: UnitType.City, icon: cityIcon }
]
[
{ unitType: UnitType.AtomBomb, icon: atomBombIcon },
{ unitType: UnitType.HydrogenBomb, icon: hydrogenBombIcon },
{ unitType: UnitType.Destroyer, icon: destroyerIcon },
{ unitType: UnitType.Battleship, icon: battleshipIcon },
{ unitType: UnitType.Port, icon: portIcon },
{ unitType: UnitType.MissileSilo, icon: missileSiloIcon },
// { unitType: UnitType.DefensePost, icon: shieldIcon },
{ unitType: UnitType.City, icon: cityIcon },
],
];
@customElement('build-menu')
@customElement("build-menu")
export class BuildMenu extends LitElement {
public game: GameView;
public eventBus: EventBus;
private myPlayer: PlayerView;
private clickedCell: Cell;
private playerActions: PlayerActions | null
public game: GameView;
public eventBus: EventBus;
private myPlayer: PlayerView;
private clickedCell: Cell;
private playerActions: PlayerActions | null;
static styles = css`
:host {
display: block;
}
.build-menu {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 9999;
background-color: #1E1E1E;
padding: 15px;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.5);
border-radius: 10px;
display: flex;
flex-direction: column;
align-items: center;
max-width: 95vw;
max-height: 95vh;
overflow-y: auto;
}
.build-row {
display: flex;
justify-content: center;
flex-wrap: wrap;
width: 100%;
}
.build-button {
width: 120px;
height: 120px;
border: 2px solid #444;
background-color: #2C2C2C;
color: white;
border-radius: 12px;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
margin: 8px;
padding: 10px;
}
.build-button:not(:disabled):hover {
background-color: #3A3A3A;
transform: scale(1.05);
border-color: #666;
}
.build-button:not(:disabled):active {
background-color: #4A4A4A;
transform: scale(0.95);
}
.build-button:disabled {
background-color: #1A1A1A;
border-color: #333;
cursor: not-allowed;
opacity: 0.7;
}
.build-button:disabled img {
opacity: 0.5;
}
.build-button:disabled .build-cost {
color: #FF4444;
}
.build-icon {
font-size: 40px;
margin-bottom: 5px;
}
.build-name {
font-size: 14px;
font-weight: bold;
margin-bottom: 5px;
text-align: center;
}
.build-cost {
font-size: 14px;
}
.hidden {
display: none !important;
}
@media (max-width: 768px) {
.build-menu {
padding: 10px;
max-height: 80vh;
}
.build-button {
width: 90px;
height: 90px;
margin: 4px;
padding: 6px;
}
.build-icon {
font-size: 28px;
}
.build-name {
font-size: 12px;
margin-bottom: 3px;
}
.build-cost {
font-size: 11px;
}
.build-button img {
width: 32px;
height: 32px;
}
}
@media (max-width: 480px) {
.build-menu {
padding: 8px;
max-height: 70vh;
}
.build-button {
width: 70px;
height: 70px;
margin: 3px;
padding: 4px;
border-width: 1px;
}
.build-icon {
font-size: 24px;
}
.build-name {
font-size: 10px;
margin-bottom: 2px;
}
.build-cost {
font-size: 9px;
}
.build-button img {
width: 24px;
height: 24px;
}
.build-cost img {
width: 10px;
height: 10px;
}
}
`;
@state()
private _hidden = true;
private canBuild(item: BuildItemDisplay): boolean {
if (this.myPlayer == null || this.playerActions == null) {
return false
}
return this.playerActions.buildableUnits.some(u => u == item.unitType)
static styles = css`
:host {
display: block;
}
.build-menu {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 9999;
background-color: #1e1e1e;
padding: 15px;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.5);
border-radius: 10px;
display: flex;
flex-direction: column;
align-items: center;
max-width: 95vw;
max-height: 95vh;
overflow-y: auto;
}
.build-row {
display: flex;
justify-content: center;
flex-wrap: wrap;
width: 100%;
}
.build-button {
width: 120px;
height: 120px;
border: 2px solid #444;
background-color: #2c2c2c;
color: white;
border-radius: 12px;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
margin: 8px;
padding: 10px;
}
.build-button:not(:disabled):hover {
background-color: #3a3a3a;
transform: scale(1.05);
border-color: #666;
}
.build-button:not(:disabled):active {
background-color: #4a4a4a;
transform: scale(0.95);
}
.build-button:disabled {
background-color: #1a1a1a;
border-color: #333;
cursor: not-allowed;
opacity: 0.7;
}
.build-button:disabled img {
opacity: 0.5;
}
.build-button:disabled .build-cost {
color: #ff4444;
}
.build-icon {
font-size: 40px;
margin-bottom: 5px;
}
.build-name {
font-size: 14px;
font-weight: bold;
margin-bottom: 5px;
text-align: center;
}
.build-cost {
font-size: 14px;
}
.hidden {
display: none !important;
}
public onBuildSelected = (item: BuildItemDisplay) => {
this.eventBus.emit(new BuildUnitIntentEvent(item.unitType, this.clickedCell))
this.hideMenu()
};
@media (max-width: 768px) {
.build-menu {
padding: 10px;
max-height: 80vh;
}
.build-button {
width: 90px;
height: 90px;
margin: 4px;
padding: 6px;
}
.build-icon {
font-size: 28px;
}
.build-name {
font-size: 12px;
margin-bottom: 3px;
}
.build-cost {
font-size: 11px;
}
.build-button img {
width: 32px;
height: 32px;
}
}
render() {
return html`
<div class="build-menu ${this._hidden ? 'hidden' : ''}">
${buildTable.map(row => html`
<div class="build-row">
${row.map(item => html`
<button
class="build-button"
@click=${() => this.onBuildSelected(item)}
?disabled=${!this.canBuild(item)}
title=${!this.canBuild(item) ? 'Not enough money' : ''}
>
<img src=${item.icon} alt="${item.unitType}" width="40" height="40">
<span class="build-name">${item.unitType}</span>
<span class="build-cost">
${renderNumber(this.game && this.myPlayer ? this.game.unitInfo(item.unitType).cost(this.myPlayer) : 0)}
<img src=${goldCoinIcon} alt="gold" width="12" height="12" style="vertical-align: middle;">
</span>
</button>
`)}
</div>
`)}
@media (max-width: 480px) {
.build-menu {
padding: 8px;
max-height: 70vh;
}
.build-button {
width: 70px;
height: 70px;
margin: 3px;
padding: 4px;
border-width: 1px;
}
.build-icon {
font-size: 24px;
}
.build-name {
font-size: 10px;
margin-bottom: 2px;
}
.build-cost {
font-size: 9px;
}
.build-button img {
width: 24px;
height: 24px;
}
.build-cost img {
width: 10px;
height: 10px;
}
}
`;
@state()
private _hidden = true;
private canBuild(item: BuildItemDisplay): boolean {
if (this.myPlayer == null || this.playerActions == null) {
return false;
}
return this.playerActions.buildableUnits.some((u) => u == item.unitType);
}
public onBuildSelected = (item: BuildItemDisplay) => {
this.eventBus.emit(
new BuildUnitIntentEvent(item.unitType, this.clickedCell),
);
this.hideMenu();
};
render() {
return html`
<div class="build-menu ${this._hidden ? "hidden" : ""}">
${buildTable.map(
(row) => html`
<div class="build-row">
${row.map(
(item) => html`
<button
class="build-button"
@click=${() => this.onBuildSelected(item)}
?disabled=${!this.canBuild(item)}
title=${!this.canBuild(item) ? "Not enough money" : ""}
>
<img
src=${item.icon}
alt="${item.unitType}"
width="40"
height="40"
/>
<span class="build-name">${item.unitType}</span>
<span class="build-cost">
${renderNumber(
this.game && this.myPlayer
? this.game
.unitInfo(item.unitType)
.cost(this.myPlayer)
: 0,
)}
<img
src=${goldCoinIcon}
alt="gold"
width="12"
height="12"
style="vertical-align: middle;"
/>
</span>
</button>
`,
)}
</div>
`;
}
`,
)}
</div>
`;
}
hideMenu() {
this._hidden = true;
hideMenu() {
this._hidden = true;
this.requestUpdate();
}
showMenu(player: PlayerView, clickedCell: Cell) {
player
.actions(this.game.ref(clickedCell.x, clickedCell.y))
.then((actions) => {
console.log(`got actions: ${JSON.stringify(actions)}`);
this.playerActions = actions;
this.myPlayer = player;
this.clickedCell = clickedCell;
this._hidden = false;
this.requestUpdate();
}
});
}
showMenu(player: PlayerView, clickedCell: Cell) {
player.actions(this.game.ref(clickedCell.x, clickedCell.y)).then(actions => {
console.log(`got actions: ${JSON.stringify(actions)}`)
this.playerActions = actions
this.myPlayer = player;
this.clickedCell = clickedCell;
this._hidden = false;
this.requestUpdate();
})
}
get isVisible() {
return !this._hidden;
}
}
get isVisible() {
return !this._hidden;
}
}
+121 -116
View File
@@ -1,129 +1,134 @@
import { LitElement, html, css } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import { LitElement, html, css } from "lit";
import { customElement, state } from "lit/decorators.js";
const emojiTable: string[][] = [
["😀", "😱", "🤡", "😡", "🥺"],
["😈", "👏", "🥉", "🥈", "🥇"],
["🤙", "🥰", "😇", "😊", "🔥"],
["💪", "🏳️", "💀", "😭", "🤦‍♂️"],
["😎", "👎", "👍", "🥱", "💔"],
["❤️", "💰", "🤝", "🖕", "💥"],
["🆘", "🕊️", "➡️", "⬅️", "↙️"],
["↖️", "↗️", "⬆️", "↘️", "⬇️"]
["😀", "😱", "🤡", "😡", "🥺"],
["😈", "👏", "🥉", "🥈", "🥇"],
["🤙", "🥰", "😇", "😊", "🔥"],
["💪", "🏳️", "💀", "😭", "🤦‍♂️"],
["😎", "👎", "👍", "🥱", "💔"],
["❤️", "💰", "🤝", "🖕", "💥"],
["🆘", "🕊️", "➡️", "⬅️", "↙️"],
["↖️", "↗️", "⬆️", "↘️", "⬇️"],
];
@customElement('emoji-table')
@customElement("emoji-table")
export class EmojiTable extends LitElement {
static styles = css`
:host {
display: block;
}
.emoji-table {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 9999;
background-color: #1E1E1E;
padding: 15px;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.5);
border-radius: 10px;
display: flex;
flex-direction: column;
align-items: center;
max-width: 95vw;
max-height: 95vh;
overflow-y: auto;
}
.emoji-row {
display: flex;
justify-content: center;
width: 100%;
}
.emoji-button {
font-size: 60px;
width: 80px;
height: 80px;
border: 1px solid #333;
background-color: #2C2C2C;
color: white;
border-radius: 12px;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
justify-content: center;
align-items: center;
margin: 8px;
}
.emoji-button:hover {
background-color: #3A3A3A;
transform: scale(1.1);
}
.emoji-button:active {
background-color: #4A4A4A;
transform: scale(0.95);
}
.hidden {
display: none !important;
}
@media (max-width: 600px) {
.emoji-button {
font-size: 32px;
/* Slightly smaller font size for mobile */
width: 60px;
/* Smaller width for mobile */
height: 60px;
/* Smaller height for mobile */
margin: 5px;
/* Smaller margin for mobile */
}
}
static styles = css`
:host {
display: block;
}
.emoji-table {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 9999;
background-color: #1e1e1e;
padding: 15px;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.5);
border-radius: 10px;
display: flex;
flex-direction: column;
align-items: center;
max-width: 95vw;
max-height: 95vh;
overflow-y: auto;
}
.emoji-row {
display: flex;
justify-content: center;
width: 100%;
}
.emoji-button {
font-size: 60px;
width: 80px;
height: 80px;
border: 1px solid #333;
background-color: #2c2c2c;
color: white;
border-radius: 12px;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
justify-content: center;
align-items: center;
margin: 8px;
}
.emoji-button:hover {
background-color: #3a3a3a;
transform: scale(1.1);
}
.emoji-button:active {
background-color: #4a4a4a;
transform: scale(0.95);
}
.hidden {
display: none !important;
}
@media (max-width: 400px) {
.emoji-button {
font-size: 28px;
width: 50px;
height: 50px;
margin: 3px;
}
}
`;
@media (max-width: 600px) {
.emoji-button {
font-size: 32px;
/* Slightly smaller font size for mobile */
width: 60px;
/* Smaller width for mobile */
height: 60px;
/* Smaller height for mobile */
margin: 5px;
/* Smaller margin for mobile */
}
}
@state()
private _hidden = true;
@media (max-width: 400px) {
.emoji-button {
font-size: 28px;
width: 50px;
height: 50px;
margin: 3px;
}
}
`;
public onEmojiClicked: (emoji: string) => void = () => { }
@state()
private _hidden = true;
render() {
return html`
<div class="emoji-table ${this._hidden ? 'hidden' : ''}">
${emojiTable.map(row => html`
<div class="emoji-row">
${row.map(emoji => html`
<button class="emoji-button" @click=${() => this.onEmojiClicked(emoji)}>
${emoji}
</button>
`)}
</div>
`)}
public onEmojiClicked: (emoji: string) => void = () => {};
render() {
return html`
<div class="emoji-table ${this._hidden ? "hidden" : ""}">
${emojiTable.map(
(row) => html`
<div class="emoji-row">
${row.map(
(emoji) => html`
<button
class="emoji-button"
@click=${() => this.onEmojiClicked(emoji)}
>
${emoji}
</button>
`,
)}
</div>
`;
}
`,
)}
</div>
`;
}
hideTable() {
this._hidden = true;
this.requestUpdate();
}
hideTable() {
this._hidden = true;
this.requestUpdate();
showTable() {
this._hidden = false;
this.requestUpdate();
}
}
showTable() {
this._hidden = false;
this.requestUpdate();
}
get isVisible() {
return !this._hidden;
}
}
get isVisible() {
return !this._hidden;
}
}
+11 -11
View File
@@ -97,7 +97,7 @@ export class RadialMenu implements Layer {
private emojiTable: EmojiTable,
private buildMenu: BuildMenu,
private uiState: UIState,
private playerInfoOverlay: PlayerInfoOverlay
private playerInfoOverlay: PlayerInfoOverlay,
) {}
init() {
@@ -106,7 +106,7 @@ export class RadialMenu implements Layer {
this.eventBus.on(ShowBuildMenuEvent, (e) => {
const clickedCell = this.transformHandler.screenToWorldCoordinates(
e.x,
e.y
e.y,
);
if (clickedCell == null) {
return;
@@ -139,7 +139,7 @@ export class RadialMenu implements Layer {
.append("g")
.attr(
"transform",
`translate(${this.menuSize / 2},${this.menuSize / 2})`
`translate(${this.menuSize / 2},${this.menuSize / 2})`,
);
const pie = d3
@@ -162,7 +162,7 @@ export class RadialMenu implements Layer {
.append("path")
.attr("d", arc)
.attr("fill", (d) =>
d.data.disabled ? this.disabledColor : d.data.color
d.data.disabled ? this.disabledColor : d.data.color,
)
.attr("stroke", "#ffffff")
.attr("stroke-width", "2")
@@ -287,7 +287,7 @@ export class RadialMenu implements Layer {
this.clickedCell = this.transformHandler.screenToWorldCoordinates(
event.x,
event.y
event.y,
);
if (!this.g.isValidCoord(this.clickedCell.x, this.clickedCell.y)) {
return;
@@ -316,7 +316,7 @@ export class RadialMenu implements Layer {
private handlePlayerActions(
myPlayer: PlayerView,
actions: PlayerActions,
tile: TileRef
tile: TileRef,
) {
this.activateMenuElement(Slot.Build, "#ebe250", buildIcon, () => {
this.buildMenu.showMenu(myPlayer, this.clickedCell);
@@ -348,8 +348,8 @@ export class RadialMenu implements Layer {
new SendBoatAttackIntentEvent(
this.g.owner(tile).id(),
this.clickedCell,
this.uiState.attackRatio * myPlayer.troops()
)
this.uiState.attackRatio * myPlayer.troops(),
),
);
});
}
@@ -426,8 +426,8 @@ export class RadialMenu implements Layer {
this.eventBus.emit(
new SendAttackIntentEvent(
this.g.owner(clicked).id(),
this.uiState.attackRatio * myPlayer.troops()
)
this.uiState.attackRatio * myPlayer.troops(),
),
);
}
}
@@ -438,7 +438,7 @@ export class RadialMenu implements Layer {
slot: Slot,
color: string,
icon: string,
action: () => void
action: () => void,
) {
const menuItem = this.menuItems.get(slot);
menuItem.action = action;
+1 -1
View File
@@ -1,4 +1,4 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en" class="h-full">
<head>
<meta charset="UTF-8" />