Merge branch 'main' into randmap

This commit is contained in:
Todd Groff
2025-03-02 16:29:57 -05:00
committed by GitHub
83 changed files with 2788 additions and 24367 deletions
+16 -1
View File
@@ -6,11 +6,16 @@ import { ClientID, GameConfig, GameID, ServerMessage } from "../core/Schemas";
import { loadTerrainMap } from "../core/game/TerrainMapLoader";
import {
SendAttackIntentEvent,
SendHashEvent,
SendSpawnIntentEvent,
Transport,
} from "./Transport";
import { createCanvas } from "./Utils";
import { ErrorUpdate } from "../core/game/GameUpdates";
import {
ErrorUpdate,
GameUpdateType,
HashUpdate,
} from "../core/game/GameUpdates";
import { WorkerClient } from "../core/worker/WorkerClient";
import { consolex, initRemoteSender } from "../core/Consolex";
import { getConfig, getServerConfig } from "../core/configuration/Config";
@@ -171,6 +176,9 @@ export class ClientGameRunner {
showErrorModal(gu.errMsg, gu.stack, this.clientID);
return;
}
gu.updates[GameUpdateType.Hash].forEach((hu: HashUpdate) => {
this.eventBus.emit(new SendHashEvent(hu.tick, hu.hash));
});
this.gameView.update(gu);
this.renderer.tick();
});
@@ -205,6 +213,13 @@ export class ClientGameRunner {
this.turnsSeen++;
}
}
if (message.type == "desync") {
showErrorModal(
`desync from server: ${JSON.stringify(message)}`,
"",
this.clientID,
);
}
if (message.type == "turn") {
if (!this.hasJoined) {
this.transport.joinGame(0);
+10 -7
View File
@@ -1,8 +1,7 @@
import { LitElement, html, css } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import Countries from "./data/countries.json";
import { UserSettings } from "../core/game/UserSettings";
import { ModalOverlay } from "./components/ModalOverlay";
const flagKey: string = "flag";
@customElement("flag-input")
@@ -10,7 +9,6 @@ export class FlagInput extends LitElement {
@state() private flag: string = "";
@state() private search: string = "";
@state() private showModal: boolean = false;
private userSettings: UserSettings = new UserSettings();
static styles = css`
@media (max-width: 768px) {
@@ -29,11 +27,10 @@ export class FlagInput extends LitElement {
}
private setFlag(flag: string) {
if (flag == "") {
this.flag = "";
} else {
this.flag = flag;
if (flag == "xx") {
flag = "";
}
this.flag = flag;
this.showModal = false;
this.storeFlag(flag);
}
@@ -80,6 +77,12 @@ export class FlagInput extends LitElement {
render() {
return html`
<div
class="absolute left-0 top-0 w-full h-full ${this.showModal
? ""
: "hidden"}"
@click=${() => (this.showModal = false)}
></div>
<div class="flex relative">
<button
@click=${() => (this.showModal = !this.showModal)}
+42
View File
@@ -0,0 +1,42 @@
import { LitElement, html, css } from "lit";
import { customElement } from "lit/decorators.js";
@customElement("google-ad")
export class GoogleAdElement extends LitElement {
createRenderRoot() {
return this;
}
static styles = css`
.google-ad-container {
margin-top: 1rem;
background-color: rgba(255, 255, 255, 0.1);
border-radius: 0.5rem;
padding: 0.5rem;
width: 100%;
overflow: hidden;
}
.dark .google-ad-container {
background-color: rgba(0, 0, 0, 0.2);
}
`;
render() {
return html`
<div class="google-ad-container">
<ins
class="adsbygoogle"
style="display:block"
data-ad-client="ca-pub-7035513310742290"
data-ad-slot="rightsidebar"
data-ad-format="auto"
data-full-width-responsive="true"
></ins>
<script>
(adsbygoogle = window.adsbygoogle || []).push({});
</script>
</div>
`;
}
}
+24 -4
View File
@@ -1,5 +1,5 @@
import { LitElement, html, css } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { LitElement, css, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import "./components/Difficulties";
import "./components/Maps";
@@ -207,11 +207,17 @@ export class HelpModal extends LitElement {
class="modal-overlay"
style="display: ${this.isModalOpen ? "flex" : "none"}"
>
<div
class="absolute left-0 top-0 w-full h-full ${
this.isModalOpen ? "" : "hidden"
}"
@click=${this.close}
></div>
<div class="modal-content">
<span class="close" @click=${this.close}>&times;</span>
<div class="flex flex-col items-center">
<div class="text-center text-2xl font-bold mb-4">Keybinds</div>
<div class="text-center text-2xl font-bold mb-4">Hotkeys</div>
<table>
<thead>
<tr>
@@ -220,10 +226,18 @@ export class HelpModal extends LitElement {
</tr>
</thead>
<tbody class="text-left">
<tr>
<td>CTRL + Left Click</td>
<td>Open build menu</td>
</tr>
<tr>
<td>Space</td>
<td>Alternate view</td>
</tr>
<tr>
<td>C</td>
<td>Center camera on player</td>
</tr>
<tr>
<td>Q / E</td>
<td>Zoom out/in</td>
@@ -236,6 +250,10 @@ export class HelpModal extends LitElement {
<td>1 / 2</td>
<td>Decrease/Increase attack ratio</td>
</tr>
<tr>
<td>Shift + scroll down / scroll up</td>
<td>Decrease/Increase attack ratio</td>
</tr>
<tr>
<td>ALT + R</td>
<td>Reset graphics</td>
@@ -303,7 +321,8 @@ export class HelpModal extends LitElement {
<p class="mb-4">Right clicking (or touch on mobile) opens the radial menu. From there you can:</p>
<ul>
<li class="mb-4"><div class="inline-block icon build-icon"></div> - Open the build menu.</li>
<li class="mb-4"><div class="inline-block icon info-icon"></div> - Open the Info menu.</li>
<li class="mb-4">
<img src="/images/InfoIcon.svg" class="inline-block icon" style="fill: white; background: transparent;"/> - Open the Info menu.</li>
<li class="mb-4"><div class="inline-block icon boat-icon"></div> - Send a boat to attack at the selected location (only available if you have access to water).</li>
<li class="mb-4"><div class="inline-block icon cancel-icon"></div> - Close the menu.</li>
</ul>
@@ -455,5 +474,6 @@ export class HelpModal extends LitElement {
public close() {
this.isModalOpen = false;
console.log("closing modal");
}
}
+61 -45
View File
@@ -1,13 +1,16 @@
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 { GameConfig, GameInfo } from "../core/Schemas";
import { consolex } from "../core/Consolex";
import "./components/Difficulties";
import { DifficultyDescription } from "./components/Difficulties";
import "./components/Maps";
import { generateID } from "../core/Util";
import { getConfig, getServerConfig } from "../core/configuration/Config";
import randomMap from "../../resources/images/RandomMap.png";
@customElement("host-lobby-modal")
export class HostLobbyModal extends LitElement {
@state() private isModalOpen = false;
@@ -323,6 +326,11 @@ export class HostLobbyModal extends LitElement {
class="modal-overlay"
style="display: ${this.isModalOpen ? "flex" : "none"}"
>
<div
style="position: absolute; left: 0px; top: 0px; width: 100%; height: 100%;"
class="${this.isModalOpen ? "" : "hidden"}"
@click=${this.close}
></div>
<div class="modal-content">
<span class="close" @click=${this.close}>&times;</span>
@@ -430,7 +438,7 @@ export class HostLobbyModal extends LitElement {
step="1"
@input=${this.handleBotsChange}
@change=${this.handleBotsChange}
.value="${this.bots}"
.value="${String(this.bots)}"
/>
<div class="option-card-title">
Bots: ${this.bots == 0 ? "Disabled" : this.bots}
@@ -526,7 +534,7 @@ export class HostLobbyModal extends LitElement {
public open() {
createLobby()
.then((lobby) => {
this.lobbyId = lobby.id;
this.lobbyId = lobby.gameID;
// join lobby
})
.then(() => {
@@ -535,7 +543,7 @@ export class HostLobbyModal extends LitElement {
detail: {
gameType: GameType.Private,
lobby: {
id: this.lobbyId,
gameID: this.lobbyId,
},
map: this.selectedMap,
difficulty: this.selectedDifficulty,
@@ -604,21 +612,24 @@ export class HostLobbyModal extends LitElement {
}
private async putGameConfig() {
const response = await fetch(`/private_lobby/${this.lobbyId}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
const response = await fetch(
`${window.location.origin}/${getServerConfig().workerPath(this.lobbyId)}/game/${this.lobbyId}`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
gameMap: this.selectedMap,
difficulty: this.selectedDifficulty,
disableNPCs: this.disableNPCs,
bots: this.bots,
infiniteGold: this.infiniteGold,
infiniteTroops: this.infiniteTroops,
instantBuild: this.instantBuild,
} as GameConfig),
},
body: JSON.stringify({
gameMap: this.selectedMap,
difficulty: this.selectedDifficulty,
disableNPCs: this.disableNPCs,
bots: this.bots,
infiniteGold: this.infiniteGold,
infiniteTroops: this.infiniteTroops,
instantBuild: this.instantBuild,
}),
});
);
}
private getRandomMap(): GameMapType {
@@ -637,12 +648,15 @@ export class HostLobbyModal extends LitElement {
`Starting private game with map: ${GameMapType[this.selectedMap]} ${this.useRandomMap ? " (Randomly selected)" : ""}`,
);
this.close();
const response = await fetch(`/start_private_lobby/${this.lobbyId}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
const response = await fetch(
`${window.location.origin}/${getServerConfig().workerPath(this.lobbyId)}/start_game/${this.lobbyId}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
},
});
);
}
private async copyToClipboard() {
@@ -656,34 +670,42 @@ 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",
headers: {
"Content-Type": "application/json",
fetch(
`/${getServerConfig().workerPath(this.lobbyId)}/game/${this.lobbyId}`,
{
method: "GET",
headers: {
"Content-Type": "application/json",
},
},
})
)
.then((response) => response.json())
.then((data) => {
.then((data: GameInfo) => {
console.log(`got response: ${data}`);
this.players = data.players.map((p) => p.username);
this.players = data.clients.map((p) => p.username);
});
}
}
async function createLobby(): Promise<Lobby> {
async function createLobby(): Promise<GameInfo> {
const serverConfig = getServerConfig();
try {
const response = await fetch("/private_lobby", {
method: "POST",
headers: {
"Content-Type": "application/json",
const id = generateID();
const response = await fetch(
`/${serverConfig.workerPath(id)}/create_game/${id}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
// body: JSON.stringify(data), // Include this if you need to send data
},
// body: JSON.stringify(data), // Include this if you need to send data
});
);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
@@ -692,13 +714,7 @@ async function createLobby(): Promise<Lobby> {
const data = await response.json();
consolex.log("Success:", data);
// Assuming the server returns an object with an 'id' property
const lobby: Lobby = {
id: data.id,
// Add other properties as needed
};
return lobby;
return data as GameInfo;
} catch (error) {
consolex.error("Error creating lobby:", error);
throw error; // Re-throw the error so the caller can handle it
+24 -2
View File
@@ -1,5 +1,4 @@
import { EventBus, GameEvent } from "../core/EventBus";
import { Game } from "../core/game/Game";
export class MouseUpEvent implements GameEvent {
constructor(
@@ -63,6 +62,10 @@ export class AttackRatioEvent implements GameEvent {
constructor(public readonly attackRatio: number) {}
}
export class CenterCameraEvent implements GameEvent {
constructor() {}
}
export class InputHandler {
private lastPointerX: number = 0;
private lastPointerY: number = 0;
@@ -95,6 +98,9 @@ export class InputHandler {
this.canvas.addEventListener("wheel", (e) => this.onScroll(e), {
passive: false,
});
this.canvas.addEventListener("wheel", (e) => this.onShiftScroll(e), {
passive: false,
});
window.addEventListener("pointermove", this.onPointerMove.bind(this));
this.canvas.addEventListener("contextmenu", (e: MouseEvent) => {
this.onContextMenu(e);
@@ -173,6 +179,7 @@ export class InputHandler {
"KeyQ",
"Digit1",
"Digit2",
"KeyC",
].includes(e.code)
) {
this.activeKeys.add(e.code);
@@ -200,6 +207,11 @@ export class InputHandler {
this.eventBus.emit(new AttackRatioEvent(10));
}
if (e.code === "KeyC") {
e.preventDefault();
this.eventBus.emit(new CenterCameraEvent());
}
// Remove all movement keys from activeKeys
if (
[
@@ -217,6 +229,7 @@ export class InputHandler {
"KeyQ",
"Digit1",
"Digit2",
"KeyC",
].includes(e.code)
) {
this.activeKeys.delete(e.code);
@@ -272,7 +285,16 @@ export class InputHandler {
}
private onScroll(event: WheelEvent) {
this.eventBus.emit(new ZoomEvent(event.x, event.y, event.deltaY));
if (!event.shiftKey) {
this.eventBus.emit(new ZoomEvent(event.x, event.y, event.deltaY));
}
}
private onShiftScroll(event: WheelEvent) {
if (event.shiftKey) {
const ratio = event.deltaY > 0 ? -10 : 10;
this.eventBus.emit(new AttackRatioEvent(ratio));
}
}
private onPointerMove(event: PointerEvent) {
+26 -13
View File
@@ -1,7 +1,9 @@
import { LitElement, html, css } from "lit";
import { customElement, property, state, query } from "lit/decorators.js";
import { GameMapType, GameType } from "../core/game/Game";
import { LitElement, css, html } from "lit";
import { customElement, query, state } from "lit/decorators.js";
import { getServerConfig } from "../core/configuration/Config";
import { consolex } from "../core/Consolex";
import { GameMapType, GameType } from "../core/game/Game";
import { GameInfo } from "../core/Schemas";
@customElement("join-private-lobby-modal")
export class JoinPrivateLobbyModal extends LitElement {
@@ -231,6 +233,11 @@ export class JoinPrivateLobbyModal extends LitElement {
class="modal-overlay"
style="display: ${this.isModalOpen ? "flex" : "none"}"
>
<div
style="position: absolute; left: 0px; top: 0px; width: 100%; height: 100%;"
class="${this.isModalOpen ? "" : "hidden"}"
@click=${this.close}
></div>
<div class="modal-content">
<span class="close" @click=${this.closeAndLeave}>&times;</span>
<div class="title">Join Private Lobby</div>
@@ -358,13 +365,16 @@ export class JoinPrivateLobbyModal extends LitElement {
consolex.log(`Joining lobby with ID: ${lobbyId}`);
this.message = "Checking lobby..."; // Set initial message
fetch(`/lobby/${lobbyId}/exists`, {
const url = `/${getServerConfig().workerPath(lobbyId)}/game/${lobbyId}/exists`;
fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
})
.then((response) => response.json())
.then((response) => {
return response.json();
})
.then((data) => {
if (data.exists) {
this.message = "Joined successfully! Waiting for game to start...";
@@ -372,7 +382,7 @@ export class JoinPrivateLobbyModal extends LitElement {
this.dispatchEvent(
new CustomEvent("join-lobby", {
detail: {
lobby: { id: lobbyId },
lobby: { gameID: lobbyId },
gameType: GameType.Private,
map: GameMapType.World,
},
@@ -394,15 +404,18 @@ export class JoinPrivateLobbyModal extends LitElement {
private async pollPlayers() {
if (!this.lobbyIdInput?.value) return;
fetch(`/lobby/${this.lobbyIdInput.value}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
fetch(
`/${getServerConfig().workerPath(this.lobbyIdInput.value)}/game/${this.lobbyIdInput.value}`,
{
method: "GET",
headers: {
"Content-Type": "application/json",
},
},
})
)
.then((response) => response.json())
.then((data) => {
this.players = data.players.map((p) => p.username);
.then((data: GameInfo) => {
this.players = data.clients.map((p) => p.username);
})
.catch((error) => {
consolex.error("Error polling players:", error);
+8 -2
View File
@@ -1,4 +1,9 @@
import { Config, GameEnv, ServerConfig } from "../core/configuration/Config";
import {
Config,
GameEnv,
getServerConfig,
ServerConfig,
} from "../core/configuration/Config";
import { consolex } from "../core/Consolex";
import { GameEvent } from "../core/EventBus";
import {
@@ -125,6 +130,7 @@ export class LocalServer {
const blob = new Blob([JSON.stringify(GameRecordSchema.parse(record))], {
type: "application/json",
});
navigator.sendBeacon("/archive_singleplayer_game", blob);
const workerPath = getServerConfig().workerPath(this.lobbyConfig.gameID);
navigator.sendBeacon(`/${workerPath}/archive_singleplayer_game`, blob);
}
}
+9 -13
View File
@@ -17,6 +17,7 @@ import { PublicLobby } from "./PublicLobby";
import { UserSettings } from "../core/game/UserSettings";
import "./DarkModeButton";
import { DarkModeButton } from "./DarkModeButton";
import "./GoogleAdElement";
import { HelpModal } from "./HelpModal";
import { GameType } from "../core/game/Game";
@@ -65,10 +66,6 @@ class Client {
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",
@@ -112,9 +109,9 @@ class Client {
});
if (this.userSettings.darkMode()) {
document.body.classList.add("dark");
document.documentElement.classList.add("dark");
} else {
document.body.classList.remove("dark");
document.documentElement.classList.remove("dark");
}
page("/join/:lobbyId", (ctx) => {
if (ctx.init && sessionStorage.getItem("inLobby")) {
@@ -143,9 +140,12 @@ class Client {
this.gameStop = joinLobby(
{
gameType: gameType,
flag: (): string => this.flagInput.getCurrentFlag(),
flag: (): string =>
this.flagInput.getCurrentFlag() == "xx"
? ""
: this.flagInput.getCurrentFlag(),
playerName: (): string => this.usernameInput.getCurrentUsername(),
gameID: lobby.id,
gameID: lobby.gameID,
persistentID: getPersistentIDFromCookie(),
playerID: generateID(),
clientID: generateID(),
@@ -161,7 +161,7 @@ class Client {
this.joinModal.close();
this.publicLobby.stop();
if (gameType != GameType.Singleplayer) {
window.history.pushState({}, "", `/join/${lobby.id}`);
window.history.pushState({}, "", `/join/${lobby.gameID}`);
sessionStorage.setItem("inLobby", "true");
}
},
@@ -177,10 +177,6 @@ class Client {
this.gameStop = null;
this.publicLobby.leaveLobby();
}
private async handleSinglePlayer(event: CustomEvent) {
alert("coming soon");
}
}
// Initialize the client when the DOM is loaded
+10 -7
View File
@@ -1,16 +1,16 @@
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 { getMapsImage } from "./utilities/Maps";
import { GameInfo } from "../core/Schemas";
@customElement("public-lobby")
export class PublicLobby extends LitElement {
@state() private lobbies: Lobby[] = [];
@state() private lobbies: GameInfo[] = [];
@state() public isLobbyHighlighted: boolean = false;
private lobbiesInterval: number | null = null;
private currLobby: Lobby = null;
private currLobby: GameInfo = null;
createRenderRoot() {
return this;
@@ -42,9 +42,9 @@ export class PublicLobby extends LitElement {
}
}
async fetchLobbies(): Promise<Lobby[]> {
async fetchLobbies(): Promise<GameInfo[]> {
try {
const response = await fetch("/lobbies");
const response = await fetch(`/public_lobbies`);
if (!response.ok)
throw new Error(`HTTP error! status: ${response.status}`);
const data = await response.json();
@@ -67,6 +67,9 @@ export class PublicLobby extends LitElement {
if (this.lobbies.length === 0) return html``;
const lobby = this.lobbies[0];
if (!lobby?.gameConfig) {
return;
}
const timeRemaining = Math.max(0, Math.floor(lobby.msUntilStart / 1000));
// Format time to show minutes and seconds
@@ -81,7 +84,7 @@ export class PublicLobby extends LitElement {
? "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="text-lg md:text-2xl font-semibold mb-2">Join next Game</div>
<div class="flex">
<img
src="${getMapsImage(lobby.gameConfig.gameMap)}"
@@ -121,7 +124,7 @@ export class PublicLobby extends LitElement {
this.currLobby = null;
}
private lobbyClicked(lobby: Lobby) {
private lobbyClicked(lobby: GameInfo) {
if (this.currLobby == null) {
this.isLobbyHighlighted = true;
this.currLobby = lobby;
+6 -1
View File
@@ -256,6 +256,11 @@ export class SinglePlayerModal extends LitElement {
class="modal-overlay"
style="display: ${this.isModalOpen ? "flex" : "none"}"
>
<div
style="position: absolute; left: 0px; top: 0px; width: 100%; height: 100%;"
class="${this.isModalOpen ? "" : "hidden"}"
@click=${this.close}
></div>
<div class="modal-content">
<span class="close" @click=${this.close}>&times;</span>
@@ -476,7 +481,7 @@ export class SinglePlayerModal extends LitElement {
detail: {
gameType: GameType.Singleplayer,
lobby: {
id: generateID(),
gameID: generateID(),
},
map: this.selectedMap,
difficulty: this.selectedDifficulty,
+56 -3
View File
@@ -9,6 +9,7 @@ import {
Player,
PlayerID,
PlayerType,
Tick,
UnitType,
} from "../core/game/Game";
import {
@@ -23,6 +24,7 @@ import {
GameConfig,
ClientLogMessageSchema,
ClientSendWinnerSchema,
ClientMessageSchema,
} from "../core/Schemas";
import { LobbyConfig } from "./ClientGameRunner";
import { LocalServer } from "./LocalServer";
@@ -100,6 +102,14 @@ export class SendDonateIntentEvent implements GameEvent {
) {}
}
export class SendEmbargoIntentEvent implements GameEvent {
constructor(
public readonly sender: PlayerView,
public readonly target: PlayerView,
public readonly action: "start" | "stop",
) {}
}
export class SendSetTargetTroopRatioEvent implements GameEvent {
constructor(public readonly ratio: number) {}
}
@@ -107,6 +117,12 @@ export class SendSetTargetTroopRatioEvent implements GameEvent {
export class SendWinnerEvent implements GameEvent {
constructor(public readonly winner: ClientID) {}
}
export class SendHashEvent implements GameEvent {
constructor(
public readonly tick: Tick,
public readonly hash: number,
) {}
}
export class Transport {
private socket: WebSocket;
@@ -151,6 +167,9 @@ export class Transport {
);
this.eventBus.on(SendEmojiIntentEvent, (e) => this.onSendEmojiIntent(e));
this.eventBus.on(SendDonateIntentEvent, (e) => this.onSendDonateIntent(e));
this.eventBus.on(SendEmbargoIntentEvent, (e) =>
this.onSendEmbargoIntent(e),
);
this.eventBus.on(SendSetTargetTroopRatioEvent, (e) =>
this.onSendSetTargetTroopRatioEvent(e),
);
@@ -159,6 +178,7 @@ export class Transport {
this.eventBus.on(SendLogEvent, (e) => this.onSendLogEvent(e));
this.eventBus.on(PauseGameEvent, (e) => this.onPauseGameEvent(e));
this.eventBus.on(SendWinnerEvent, (e) => this.onSendWinnerEvent(e));
this.eventBus.on(SendHashEvent, (e) => this.onSendHashEvent(e));
}
private startPing() {
@@ -219,9 +239,10 @@ export class Transport {
) {
this.startPing();
this.maybeKillSocket();
const wsHost = process.env.WEBSOCKET_URL || window.location.host;
const wsHost = window.location.host;
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
this.socket = new WebSocket(`${wsProtocol}//${wsHost}`);
const workerPath = this.serverConfig.workerPath(this.lobbyConfig.gameID);
this.socket = new WebSocket(`${wsProtocol}//${wsHost}/${workerPath}`);
this.onconnect = onconnect;
this.onmessage = onmessage;
this.socket.onopen = () => {
@@ -237,7 +258,9 @@ export class Transport {
const serverMsg = ServerMessageSchema.parse(JSON.parse(event.data));
this.onmessage(serverMsg);
} catch (error) {
console.error("Failed to process server message:", error);
console.error(
`Failed to process server message ${event.data}: ${error}`,
);
}
};
this.socket.onerror = (err) => {
@@ -397,6 +420,16 @@ export class Transport {
});
}
private onSendEmbargoIntent(event: SendEmbargoIntentEvent) {
this.sendIntent({
type: "embargo",
clientID: this.lobbyConfig.clientID,
playerID: this.lobbyConfig.playerID,
targetID: event.target.id(),
action: event.action,
});
}
private onSendSetTargetTroopRatioEvent(event: SendSetTargetTroopRatioEvent) {
this.sendIntent({
type: "troop_ratio",
@@ -448,6 +481,26 @@ export class Transport {
}
}
private onSendHashEvent(event: SendHashEvent) {
if (this.isLocal || this.socket.readyState === WebSocket.OPEN) {
const msg = ClientMessageSchema.parse({
type: "hash",
clientID: this.lobbyConfig.clientID,
persistentID: this.lobbyConfig.persistentID,
gameID: this.lobbyConfig.gameID,
tick: event.tick,
hash: event.hash,
});
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({
+26
View File
@@ -0,0 +1,26 @@
import { LitElement, html, css } from "lit";
import { customElement, property } from "lit/decorators";
@customElement("modal-overlay")
export class ModalOverlay extends LitElement {
@property({ reflect: true }) public visible: boolean = false;
static styles = css`
.overlay {
position: absolute;
left: 0px;
top: 0px;
width: 100%;
height: 100%;
}
`;
render() {
return html`
<div
class="overlay ${this.visible ? "" : "hidden"}"
@click=${() => (this.visible = false)}
></div>
`;
}
}
+10 -1
View File
@@ -5,7 +5,7 @@ import {
calculateBoundingBox,
calculateBoundingBoxCenter,
} from "../../core/Util";
import { ZoomEvent, DragEvent } from "../InputHandler";
import { ZoomEvent, DragEvent, CenterCameraEvent } from "../InputHandler";
import { GoToPlayerEvent } from "./layers/Leaderboard";
import { placeName } from "./NameBoxCalculator";
import { GameView } from "../../core/game/GameView";
@@ -27,6 +27,7 @@ export class TransformHandler {
this.eventBus.on(ZoomEvent, (e) => this.onZoom(e));
this.eventBus.on(DragEvent, (e) => this.onMove(e));
this.eventBus.on(GoToPlayerEvent, (e) => this.onGoToPlayer(e));
this.eventBus.on(CenterCameraEvent, () => this.centerCamera());
}
boundingRect(): DOMRect {
@@ -148,6 +149,14 @@ export class TransformHandler {
this.intervalID = setInterval(() => this.goTo(), 1);
}
centerCamera() {
this.clearTarget();
const player = this.game.myPlayer();
if (!player || !player.nameLocation()) return;
this.target = new Cell(player.nameLocation().x, player.nameLocation().y);
this.intervalID = setInterval(() => this.goTo(), 1);
}
private goTo() {
const { screenX, screenY } = this.screenCenter();
const screenMapCenter = new Cell(screenX, screenY);
+1 -1
View File
@@ -190,7 +190,7 @@ export class ControlPanel extends LitElement implements Layer {
</style>
<div
class="${this._isVisible
? "w-full text-sm lg:text-m lg:w-72 bg-gray-800/70 p-2 pr-3 lg:p-4 shadow-lg rounded-lg backdrop-blur"
? "w-full text-sm lg:text-m lg:w-72 bg-gray-800/70 p-2 pr-3 lg:p-4 shadow-lg lg:rounded-lg backdrop-blur"
: "hidden"}"
@contextmenu=${(e) => e.preventDefault()}
>
+36 -6
View File
@@ -25,6 +25,7 @@ import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { onlyImages, sanitize } from "../../../core/Util";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { renderTroops } from "../../Utils";
import { GoToPlayerEvent } from "./Leaderboard";
interface Event {
description: string;
@@ -33,6 +34,7 @@ interface Event {
text: string;
className: string;
action: () => void;
preventClose?: boolean;
}[];
type: MessageType;
highlight?: boolean;
@@ -53,6 +55,15 @@ export class EventsDisplay extends LitElement implements Layer {
@state() private incomingAttacks: AttackUpdate[] = [];
@state() private outgoingAttacks: AttackUpdate[] = [];
@state() private _hidden: boolean = false;
@state() private newEvents: number = 0;
private toggleHidden() {
this._hidden = !this._hidden;
if (this._hidden) {
this.newEvents = 0;
}
this.requestUpdate();
}
private updateMap = new Map([
[GameUpdateType.DisplayEvent, (u) => this.onDisplayMessageEvent(u)],
@@ -63,7 +74,7 @@ export class EventsDisplay extends LitElement implements Layer {
],
[GameUpdateType.BrokeAlliance, (u) => this.onBrokeAllianceEvent(u)],
[GameUpdateType.TargetPlayer, (u) => this.onTargetPlayerEvent(u)],
[GameUpdateType.EmojiUpdate, (u) => this.onEmojiMessageEvent(u)],
[GameUpdateType.Emoji, (u) => this.onEmojiMessageEvent(u)],
]);
constructor() {
@@ -119,6 +130,9 @@ export class EventsDisplay extends LitElement implements Layer {
private addEvent(event: Event) {
this.events = [...this.events, event];
if (this._hidden == true) {
this.newEvents++;
}
this.requestUpdate();
}
@@ -169,6 +183,12 @@ export class EventsDisplay extends LitElement implements Layer {
this.addEvent({
description: `${requestor.name()} requests an alliance!`,
buttons: [
{
text: "Focus",
className: "btn-gray",
action: () => this.eventBus.emit(new GoToPlayerEvent(requestor)),
preventClose: true,
},
{
text: "Accept",
className: "btn",
@@ -397,7 +417,7 @@ export class EventsDisplay extends LitElement implements Layer {
<div
class="${this._hidden
? "w-fit px-[10px] py-[5px]"
: ""} rounded-md bg-black bg-opacity-60 relative max-h-[30vh] flex flex-col-reverse overflow-y-auto w-full lg:bottom-2.5 lg:right-2.5 z-50 lg:max-w-3xl lg:w-full lg:w-auto"
: ""} rounded-md bg-black bg-opacity-60 relative max-h-[30vh] flex flex-col-reverse overflow-y-auto w-full lg:bottom-2.5 lg:right-2.5 z-50 lg:max-w-[30vw] lg:w-full lg:w-auto"
>
<div>
<div class="w-full bg-black/80 sticky top-0 px-[10px]">
@@ -406,7 +426,7 @@ export class EventsDisplay extends LitElement implements Layer {
._hidden
? "hidden"
: ""}"
@click=${() => (this._hidden = true)}
@click=${this.toggleHidden}
>
Hide
</button>
@@ -415,9 +435,15 @@ export class EventsDisplay extends LitElement implements Layer {
class="text-white cursor-pointer pointer-events-auto ${this._hidden
? ""
: "hidden"}"
@click=${() => (this._hidden = false)}
@click=${this.toggleHidden}
>
Events
<span
class="${this.newEvents
? ""
: "hidden"} inline-block px-2 bg-red-500 rounded-sm"
>${this.newEvents}</span
>
</button>
<table
class="w-full border-collapse text-white shadow-lg lg:text-xl text-xs ${this
@@ -447,10 +473,14 @@ export class EventsDisplay extends LitElement implements Layer {
class="inline-block px-3 py-1 text-white rounded text-sm cursor-pointer transition-colors duration-300
${btn.className.includes("btn-info")
? "bg-blue-500 hover:bg-blue-600"
: "bg-green-600 hover:bg-green-700"}"
: btn.className.includes("btn-gray")
? "bg-gray-500 hover:bg-gray-600"
: "bg-green-600 hover:bg-green-700"}"
@click=${() => {
btn.action();
this.removeEvent(index);
if (!btn.preventClose) {
this.removeEvent(index);
}
this.requestUpdate();
}}
>
+60 -24
View File
@@ -1,17 +1,18 @@
import { LitElement, html, css } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { Layer } from "./Layer";
import { ClientID } from "../../../core/Schemas";
import { LitElement, css, html } from "lit";
import { customElement, state } from "lit/decorators.js";
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 { ClientID } from "../../../core/Schemas";
import { renderNumber } from "../../Utils";
import { Layer } from "./Layer";
interface Entry {
name: string;
position: number;
score: string;
gold: string;
troops: string;
isMyPlayer: boolean;
player: PlayerView;
}
@@ -26,6 +27,13 @@ export class Leaderboard extends LitElement implements Layer {
public clientID: ClientID;
public eventBus: EventBus;
players: Entry[] = [];
@state()
private _leaderboardHidden = true;
private _shownOnInit = false;
private showTopFive = true;
init() {}
tick() {
@@ -58,14 +66,25 @@ export class Leaderboard extends LitElement implements Layer {
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,
}));
const playersToShow = this.showTopFive ? sorted.slice(0, 5) : sorted;
this.players = playersToShow.map((player, index) => {
let troops = player.troops() / 10;
if (!player.isAlive()) {
troops = 0;
}
return {
name: player.displayName(),
position: index + 1,
score: formatPercentage(
player.numTilesOwned() / numTilesWithoutFallout,
),
gold: renderNumber(player.gold()),
troops: renderNumber(troops),
isMyPlayer: player == myPlayer,
player: player,
};
});
if (myPlayer != null && this.players.find((p) => p.isMyPlayer) == null) {
let place = 0;
@@ -76,6 +95,10 @@ export class Leaderboard extends LitElement implements Layer {
}
}
let myPlayerTroops = myPlayer.troops() / 10;
if (!myPlayer.isAlive()) {
myPlayerTroops = 0;
}
this.players.pop();
this.players.push({
name: myPlayer.displayName(),
@@ -84,6 +107,7 @@ export class Leaderboard extends LitElement implements Layer {
myPlayer.numTilesOwned() / this.game.numLandTiles(),
),
gold: renderNumber(myPlayer.gold()),
troops: renderNumber(myPlayerTroops),
isMyPlayer: true,
player: myPlayer,
});
@@ -119,10 +143,10 @@ export class Leaderboard extends LitElement implements Layer {
padding-top: 0px;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.5);
border-radius: 10px;
max-width: 300px;
max-height: 80vh;
max-width: 500px;
max-height: 30vh;
overflow-y: auto;
width: 300px;
width: 400px;
backdrop-filter: blur(5px);
}
table {
@@ -180,6 +204,13 @@ export class Leaderboard extends LitElement implements Layer {
cursor: pointer;
}
.leaderboard-top-five-button {
background: none;
border: none;
color: white;
cursor: pointer;
}
.player-name {
max-width: 10ch;
overflow: hidden;
@@ -188,7 +219,8 @@ export class Leaderboard extends LitElement implements Layer {
@media (max-width: 1000px) {
.leaderboard {
top: 60px;
top: 70px;
left: 0px;
}
.leaderboard-button {
@@ -198,13 +230,6 @@ export class Leaderboard extends LitElement implements Layer {
}
`;
players: Entry[] = [];
@state()
private _leaderboardHidden = true;
private _shownOnInit = false;
render() {
return html`
<button
@@ -225,6 +250,15 @@ export class Leaderboard extends LitElement implements Layer {
>
Hide
</button>
<button
class="leaderboard-top-five-button"
@click=${() => {
this.showTopFive = !this.showTopFive;
this.updateLeaderboard();
}}
>
${this.showTopFive ? "Show All" : "Show Top 5"}
</button>
<table>
<thead>
<tr>
@@ -232,6 +266,7 @@ export class Leaderboard extends LitElement implements Layer {
<th>Player</th>
<th>Owned</th>
<th>Gold</th>
<th>Troops</th>
</tr>
</thead>
<tbody>
@@ -245,6 +280,7 @@ export class Leaderboard extends LitElement implements Layer {
<td class="player-name">${unsafeHTML(player.name)}</td>
<td>${player.score}</td>
<td>${player.gold}</td>
<td>${player.troops}</td>
</tr>
`,
)}
+44
View File
@@ -11,8 +11,10 @@ import { Layer } from "./Layer";
import { TransformHandler } from "../TransformHandler";
import traitorIcon from "../../../../resources/images/TraitorIcon.svg";
import allianceIcon from "../../../../resources/images/AllianceIcon.svg";
import allianceRequestIcon from "../../../../resources/images/AllianceRequestIcon.svg";
import crownIcon from "../../../../resources/images/CrownIcon.svg";
import targetIcon from "../../../../resources/images/TargetIcon.svg";
import embargoIcon from "../../../../resources/images/EmbargoIcon.svg";
import { ClientID } from "../../../core/Schemas";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { createCanvas, renderTroops } from "../../Utils";
@@ -40,9 +42,11 @@ export class NameLayer implements Layer {
private renders: RenderInfo[] = [];
private seenPlayers: Set<PlayerView> = new Set();
private traitorIconImage: HTMLImageElement;
private allianceRequestIconImage: HTMLImageElement;
private allianceIconImage: HTMLImageElement;
private targetIconImage: HTMLImageElement;
private crownIconImage: HTMLImageElement;
private embargoIconImage: HTMLImageElement;
private container: HTMLDivElement;
private myPlayer: PlayerView | null = null;
private firstPlace: PlayerView | null = null;
@@ -57,10 +61,14 @@ export class NameLayer implements Layer {
this.traitorIconImage.src = traitorIcon;
this.allianceIconImage = new Image();
this.allianceIconImage.src = allianceIcon;
this.allianceRequestIconImage = new Image();
this.allianceRequestIconImage.src = allianceRequestIcon;
this.crownIconImage = new Image();
this.crownIconImage.src = crownIcon;
this.targetIconImage = new Image();
this.targetIconImage.src = targetIcon;
this.embargoIconImage = new Image();
this.embargoIconImage.src = embargoIcon;
}
resizeCanvas() {
@@ -162,6 +170,7 @@ export class NameLayer implements Layer {
iconsDiv.style.justifyContent = "center";
iconsDiv.style.alignItems = "center";
iconsDiv.style.zIndex = "2";
iconsDiv.style.opacity = "0.8";
element.appendChild(iconsDiv);
const nameDiv = document.createElement("div");
@@ -314,6 +323,23 @@ export class NameLayer implements Layer {
existingAlliance.remove();
}
// Alliance request icon
const data = '[data-icon="alliance-request"]';
const existingRequestAlliance = iconsDiv.querySelector(data);
if (myPlayer != null && render.player.isRequestingAllianceWith(myPlayer)) {
if (!existingRequestAlliance) {
iconsDiv.appendChild(
this.createIconElement(
this.allianceRequestIconImage.src,
iconSize,
"alliance-request",
),
);
}
} else if (existingRequestAlliance) {
existingRequestAlliance.remove();
}
// Target icon
const existingTarget = iconsDiv.querySelector('[data-icon="target"]');
if (
@@ -359,6 +385,24 @@ export class NameLayer implements Layer {
existingEmoji.remove();
}
const existingEmbargo = iconsDiv.querySelector('[data-icon="embargo"]');
const hasEmbargo =
render.player.hasEmbargoAgainst(myPlayer) ||
myPlayer.hasEmbargoAgainst(render.player);
if (myPlayer && hasEmbargo) {
if (!existingEmbargo) {
iconsDiv.appendChild(
this.createIconElement(
this.embargoIconImage.src,
iconSize,
"embargo",
),
);
}
} else if (existingEmbargo) {
existingEmbargo.remove();
}
// Update all icon sizes
const icons = iconsDiv.getElementsByTagName("img");
for (const icon of icons) {
+2 -2
View File
@@ -107,7 +107,7 @@ export class OptionsMenu extends LitElement implements Layer {
tick() {
this.hasWinner =
this.hasWinner ||
this.game.updatesSinceLastTick()[GameUpdateType.WinUpdate].length > 0;
this.game.updatesSinceLastTick()[GameUpdateType.Win].length > 0;
if (this.game.inSpawnPhase()) {
this.timer = 0;
} else if (!this.hasWinner && this.game.ticks() % 10 == 0) {
@@ -127,7 +127,7 @@ export class OptionsMenu extends LitElement implements Layer {
@contextmenu=${(e) => e.preventDefault()}
>
<div
class="bg-opacity-60 bg-gray-900 p-1 lg:p-2 rounded-lg backdrop-blur-md"
class="bg-opacity-60 bg-gray-900 p-1 lg:p-2 rounded-es-sm lg:rounded-lg backdrop-blur-md"
>
<div class="flex items-stretch gap-1 lg:gap-2">
${button({
+98 -12
View File
@@ -4,7 +4,12 @@ import { EventBus } from "../../../core/EventBus";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { Layer } from "./Layer";
import { MouseUpEvent } from "../../InputHandler";
import { AllPlayers, Player, PlayerActions } from "../../../core/game/Game";
import {
AllPlayers,
Player,
PlayerActions,
UnitType,
} from "../../../core/game/Game";
import { TileRef } from "../../../core/game/GameMap";
import { renderNumber, renderTroops } from "../../Utils";
import targetIcon from "../../../../resources/images/TargetIconWhite.svg";
@@ -18,6 +23,7 @@ import {
SendDonateIntentEvent,
SendEmojiIntentEvent,
SendTargetPlayerIntentEvent,
SendEmbargoIntentEvent,
} from "../../Transport";
import { EmojiTable } from "./EmojiTable";
@@ -76,6 +82,26 @@ export class PlayerPanel extends LitElement implements Layer {
this.hide();
}
private handleEmbargoClick(
e: Event,
myPlayer: PlayerView,
other: PlayerView,
) {
e.stopPropagation();
this.eventBus.emit(new SendEmbargoIntentEvent(myPlayer, other, "start"));
this.hide();
}
private handleStopEmbargoClick(
e: Event,
myPlayer: PlayerView,
other: PlayerView,
) {
e.stopPropagation();
this.eventBus.emit(new SendEmbargoIntentEvent(myPlayer, other, "stop"));
this.hide();
}
private handleEmojiClick(e: Event, myPlayer: PlayerView, other: PlayerView) {
e.stopPropagation();
this.emojiTable.showTable((emoji: string) => {
@@ -107,6 +133,24 @@ export class PlayerPanel extends LitElement implements Layer {
this.requestUpdate();
}
getTotalNukesSent(): number {
const stats = this.actions.interaction?.stats;
if (!stats) {
return 0;
}
let sum = 0;
const nukes = stats.sentNukes[this.g.myPlayer().id()];
if (!nukes) {
return 0;
}
for (const nukeType in nukes) {
if (nukeType != UnitType.MIRVWarhead) {
sum += nukes[nukeType];
}
}
return sum;
}
render() {
if (!this.isVisible) {
return html``;
@@ -131,6 +175,7 @@ export class PlayerPanel extends LitElement implements Layer {
: this.actions.interaction?.canSendEmoji;
const canBreakAlliance = this.actions.interaction?.canBreakAlliance;
const canTarget = this.actions.interaction?.canTarget;
const canEmbargo = this.actions.interaction?.canEmbargo;
return html`
<div
@@ -143,7 +188,7 @@ export class PlayerPanel extends LitElement implements Layer {
<!-- Close button -->
<button
@click=${this.handleClose}
class="absolute -top-2 -right-2 w-6 h-6 flex items-center justify-center
class="absolute -top-2 -right-2 w-6 h-6 flex items-center justify-center
bg-red-500 hover:bg-red-600 text-white rounded-full
text-sm font-bold transition-colors"
>
@@ -155,7 +200,7 @@ export class PlayerPanel extends LitElement implements Layer {
<div class="flex items-center gap-1 lg:gap-2">
<div
class="px-4 h-8 lg:h-10 flex items-center justify-center
bg-opacity-50 bg-gray-700 text-opacity-90 text-white
bg-opacity-50 bg-gray-700 text-opacity-90 text-white
rounded text-sm lg:text-xl w-full"
>
${other?.name()}
@@ -190,12 +235,32 @@ export class PlayerPanel extends LitElement implements Layer {
</div>
</div>
<!-- Embargo -->
<div class="flex flex-col gap-1">
<div class="text-white text-opacity-80 text-sm px-2">
Embargo against you
</div>
<div class="bg-opacity-50 bg-gray-700 rounded p-2 text-white">
${other.hasEmbargoAgainst(myPlayer) ? "Yes" : "No"}
</div>
</div>
<!-- Stats -->
<div class="flex flex-col gap-1">
<div class="text-white text-opacity-80 text-sm px-2">
Nukes sent by them to you
</div>
<div class="bg-opacity-50 bg-gray-700 rounded p-2 text-white">
${this.getTotalNukesSent()}
</div>
</div>
<!-- Action buttons -->
<div class="flex justify-center gap-2">
${canTarget
? html`<button
@click=${(e) => this.handleTargetClick(e, other)}
class="w-10 h-10 flex items-center justify-center
class="w-10 h-10 flex items-center justify-center
bg-opacity-50 bg-gray-700 hover:bg-opacity-70
text-white rounded-lg transition-colors"
>
@@ -206,8 +271,8 @@ export class PlayerPanel extends LitElement implements Layer {
? html`<button
@click=${(e) =>
this.handleBreakAllianceClick(e, myPlayer, other)}
class="w-10 h-10 flex items-center justify-center
bg-opacity-50 bg-gray-700 hover:bg-opacity-70
class="w-10 h-10 flex items-center justify-center
bg-opacity-50 bg-gray-700 hover:bg-opacity-70
text-white rounded-lg transition-colors"
>
<img
@@ -221,8 +286,8 @@ export class PlayerPanel extends LitElement implements Layer {
? html`<button
@click=${(e) =>
this.handleAllianceClick(e, myPlayer, other)}
class="w-10 h-10 flex items-center justify-center
bg-opacity-50 bg-gray-700 hover:bg-opacity-70
class="w-10 h-10 flex items-center justify-center
bg-opacity-50 bg-gray-700 hover:bg-opacity-70
text-white rounded-lg transition-colors"
>
<img src=${allianceIcon} alt="Alliance" class="w-6 h-6" />
@@ -231,8 +296,8 @@ export class PlayerPanel extends LitElement implements Layer {
${canDonate
? html`<button
@click=${(e) => this.handleDonateClick(e, myPlayer, other)}
class="w-10 h-10 flex items-center justify-center
bg-opacity-50 bg-gray-700 hover:bg-opacity-70
class="w-10 h-10 flex items-center justify-center
bg-opacity-50 bg-gray-700 hover:bg-opacity-70
text-white rounded-lg transition-colors"
>
<img src=${donateIcon} alt="Donate" class="w-6 h-6" />
@@ -241,14 +306,35 @@ export class PlayerPanel extends LitElement implements Layer {
${canSendEmoji
? html`<button
@click=${(e) => this.handleEmojiClick(e, myPlayer, other)}
class="w-10 h-10 flex items-center justify-center
bg-opacity-50 bg-gray-700 hover:bg-opacity-70
class="w-10 h-10 flex items-center justify-center
bg-opacity-50 bg-gray-700 hover:bg-opacity-70
text-white rounded-lg transition-colors"
>
<img src=${emojiIcon} alt="Emoji" class="w-6 h-6" />
</button>`
: ""}
</div>
${canEmbargo && other != myPlayer
? html`<button
@click=${(e) => this.handleEmbargoClick(e, myPlayer, other)}
class="w-100 h-10 flex items-center justify-center
bg-opacity-50 bg-gray-700 hover:bg-opacity-70
text-white rounded-lg transition-colors"
>
Start embargo
</button>`
: ""}
${!canEmbargo && other != myPlayer
? html`<button
@click=${(e) =>
this.handleStopEmbargoClick(e, myPlayer, other)}
class="w-100 h-10 flex items-center justify-center
bg-opacity-50 bg-gray-700 hover:bg-opacity-70
text-white rounded-lg transition-colors"
>
Stop embargo
</button>`
: ""}
</div>
</div>
</div>
+19 -2
View File
@@ -8,6 +8,9 @@ import { renderNumber, renderTroops } from "../../Utils";
export class TopBar extends LitElement implements Layer {
public game: GameView;
private isVisible = false;
private _population = 0;
private _lastPopulationIncreaseRate = 0;
private _popRateIsIncreasing = false;
createRenderRoot() {
return this;
@@ -19,6 +22,15 @@ export class TopBar extends LitElement implements Layer {
}
tick() {
if (this.game?.myPlayer() !== null) {
const popIncreaseRate =
this.game.myPlayer().population() - this._population;
if (this.game.ticks() % 5 == 0) {
this._popRateIsIncreasing =
popIncreaseRate >= this._lastPopulationIncreaseRate;
this._lastPopulationIncreaseRate = popIncreaseRate;
}
}
this.requestUpdate();
}
@@ -38,7 +50,7 @@ export class TopBar extends LitElement implements Layer {
return html`
<div
class="fixed top-0 z-50 bg-gray-800/70 text-white text-sm p-1 rounded grid grid-cols-1 sm:grid-cols-2 w-1/2 sm:w-2/3 md:w-1/2 lg:hidden backdrop-blur"
class="fixed top-0 z-50 bg-gray-800/70 text-white text-sm p-1 rounded-ee-sm lg:rounded grid grid-cols-1 sm:grid-cols-2 w-1/2 sm:w-2/3 md:w-1/2 lg:hidden backdrop-blur"
>
<!-- Pop section (takes 2 columns on desktop) -->
<div
@@ -49,7 +61,12 @@ export class TopBar extends LitElement implements Layer {
>${renderTroops(myPlayer.population())} /
${renderTroops(maxPop)}</span
>
<span>(+${renderTroops(popRate)})</span>
<span
class="${this._popRateIsIncreasing
? "text-green-500"
: "text-yellow-500"}"
>(+${renderTroops(popRate)})</span
>
</div>
<!-- Gold section (takes 1 column on desktop) -->
<div
+48 -27
View File
@@ -34,12 +34,13 @@ export class WinModal extends LitElement implements Layer {
private _title: string;
private won: boolean;
static styles = css`
:host {
display: block;
}
// Override to prevent shadow DOM creation
createRenderRoot() {
return this;
}
.modal {
static styles = css`
.win-modal {
display: none;
position: fixed;
top: 50%;
@@ -58,7 +59,7 @@ export class WinModal extends LitElement implements Layer {
visibility 0.3s ease-in-out;
}
.modal.visible {
.win-modal.visible {
display: block;
animation: fadeIn 0.3s ease-out;
}
@@ -74,14 +75,14 @@ export class WinModal extends LitElement implements Layer {
}
}
h2 {
.win-modal h2 {
margin: 0 0 15px 0;
font-size: 24px;
text-align: center;
color: white;
}
p {
.win-modal p {
margin: 0 0 20px 0;
text-align: center;
background-color: rgba(0, 0, 0, 0.3);
@@ -95,7 +96,7 @@ export class WinModal extends LitElement implements Layer {
gap: 10px;
}
button {
.win-modal button {
flex: 1;
padding: 12px;
font-size: 16px;
@@ -109,38 +110,46 @@ export class WinModal extends LitElement implements Layer {
transform 0.1s ease;
}
button:hover {
.win-modal button:hover {
background: rgba(0, 150, 255, 0.8);
transform: translateY(-1px);
}
button:active {
.win-modal button:active {
transform: translateY(1px);
}
@media (max-width: 768px) {
.modal {
.win-modal {
width: 90%;
max-width: 300px;
padding: 20px;
}
h2 {
.win-modal h2 {
font-size: 20px;
}
button {
.win-modal button {
padding: 10px;
font-size: 14px;
}
}
`;
constructor() {
super();
// Add styles to document
const styleEl = document.createElement("style");
styleEl.textContent = WinModal.styles.toString();
document.head.appendChild(styleEl);
}
render() {
return html`
<div class="modal ${this.isVisible ? "visible" : ""}">
<h2>${this._title}</h2>
<div>${this.won ? this.supportHTML() : this.adsHTML()}</div>
<div class="win-modal ${this.isVisible ? "visible" : ""}">
<h2>${this._title || ""}</h2>
${this.won ? this.supportHTML() : this.adsHTML()}
<div class="button-container">
<button @click=${this._handleExit}>Exit Game</button>
<button @click=${this.hide}>Keep Playing</button>
@@ -149,24 +158,40 @@ export class WinModal extends LitElement implements Layer {
`;
}
adsHTML(): ReturnType<typeof html> {
updated(changedProperties) {
super.updated(changedProperties);
// Initialize ads if modal is visible and showing ads
if (changedProperties.has("isVisible") && this.isVisible && !this.won) {
try {
setTimeout(() => {
(adsbygoogle = window.adsbygoogle || []).push({});
}, 0);
} catch (error) {
console.error("Error initializing ad:", error);
}
}
}
adsHTML() {
return html`<ins
class="adsbygoogle"
style="display:block"
data-ad-client="ca-pub-7035513310742290"
data-ad-slot="3772893937"
data-ad-slot="winmodalad"
data-ad-format="auto"
data-full-width-responsive="true"
></ins>`;
}
supportHTML(): ReturnType<typeof html> {
supportHTML() {
return html`
<div style="text-align: center; margin: 15px 0;">
<p>
Like the game? Help make this my full-time project!
<a
href="https://discord.com/channels/1284581928254701718/shop/1330243291366559744"
href="https://discord.gg/k22YrnAzGp"
target="_blank"
rel="noopener noreferrer"
style="color: #0096ff; text-decoration: underline; display: block; margin-top: 5px;"
>
Support the game!
@@ -201,14 +226,10 @@ export class WinModal extends LitElement implements Layer {
this.hasShownDeathModal = true;
this._title = "You died";
this.won = false;
try {
(adsbygoogle = window.adsbygoogle || []).push({});
} catch (error) {
console.error("Error initializing ad:", error);
}
this.show();
}
this.game.updatesSinceLastTick()[GameUpdateType.WinUpdate].forEach((wu) => {
this.game.updatesSinceLastTick()[GameUpdateType.Win].forEach((wu) => {
const winner = this.game.playerBySmallID(wu.winnerID) as PlayerView;
this.eventBus.emit(new SendWinnerEvent(winner.clientID()));
if (winner == this.game.myPlayer()) {
+9 -3
View File
@@ -153,19 +153,20 @@
</g>
</svg>
<div class="flex justify-center text-sm font-bold mt-[-5px] logo-version">
v0.16.0
v0.17.1
</div>
</div>
<div class="bg-image"></div>
<dark-mode-button></dark-mode-button>
<google-ad></google-ad>
<!-- Main container with responsive padding -->
<div class="flex justify-center items-center flex-grow">
<div class="container px-4 sm:px-6 lg:px-8 py-4 sm:py-6 lg:py-8">
<div
class="relative flex gap-1 items-center max-w-sm sm:max-w-md lg:max-w-lg xl:max-w-xl mx-auto p-2 pb-4"
class="flex gap-1 items-center max-w-sm sm:max-w-md lg:max-w-lg xl:max-w-xl mx-auto p-2 pb-4"
>
<flag-input class="w-[20%] md:w-[15%]"></flag-input>
<username-input class="w-full"></username-input>
@@ -174,8 +175,13 @@
<div class="max-w-sm sm:max-w-md lg:max-w-lg xl:max-w-xl mx-auto mt-4">
<a
href="https://discord.gg/k22YrnAzGp"
class="w-full bg-[#5865F2] hover:bg-[#4752C4] text-white p-3 sm:p-4 lg:p-5 font-medium text-lg sm:text-xl lg:text-2xl rounded-lg border-none cursor-pointer transition-colors duration-300 flex justify-center"
class="w-full bg-[#5865F2] hover:bg-[#4752C4] text-white p-3 sm:p-4 lg:p-5 font-medium text-lg sm:text-xl lg:text-2xl rounded-lg border-none cursor-pointer transition-colors duration-300 flex justify-center items-center gap-5"
>
<img
style="height: 50px; width: 50px"
alt="Discord"
src="../../resources/icons/discord.svg"
/>
Join the Discord!
</a>
</div>