Merge branch 'main' into multi-lobby

This commit is contained in:
Scott Anderson
2025-07-05 19:19:38 -04:00
committed by GitHub
144 changed files with 5347 additions and 1934 deletions
+3 -11
View File
@@ -7,7 +7,6 @@ import {
GameStartInfo,
PlayerRecord,
ServerMessage,
Winner,
} from "../core/Schemas";
import { createGameRecord } from "../core/Util";
import { ServerConfig } from "../core/configuration/Config";
@@ -150,9 +149,10 @@ export async function createClientGame(
const gameView = new GameView(
worker,
config,
gameMap.gameMap,
gameMap,
lobbyConfig.clientID,
lobbyConfig.gameStartInfo.gameID,
lobbyConfig.gameStartInfo.players,
);
console.log("going to init path finder");
@@ -199,13 +199,6 @@ export class ClientGameRunner {
this.lastMessageTime = Date.now();
}
private getWinner(update: WinUpdate): Winner {
if (update.winner[0] !== "player") return update.winner;
const clientId = this.gameView.playerBySmallID(update.winner[1]).clientID();
if (clientId === null) return;
return ["player", clientId];
}
private saveGame(update: WinUpdate) {
if (this.myPlayer === null) {
return;
@@ -218,7 +211,6 @@ export class ClientGameRunner {
stats: update.allPlayersStats[this.lobby.clientID],
},
];
const winner = this.getWinner(update);
if (this.lobby.gameStartInfo === undefined) {
throw new Error("missing gameStartInfo");
@@ -231,7 +223,7 @@ export class ClientGameRunner {
[],
startTime(),
Date.now(),
winner,
update.winner,
);
endGame(record);
}
+149
View File
@@ -0,0 +1,149 @@
import { UserMeResponse } from "../core/ApiSchemas";
import { COSMETICS } from "../core/CosmeticSchemas";
import { getApiBase, getAuthHeader } from "./jwt";
import { translateText } from "./Utils";
interface StripeProduct {
id: string;
object: "product";
active: boolean;
created: number;
description: string | null;
images: string[];
livemode: boolean;
metadata: Record<string, string>;
name: string;
shippable: boolean | null;
type: "good" | "service";
updated: number;
url: string | null;
price: string;
price_id: string;
}
export interface Pattern {
name: string;
key: string;
roles: string[];
price?: string;
priceId?: string;
lockedReason?: string;
notShown?: boolean;
}
export async function patterns(
userMe: UserMeResponse | null,
): Promise<Pattern[]> {
const patterns: Pattern[] = Object.entries(COSMETICS.patterns).map(
([key, patternData]) => {
return {
name: patternData.name,
key,
roles: patternData.role_group
? (COSMETICS.role_groups[patternData.role_group] ?? [])
: [],
};
},
);
const products = await listAllProducts();
patterns.forEach((pattern) => {
addRestrictions(pattern, userMe, products);
});
return patterns;
}
function addRestrictions(
pattern: Pattern,
userMe: UserMeResponse | null,
products: Map<string, StripeProduct>,
) {
if (userMe === null) {
if (products.has(`pattern:${pattern.name}`)) {
// Purchasable (flare-gated) patterns are shown as disabled
pattern.lockedReason = translateText("territory_patterns.blocked.login");
} else {
// Role-gated patterns are not shown
pattern.notShown = true;
}
return;
}
const flares = userMe.player.flares ?? [];
if (
flares.includes("pattern:*") ||
flares.includes(`pattern:${pattern.name}`)
) {
// Pattern is unlocked by flare
return;
}
const myRoles = userMe.player.roles ?? [];
if (
pattern.roles.some((authorizedRole) => myRoles.includes(authorizedRole))
) {
// Pattern is unlocked by role
return;
}
const product = products.get(`pattern:${pattern.name}`);
if (product) {
pattern.price = product.price;
pattern.priceId = product.price_id;
pattern.lockedReason = translateText("territory_patterns.blocked.purchase");
return;
}
// Pattern is locked by role group and not purchasable, don't show it.
pattern.notShown = true;
}
export async function handlePurchase(priceId: string) {
try {
const response = await fetch(
`${getApiBase()}/stripe/create-checkout-session`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
authorization: getAuthHeader(),
},
body: JSON.stringify({
priceId: priceId,
successUrl: `${window.location.href}purchase-success`,
cancelUrl: `${window.location.href}purchase-cancel`,
}),
},
);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const { url } = await response.json();
// Redirect to Stripe checkout
window.location.href = url;
} catch (error) {
console.error("Purchase error:", error);
alert("Something went wrong. Please try again later.");
}
}
// Returns a map of flare -> product
export async function listAllProducts(): Promise<Map<string, StripeProduct>> {
try {
const response = await fetch(`${getApiBase()}/stripe/products`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const products = (await response.json()) as StripeProduct[];
const productMap = new Map<string, StripeProduct>();
products.forEach((product) => {
productMap.set(product.metadata.flare, product);
});
return productMap;
} catch (error) {
console.error("Failed to fetch products:", error);
return new Map();
}
}
+4 -1
View File
@@ -39,7 +39,10 @@ export class HostLobbyModal extends LitElement {
@state() private copySuccess = false;
@state() private players: string[] = [];
@state() private useRandomMap: boolean = false;
@state() private disabledUnits: UnitType[] = [];
@state() private disabledUnits: UnitType[] = [
UnitType.Factory,
UnitType.Train,
];
private playersInterval: NodeJS.Timeout | null = null;
// Add a new timer for debouncing bot changes
+8
View File
@@ -6,10 +6,12 @@ import ar from "../../resources/lang/ar.json";
import bg from "../../resources/lang/bg.json";
import bn from "../../resources/lang/bn.json";
import cs from "../../resources/lang/cs.json";
import da from "../../resources/lang/da.json";
import de from "../../resources/lang/de.json";
import en from "../../resources/lang/en.json";
import eo from "../../resources/lang/eo.json";
import es from "../../resources/lang/es.json";
import fi from "../../resources/lang/fi.json";
import fr from "../../resources/lang/fr.json";
import he from "../../resources/lang/he.json";
import hi from "../../resources/lang/hi.json";
@@ -20,9 +22,11 @@ import pl from "../../resources/lang/pl.json";
import pt_br from "../../resources/lang/pt_br.json";
import ru from "../../resources/lang/ru.json";
import sh from "../../resources/lang/sh.json";
import sv_se from "../../resources/lang/sv_se.json";
import tp from "../../resources/lang/tp.json";
import tr from "../../resources/lang/tr.json";
import uk from "../../resources/lang/uk.json";
import zh_cn from "../../resources/lang/zh_cn.json";
@customElement("lang-selector")
export class LangSelector extends LitElement {
@@ -57,6 +61,10 @@ export class LangSelector extends LitElement {
uk,
cs,
he,
da,
fi,
sv_se,
zh_cn,
};
createRenderRoot() {
+8 -6
View File
@@ -182,13 +182,13 @@ class Client {
const territoryModal = document.querySelector(
"territory-patterns-modal",
) as TerritoryPatternsModal;
const tpButton = document.getElementById(
const patternButton = document.getElementById(
"territory-patterns-input-preview-button",
);
territoryModal instanceof TerritoryPatternsModal;
if (tpButton === null)
if (patternButton === null)
throw new Error("territory-patterns-input-preview-button");
territoryModal.previewButton = tpButton;
territoryModal.previewButton = patternButton;
territoryModal.updatePreview();
territoryModal.resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
@@ -197,7 +197,7 @@ class Client {
}
}
});
tpButton.addEventListener("click", () => {
patternButton.addEventListener("click", () => {
territoryModal.open();
});
@@ -207,6 +207,7 @@ class Client {
loginDiscordButton.translationKey = "main.login_discord";
loginDiscordButton.addEventListener("click", discordLogin);
logoutDiscordButton.hidden = true;
territoryModal.onUserMe(null);
} else {
// JWT appears to be valid
loginDiscordButton.disable = true;
@@ -215,12 +216,12 @@ class Client {
logoutDiscordButton.addEventListener("click", () => {
// Log out
logOut();
territoryModal.onUserMe(null);
loginDiscordButton.disable = false;
loginDiscordButton.translationKey = "main.login_discord";
loginDiscordButton.hidden = false;
loginDiscordButton.addEventListener("click", discordLogin);
logoutDiscordButton.hidden = true;
territoryModal.onLogout();
});
// Look up the discord user object.
// TODO: Add caching
@@ -231,6 +232,7 @@ class Client {
loginDiscordButton.translationKey = "main.login_discord";
loginDiscordButton.addEventListener("click", discordLogin);
logoutDiscordButton.hidden = true;
territoryModal.onUserMe(null);
return;
}
console.log(
@@ -366,7 +368,7 @@ class Client {
"host-lobby-modal",
"join-private-lobby-modal",
"game-starting-modal",
"top-bar",
"game-top-bar",
"help-modal",
"user-setting",
].forEach((tag) => {
+4 -1
View File
@@ -40,7 +40,10 @@ export class SinglePlayerModal extends LitElement {
@state() private gameMode: GameMode = GameMode.FFA;
@state() private teamCount: number | typeof Duos = 2;
@state() private disabledUnits: UnitType[] = [];
@state() private disabledUnits: UnitType[] = [
UnitType.Factory,
UnitType.Train,
];
private userSettings: UserSettings = new UserSettings();
+133 -160
View File
@@ -1,12 +1,13 @@
import { base64url } from "jose";
import type { TemplateResult } from "lit";
import { html, LitElement, render } from "lit";
import { customElement, query, state } from "lit/decorators.js";
import { UserMeResponse } from "../core/ApiSchemas";
import { COSMETICS } from "../core/CosmeticSchemas";
import { UserSettings } from "../core/game/UserSettings";
import { PatternDecoder } from "../core/PatternDecoder";
import "./components/Difficulties";
import "./components/Maps";
import { handlePurchase, Pattern, patterns } from "./Cosmetics";
import { translateText } from "./Utils";
@customElement("territory-patterns-modal")
@@ -17,25 +18,27 @@ export class TerritoryPatternsModal extends LitElement {
};
public previewButton: HTMLElement | null = null;
public buttonWidth: number = 100;
public buttonWidth: number = 150;
@state() private selectedPattern: string | undefined;
@state() private lockedPatterns: string[] = [];
@state() private lockedReasons: Record<string, string> = {};
@state() private hoveredPattern: string | null = null;
@state() private hoveredPattern: Pattern | null = null;
@state() private hoverPosition = { x: 0, y: 0 };
@state() private keySequence: string[] = [];
@state() private showChocoPattern = false;
private patterns: Pattern[] = [];
private me: UserMeResponse | null = null;
public resizeObserver: ResizeObserver;
private userSettings: UserSettings = new UserSettings();
constructor() {
super();
this.checkPatternPermission(undefined, undefined);
}
connectedCallback() {
@@ -59,52 +62,14 @@ export class TerritoryPatternsModal extends LitElement {
this.resizeObserver.disconnect();
}
onLogout() {
this.checkPatternPermission(undefined, undefined);
}
onUserMe(userMeResponse: UserMeResponse) {
const { player } = userMeResponse;
const { roles, flares } = player;
this.checkPatternPermission(roles, flares);
}
private checkPatternPermission(
roles: string[] | undefined,
flares: string[] | undefined,
) {
this.lockedPatterns = [];
this.lockedReasons = {};
for (const key in COSMETICS.patterns) {
const patternData = COSMETICS.patterns[key];
const roleGroup: string[] | string | undefined = patternData.role_group;
if (
flares !== undefined &&
(flares.includes("pattern:*") || flares.includes(`pattern:${key}`))
) {
continue;
}
if (!roleGroup || (Array.isArray(roleGroup) && roleGroup.length === 0)) {
if (roles === undefined || roles.length === 0) {
const reason = translateText("territory_patterns.blocked.login");
this.setLockedPatterns([key], reason);
}
continue;
}
const groupList = Array.isArray(roleGroup) ? roleGroup : [roleGroup];
const isAllowed =
roles !== undefined &&
groupList.some((required) => roles.includes(required));
if (!isAllowed) {
const reason = translateText("territory_patterns.blocked.role", {
role: groupList.join(", "),
});
this.setLockedPatterns([key], reason);
}
async onUserMe(userMeResponse: UserMeResponse | null) {
this.patterns = await patterns(userMeResponse);
const p = this.patterns.find((p) => p.name === this.selectedPattern);
if (p === undefined || p.lockedReason || p.notShown) {
console.warn("selected pattern is locked or not shown, resetting");
this.selectPattern(undefined);
}
this.me = userMeResponse;
this.requestUpdate();
}
@@ -140,65 +105,85 @@ export class TerritoryPatternsModal extends LitElement {
}
private renderTooltip(): TemplateResult | null {
if (this.hoveredPattern && this.lockedReasons[this.hoveredPattern]) {
if (this.hoveredPattern && this.hoveredPattern.lockedReason) {
return html`
<div
class="fixed z-[10000] px-3 py-2 rounded bg-black text-white text-sm pointer-events-none shadow-md"
style="top: ${this.hoverPosition.y + 12}px; left: ${this.hoverPosition
.x + 12}px;"
>
${this.lockedReasons[this.hoveredPattern]}
${this.hoveredPattern.lockedReason}
</div>
`;
}
return null;
}
private renderPatternButton(key: string): TemplateResult {
const isLocked = this.isPatternLocked(key);
const isSelected = this.selectedPattern === key;
const name = COSMETICS.patterns[key]?.name ?? "custom";
private renderPatternButton(pattern: Pattern): TemplateResult {
const isSelected = this.selectedPattern === pattern.key;
return html`
<button
class="border p-2 rounded-lg shadow text-black dark:text-white text-left
${isSelected
? "bg-blue-500 text-white"
: "bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700"}
${isLocked ? "opacity-50 cursor-not-allowed" : ""}"
style="flex: 0 1 calc(25% - 1rem); max-width: calc(25% - 1rem);"
@click=${() => !isLocked && this.selectPattern(key)}
@mouseenter=${(e: MouseEvent) => this.handleMouseEnter(key, e)}
@mousemove=${(e: MouseEvent) => this.handleMouseMove(e)}
@mouseleave=${() => this.handleMouseLeave()}
>
<div class="text-sm font-bold mb-1">
${translateText(`territory_patterns.pattern.${name}`)}
</div>
<div
class="preview-container"
style="
width: 100%;
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
background: #fff;
border-radius: 8px;
overflow: hidden;
"
<div style="flex: 0 1 calc(25% - 1rem); max-width: calc(25% - 1rem);">
<button
class="border p-2 rounded-lg shadow text-black dark:text-white text-left w-full
${isSelected
? "bg-blue-500 text-white"
: "bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700"}
${pattern.lockedReason ? "opacity-50 cursor-not-allowed" : ""}"
@click=${() =>
!pattern.lockedReason && this.selectPattern(pattern.key)}
@mouseenter=${(e: MouseEvent) => this.handleMouseEnter(pattern, e)}
@mousemove=${(e: MouseEvent) => this.handleMouseMove(e)}
@mouseleave=${() => this.handleMouseLeave()}
>
${this.renderPatternPreview(key, this.buttonWidth, this.buttonWidth)}
</div>
</button>
<div class="text-sm font-bold mb-1">
${translateText(`territory_patterns.pattern.${pattern.name}`)}
</div>
<div
class="preview-container"
style="
width: 100%;
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
background: #fff;
border-radius: 8px;
overflow: hidden;
"
>
${this.renderPatternPreview(
pattern.key,
this.buttonWidth,
this.buttonWidth,
)}
</div>
</button>
${pattern.priceId !== undefined && pattern.lockedReason
? html`
<button
class="w-full mt-2 px-3 py-1 bg-green-500 hover:bg-green-600 text-white text-xs font-medium rounded transition-colors"
@click=${(e: Event) => {
e.stopPropagation();
handlePurchase(pattern.priceId!);
}}
>
${translateText("territory_patterns.purchase")}
(${pattern.price})
</button>
`
: null}
</div>
`;
}
private renderPatternGrid(): TemplateResult {
const buttons: TemplateResult[] = [];
for (const key in COSMETICS.patterns) {
const value = COSMETICS.patterns[key];
if (!this.showChocoPattern && value.name === "choco") continue;
const result = this.renderPatternButton(key);
for (const pattern of this.patterns) {
if (!this.showChocoPattern && pattern.name === "choco") continue;
if (pattern.notShown === true) continue;
const result = this.renderPatternButton(pattern);
buttons.push(result);
}
@@ -267,68 +252,12 @@ export class TerritoryPatternsModal extends LitElement {
}
private renderPatternPreview(
pattern: string,
width: number,
height: number,
pattern?: string,
width?: number,
height?: number,
): TemplateResult {
const decoder = new PatternDecoder(pattern);
const cellCountX = decoder.getTileWidth();
const cellCountY = decoder.getTileHeight();
const cellSize =
cellCountX > 0 && cellCountY > 0
? Math.min(height / cellCountY, width / cellCountX)
: 1;
return html`
<div
style="
display: flex;
align-items: center;
justify-content: center;
height: ${height}px;
width: ${width}px;
background-color: #f0f0f0;
border-radius: 4px;
box-sizing: border-box;
overflow: hidden;
position: relative;
"
>
<div
style="
display: grid;
grid-template-columns: repeat(${cellCountX}, ${cellSize}px);
grid-template-rows: repeat(${cellCountY}, ${cellSize}px);
background-color: #ccc;
padding: 2px;
border-radius: 4px;
"
>
${(() => {
const tiles: TemplateResult[] = [];
for (let py = 0; py < cellCountY; py++) {
for (let px = 0; px < cellCountX; px++) {
const x = px << decoder.getScale();
const y = py << decoder.getScale();
const bit = decoder.isSet(x, y);
tiles.push(html`
<div
style="
background-color: ${bit ? "#000" : "transparent"};
border: 1px solid rgba(0, 0, 0, 0.1);
width: ${cellSize}px;
height: ${cellSize}px;
border-radius: 1px;
"
></div>
`);
}
}
return tiles;
})()}
</div>
</div>
<img src="${generatePreviewDataUrl(pattern, width, height)}"></img>
`;
}
@@ -376,10 +305,7 @@ export class TerritoryPatternsModal extends LitElement {
public updatePreview() {
if (this.previewButton === null) return;
const preview =
this.selectedPattern === undefined
? this.renderBlankPreview(48, 48)
: this.renderPatternPreview(this.selectedPattern, 48, 48);
const preview = this.renderPatternPreview(this.selectedPattern, 48, 48);
render(preview, this.previewButton);
}
@@ -397,13 +323,9 @@ export class TerritoryPatternsModal extends LitElement {
};
}
private isPatternLocked(patternKey: string): boolean {
return this.lockedPatterns.includes(patternKey);
}
private handleMouseEnter(patternKey: string, event: MouseEvent) {
if (this.isPatternLocked(patternKey)) {
this.hoveredPattern = patternKey;
private handleMouseEnter(pattern: Pattern, event: MouseEvent) {
if (pattern.lockedReason) {
this.hoveredPattern = pattern;
this.hoverPosition = { x: event.clientX, y: event.clientY };
}
}
@@ -418,3 +340,54 @@ export class TerritoryPatternsModal extends LitElement {
this.hoveredPattern = null;
}
}
const DEFAULT_PATTERN_B64 = "AAAAAA"; // Empty 2x2 pattern
const COLOR_SET = [0, 0, 0, 255]; // Black
const COLOR_UNSET = [255, 255, 255, 255]; // White
export function generatePreviewDataUrl(
pattern?: string,
width?: number,
height?: number,
): string {
// Calculate canvas size
const decoder = new PatternDecoder(
pattern ?? DEFAULT_PATTERN_B64,
base64url.decode,
);
const scaledWidth = decoder.scaledWidth();
const scaledHeight = decoder.scaledHeight();
width =
width === undefined
? scaledWidth
: Math.max(1, Math.floor(width / scaledWidth)) * scaledWidth;
height =
height === undefined
? scaledHeight
: Math.max(1, Math.floor(height / scaledHeight)) * scaledHeight;
// Create the canvas
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("2D context not supported");
// Create an image
const imageData = ctx.createImageData(width, height);
const data = imageData.data;
let i = 0;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const rgba = decoder.isSet(x, y) ? COLOR_SET : COLOR_UNSET;
data[i++] = rgba[0]; // Red
data[i++] = rgba[1]; // Green
data[i++] = rgba[2]; // Blue
data[i++] = rgba[3]; // Alpha
}
}
// Create a data URL
ctx.putImageData(imageData, 0, 0);
return canvas.toDataURL("image/png");
}
+17
View File
@@ -67,6 +67,10 @@ export class SendAllianceReplyIntentEvent implements GameEvent {
) {}
}
export class SendAllianceExtensionIntentEvent implements GameEvent {
constructor(public readonly recipient: PlayerView) {}
}
export class SendSpawnIntentEvent implements GameEvent {
constructor(public readonly cell: Cell) {}
}
@@ -194,6 +198,9 @@ export class Transport {
this.eventBus.on(SendAllianceReplyIntentEvent, (e) =>
this.onAllianceRequestReplyUIEvent(e),
);
this.eventBus.on(SendAllianceExtensionIntentEvent, (e) =>
this.onSendAllianceExtensionIntent(e),
);
this.eventBus.on(SendBreakAllianceIntentEvent, (e) =>
this.onBreakAllianceRequestUIEvent(e),
);
@@ -419,6 +426,16 @@ export class Transport {
});
}
private onSendAllianceExtensionIntent(
event: SendAllianceExtensionIntentEvent,
) {
this.sendIntent({
type: "allianceExtension",
clientID: this.lobbyConfig.clientID,
recipient: event.recipient.id(),
});
}
private onSendSpawnIntentEvent(event: SendSpawnIntentEvent) {
this.sendIntent({
type: "spawn",
+1
View File
@@ -141,6 +141,7 @@ export function getMessageTypeClasses(type: MessageType): string {
case MessageType.SAM_MISS:
case MessageType.ALLIANCE_EXPIRED:
case MessageType.NAVAL_INVASION_INBOUND:
case MessageType.RENEW_ALLIANCE:
return severityColors["warn"];
case MessageType.CHAT:
case MessageType.ALLIANCE_REQUEST:
+20 -32
View File
@@ -14,6 +14,8 @@ import { EmojiTable } from "./layers/EmojiTable";
import { EventsDisplay } from "./layers/EventsDisplay";
import { FxLayer } from "./layers/FxLayer";
import { GameLeftSidebar } from "./layers/GameLeftSidebar";
import { GameRightSidebar } from "./layers/GameRightSidebar";
import { GameTopBar } from "./layers/GameTopBar";
import { GutterAdModal } from "./layers/GutterAdModal";
import { HeadsUpMessage } from "./layers/HeadsUpMessage";
import { Layer } from "./layers/Layer";
@@ -21,7 +23,6 @@ import { Leaderboard } from "./layers/Leaderboard";
import { MainRadialMenu } from "./layers/MainRadialMenu";
import { MultiTabModal } from "./layers/MultiTabModal";
import { NameLayer } from "./layers/NameLayer";
import { OptionsMenu } from "./layers/OptionsMenu";
import { PlayerInfoOverlay } from "./layers/PlayerInfoOverlay";
import { PlayerPanel } from "./layers/PlayerPanel";
import { RailroadLayer } from "./layers/RailroadLayer";
@@ -33,9 +34,7 @@ import { StructureLayer } from "./layers/StructureLayer";
import { TeamStats } from "./layers/TeamStats";
import { TerrainLayer } from "./layers/TerrainLayer";
import { TerritoryLayer } from "./layers/TerritoryLayer";
import { TopBar } from "./layers/TopBar";
import { UILayer } from "./layers/UILayer";
import { UnitInfoModal } from "./layers/UnitInfoModal";
import { UnitLayer } from "./layers/UnitLayer";
import { WinModal } from "./layers/WinModal";
@@ -71,6 +70,7 @@ export function createRenderer(
}
buildMenu.game = game;
buildMenu.eventBus = eventBus;
buildMenu.transformHandler = transformHandler;
const leaderboard = document.querySelector("leader-board") as Leaderboard;
if (!emojiTable || !(leaderboard instanceof Leaderboard)) {
@@ -135,25 +135,28 @@ export function createRenderer(
winModal.eventBus = eventBus;
winModal.game = game;
const optionsMenu = document.querySelector("options-menu") as OptionsMenu;
if (!(optionsMenu instanceof OptionsMenu)) {
console.error("options menu not found");
}
optionsMenu.eventBus = eventBus;
optionsMenu.game = game;
const replayPanel = document.querySelector("replay-panel") as ReplayPanel;
if (!(replayPanel instanceof ReplayPanel)) {
console.error("ReplayPanel element not found in the DOM");
console.error("replay panel not found");
}
replayPanel.eventBus = eventBus;
replayPanel.game = game;
const topBar = document.querySelector("top-bar") as TopBar;
if (!(topBar instanceof TopBar)) {
const gameRightSidebar = document.querySelector(
"game-right-sidebar",
) as GameRightSidebar;
if (!(gameRightSidebar instanceof GameRightSidebar)) {
console.error("Game Right bar not found");
}
gameRightSidebar.game = game;
gameRightSidebar.eventBus = eventBus;
const gameTopBar = document.querySelector("game-top-bar") as GameTopBar;
if (!(gameTopBar instanceof GameTopBar)) {
console.error("top bar not found");
}
topBar.game = game;
gameTopBar.game = game;
gameTopBar.eventBus = eventBus;
const playerPanel = document.querySelector("player-panel") as PlayerPanel;
if (!(playerPanel instanceof PlayerPanel)) {
@@ -187,21 +190,7 @@ export function createRenderer(
}
headsUpMessage.game = game;
const unitInfoModal = document.querySelector(
"unit-info-modal",
) as UnitInfoModal;
if (!(unitInfoModal instanceof UnitInfoModal)) {
console.error("unit info modal not found");
}
unitInfoModal.game = game;
const structureLayer = new StructureLayer(
game,
eventBus,
transformHandler,
unitInfoModal,
);
unitInfoModal.structureLayer = structureLayer;
// unitInfoModal.eventBus = eventBus;
const structureLayer = new StructureLayer(game, eventBus, transformHandler);
const spawnAd = document.querySelector("spawn-ad") as SpawnAd;
if (!(spawnAd instanceof SpawnAd)) {
@@ -248,16 +237,15 @@ export function createRenderer(
new SpawnTimer(game, transformHandler),
leaderboard,
gameLeftSidebar,
gameTopBar,
gameRightSidebar,
controlPanel,
playerInfo,
winModal,
optionsMenu,
replayPanel,
teamStats,
topBar,
playerPanel,
headsUpMessage,
unitInfoModal,
multiTabModal,
spawnAd,
gutterAdModal,
-9
View File
@@ -1,14 +1,5 @@
import { Fx } from "./Fx";
// Shorten a number by replacing thousands with "k"
export function shortenNumber(num: number): string {
if (num >= 1_000) {
return (num / 1_000).toFixed(1).replace(/\.0$/, "") + "k";
} else {
return num.toString();
}
}
export class TextFx implements Fx {
private lifeTime: number = 0;
@@ -1,35 +0,0 @@
import { LitElement, css, html } from "lit";
import { customElement, property } from "lit/decorators.js";
@customElement("leaderboard-regular-icon")
export class LeaderboardRegularIcon extends LitElement {
@property({ type: String }) size = "24"; // Accepts "24", "32", etc.
@property({ type: String }) color = "currentColor";
static styles = css`
:host {
display: inline-block;
vertical-align: middle;
}
svg {
display: block;
}
`;
render() {
return html`
<svg
xmlns="http://www.w3.org/2000/svg"
width="${this.size}"
height="${this.size}"
viewBox="0 0 24 24"
fill="${this.color}"
>
<path
fill="currentColor"
d="M4 19h4v-8H4zm6 0h4V5h-4zm6 0h4v-6h-4zM2 21V9h6V3h8v8h6v10z"
/>
</svg>
`;
}
}
@@ -1,35 +0,0 @@
import { LitElement, css, html } from "lit";
import { customElement, property } from "lit/decorators.js";
@customElement("leaderboard-solid-icon")
export class LeaderboardSolidIcon extends LitElement {
@property({ type: String }) size = "24"; // Accepts "24", "32", etc.
@property({ type: String }) color = "currentColor";
static styles = css`
:host {
display: inline-block;
vertical-align: middle;
}
svg {
display: block;
}
`;
render() {
return html`
<svg
xmlns="http://www.w3.org/2000/svg"
width="${this.size}"
height="${this.size}"
viewBox="0 0 24 24"
fill="${this.color}"
>
<path
fill="currentColor"
d="M2 21V9h5.5v12zm7.25 0V3h5.5v18zm7.25 0V11H22v10z"
/>
</svg>
`;
}
}
@@ -1,35 +0,0 @@
import { LitElement, css, html } from "lit";
import { customElement, property } from "lit/decorators.js";
@customElement("team-regular-icon")
export class TeamRegularIcon extends LitElement {
@property({ type: String }) size = "24"; // Accepts "24", "32", etc.
@property({ type: String }) color = "currentColor";
static styles = css`
:host {
display: inline-block;
vertical-align: middle;
}
svg {
display: block;
}
`;
render() {
return html`
<svg
xmlns="http://www.w3.org/2000/svg"
width="${this.size}"
height="${this.size}"
viewBox="0 0 24 24"
fill="${this.color}"
>
<path
fill="currentColor"
d="M9 13.75c-2.34 0-7 1.17-7 3.5V19h14v-1.75c0-2.33-4.66-3.5-7-3.5M4.34 17c.84-.58 2.87-1.25 4.66-1.25s3.82.67 4.66 1.25zM9 12c1.93 0 3.5-1.57 3.5-3.5S10.93 5 9 5S5.5 6.57 5.5 8.5S7.07 12 9 12m0-5c.83 0 1.5.67 1.5 1.5S9.83 10 9 10s-1.5-.67-1.5-1.5S8.17 7 9 7m7.04 6.81c1.16.84 1.96 1.96 1.96 3.44V19h4v-1.75c0-2.02-3.5-3.17-5.96-3.44M15 12c1.93 0 3.5-1.57 3.5-3.5S16.93 5 15 5c-.54 0-1.04.13-1.5.35c.63.89 1 1.98 1 3.15s-.37 2.26-1 3.15c.46.22.96.35 1.5.35"
/>
</svg>
`;
}
}
@@ -1,35 +0,0 @@
import { LitElement, css, html } from "lit";
import { customElement, property } from "lit/decorators.js";
@customElement("team-solid-icon")
export class TeamSolidIcon extends LitElement {
@property({ type: String }) size = "24"; // Accepts "24", "32", etc.
@property({ type: String }) color = "currentColor";
static styles = css`
:host {
display: inline-block;
vertical-align: middle;
}
svg {
display: block;
}
`;
render() {
return html`
<svg
xmlns="http://www.w3.org/2000/svg"
width="${this.size}"
height="${this.size}"
viewBox="0 0 24 24"
fill="${this.color}"
>
<path
fill="currentColor"
d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5s-3 1.34-3 3s1.34 3 3 3m-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5S5 6.34 5 8s1.34 3 3 3m0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5m8 0c-.29 0-.62.02-.97.05c1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5"
/>
</svg>
`;
}
}
+78 -19
View File
@@ -13,11 +13,27 @@ import missileSiloIcon from "../../../../resources/non-commercial/svg/MissileSil
import samlauncherIcon from "../../../../resources/non-commercial/svg/SamLauncherIconWhite.svg";
import { translateText } from "../../../client/Utils";
import { EventBus } from "../../../core/EventBus";
import { Cell, Gold, PlayerActions, UnitType } from "../../../core/game/Game";
import {
BuildableUnit,
Cell,
Gold,
PlayerActions,
UnitType,
} from "../../../core/game/Game";
import { TileRef } from "../../../core/game/GameMap";
import { GameView } from "../../../core/game/GameView";
import { BuildUnitIntentEvent } from "../../Transport";
import {
CloseViewEvent,
MouseDownEvent,
ShowBuildMenuEvent,
ShowEmojiMenuEvent,
} from "../../InputHandler";
import {
BuildUnitIntentEvent,
SendUpgradeStructureIntentEvent,
} from "../../Transport";
import { renderNumber } from "../../Utils";
import { TransformHandler } from "../TransformHandler";
import { Layer } from "./Layer";
export interface BuildItemDisplay {
@@ -113,6 +129,30 @@ export class BuildMenu extends LitElement implements Layer {
private clickedTile: TileRef;
public playerActions: PlayerActions | null;
private filteredBuildTable: BuildItemDisplay[][] = buildTable;
public transformHandler: TransformHandler;
init() {
this.eventBus.on(ShowBuildMenuEvent, (e) => {
if (!this.game.myPlayer()?.isAlive()) {
return;
}
const clickedCell = this.transformHandler.screenToWorldCoordinates(
e.x,
e.y,
);
if (clickedCell === null) {
return;
}
if (!this.game.isValidCoord(clickedCell.x, clickedCell.y)) {
return;
}
const tile = this.game.ref(clickedCell.x, clickedCell.y);
this.showMenu(tile);
});
this.eventBus.on(CloseViewEvent, () => this.hideMenu());
this.eventBus.on(ShowEmojiMenuEvent, () => this.hideMenu());
this.eventBus.on(MouseDownEvent, () => this.hideMenu());
}
tick() {
if (!this._hidden) {
@@ -312,7 +352,7 @@ export class BuildMenu extends LitElement implements Layer {
@state()
private _hidden = true;
public canBuild(item: BuildItemDisplay): boolean {
public canBuildOrUpgrade(item: BuildItemDisplay): boolean {
if (this.game?.myPlayer() === null || this.playerActions === null) {
return false;
}
@@ -321,7 +361,7 @@ export class BuildMenu extends LitElement implements Layer {
if (unit.length === 0) {
return false;
}
return unit[0].canBuild !== false;
return unit[0].canBuild !== false || unit[0].canUpgrade !== false;
}
public cost(item: BuildItemDisplay): Gold {
@@ -342,15 +382,24 @@ export class BuildMenu extends LitElement implements Layer {
return player.units(item.unitType).length.toString();
}
public onBuildSelected = (item: BuildItemDisplay) => {
this.eventBus.emit(
new BuildUnitIntentEvent(
item.unitType,
new Cell(this.game.x(this.clickedTile), this.game.y(this.clickedTile)),
),
);
public sendBuildOrUpgrade(buildableUnit: BuildableUnit, tile: TileRef): void {
if (buildableUnit.canUpgrade !== false) {
this.eventBus.emit(
new SendUpgradeStructureIntentEvent(
buildableUnit.canUpgrade,
buildableUnit.type,
),
);
} else if (buildableUnit.canBuild) {
this.eventBus.emit(
new BuildUnitIntentEvent(
buildableUnit.type,
new Cell(this.game.x(tile), this.game.y(tile)),
),
);
}
this.hideMenu();
};
}
render() {
return html`
@@ -361,13 +410,23 @@ export class BuildMenu extends LitElement implements Layer {
${this.filteredBuildTable.map(
(row) => html`
<div class="build-row">
${row.map(
(item) => html`
${row.map((item) => {
const buildableUnit = this.playerActions?.buildableUnits.find(
(bu) => bu.type === item.unitType,
);
if (buildableUnit === undefined) {
return html``;
}
const enabled =
buildableUnit.canBuild !== false ||
buildableUnit.canUpgrade !== false;
return html`
<button
class="build-button"
@click=${() => this.onBuildSelected(item)}
?disabled=${!this.canBuild(item)}
title=${!this.canBuild(item)
@click=${() =>
this.sendBuildOrUpgrade(buildableUnit, this.clickedTile)}
?disabled=${!enabled}
title=${!enabled
? translateText("build_menu.not_enough_money")
: ""}
>
@@ -402,8 +461,8 @@ export class BuildMenu extends LitElement implements Layer {
</div>`
: ""}
</button>
`,
)}
`;
})}
</div>
`,
)}
+12 -77
View File
@@ -1,13 +1,11 @@
import { LitElement, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import factoryIcon from "../../../../resources/images/FactoryIconWhite.svg";
import { translateText } from "../../../client/Utils";
import { EventBus } from "../../../core/EventBus";
import { Gold, UnitType } from "../../../core/game/Game";
import { GameView } from "../../../core/game/GameView";
import { AttackRatioEvent } from "../../InputHandler";
import { SendSetTargetTroopRatioEvent } from "../../Transport";
import { renderNumber, renderTroops } from "../../Utils";
import { renderTroops } from "../../Utils";
import { UIState } from "../UIState";
import { Layer } from "./Layer";
@@ -29,37 +27,13 @@ export class ControlPanel extends LitElement implements Layer {
@state()
private _population: number;
@state()
private _maxPopulation: number;
@state()
private popRate: number;
@state()
private _troops: number;
@state()
private _workers: number;
@state()
private _isVisible = false;
@state()
private _manpower: number = 0;
@state()
private _gold: Gold;
@state()
private _goldPerSecond: Gold;
@state()
private _factories: number;
private _lastPopulationIncreaseRate: number;
private _popRateIsIncreasing: boolean = true;
private init_: boolean = false;
init() {
@@ -118,22 +92,13 @@ export class ControlPanel extends LitElement implements Layer {
const popIncreaseRate = player.population() - this._population;
if (this.game.ticks() % 5 === 0) {
this._popRateIsIncreasing =
popIncreaseRate >= this._lastPopulationIncreaseRate;
this._lastPopulationIncreaseRate = popIncreaseRate;
}
this._population = player.population();
this._maxPopulation = this.game.config().maxPopulation(player);
this._gold = player.gold();
this._troops = player.troops();
this._workers = player.workers();
this.popRate = this.game.config().populationIncreaseRate(player) * 10;
this._goldPerSecond = this.game.config().goldAdditionRate(player) * 10n;
this.currentTroopRatio = player.troops() / player.population();
this.requestUpdate();
this._factories = player.units(UnitType.Factory).length;
}
onAttackRatioChange(newRatio: number) {
@@ -209,51 +174,21 @@ export class ControlPanel extends LitElement implements Layer {
</style>
<div
class="${this._isVisible
? "w-[320px] text-sm lg:text-m bg-gray-800/70 p-2 pr-3 lg:p-4 shadow-lg lg:rounded-lg backdrop-blur"
? "text-sm lg:text-m md:w-[320px] bg-gray-800/70 p-2 pr-3 lg:p-4 shadow-lg lg:rounded-lg backdrop-blur"
: "hidden"}"
@contextmenu=${(e) => e.preventDefault()}
>
<div class="hidden lg:block bg-black/30 text-white mb-4 p-2 rounded">
<div class="flex justify-between mb-1">
<span class="font-bold"
>${translateText("control_panel.pop")}:</span
>
<span translate="no"
>${renderTroops(this._population)} /
${renderTroops(this._maxPopulation)}
<span
class="${this._popRateIsIncreasing
? "text-green-500"
: "text-yellow-500"}"
translate="no"
>(+${renderTroops(this.popRate)})</span
></span
>
</div>
<div class="flex justify-between">
<span class="font-bold"
>${translateText("control_panel.gold")}:</span
>
<span translate="no"
>${renderNumber(this._gold)}
(+${renderNumber(this._goldPerSecond)}
${renderNumber(this._factories)}
<img
src="${factoryIcon}"
style="display: inline"
width="15"
/>)</span
>
</div>
</div>
<div class="relative mb-4 lg:mb-4">
<label class="block text-white mb-1" translate="no"
>${translateText("control_panel.troops")}:
<span translate="no">${renderTroops(this._troops)}</span> |
${translateText("control_panel.workers")}:
<span translate="no">${renderTroops(this._workers)}</span></label
>
<label class="flex justify-between text-white mb-1" translate="no">
<span>
${translateText("control_panel.troops")}:
${(this.currentTroopRatio * 100).toFixed(0)}%
</span>
<span>
${translateText("control_panel.workers")}:
${((1 - this.currentTroopRatio) * 100).toFixed(0)}%
</span>
</label>
<div class="relative h-8">
<!-- Background track -->
<div
+65 -2
View File
@@ -32,6 +32,7 @@ import {
import {
CancelAttackIntentEvent,
CancelBoatIntentEvent,
SendAllianceExtensionIntentEvent,
SendAllianceReplyIntentEvent,
} from "../../Transport";
import { Layer } from "./Layer";
@@ -45,7 +46,6 @@ import {
GoToUnitEvent,
} from "./Leaderboard";
import { UserSettings } from "../../../core/game/UserSettings";
import { getMessageTypeClasses, translateText } from "../../Utils";
interface GameEvent {
@@ -73,9 +73,11 @@ export class EventsDisplay extends LitElement implements Layer {
public eventBus: EventBus;
public game: GameView;
private userSettings: UserSettings = new UserSettings();
private active: boolean = false;
private events: GameEvent[] = [];
// allianceID -> last checked at tick
private alliancesCheckedAt = new Map<number, Tick>();
@state() private incomingAttacks: AttackUpdate[] = [];
@state() private outgoingAttacks: AttackUpdate[] = [];
@state() private outgoingLandAttacks: AttackUpdate[] = [];
@@ -152,6 +154,7 @@ export class EventsDisplay extends LitElement implements Layer {
[GameUpdateType.TargetPlayer, (u) => this.onTargetPlayerEvent(u)],
[GameUpdateType.Emoji, (u) => this.onEmojiMessageEvent(u)],
[GameUpdateType.UnitIncoming, (u) => this.onUnitIncomingEvent(u)],
[GameUpdateType.AllianceExpired, (u) => this.onAllianceExpiredEvent(u)],
]);
constructor() {
@@ -181,6 +184,8 @@ export class EventsDisplay extends LitElement implements Layer {
return;
}
this.checkForAllianceExpirations();
const updates = this.game.updatesSinceLastTick();
if (updates) {
for (const [ut, fn] of this.updateMap) {
@@ -234,6 +239,64 @@ export class EventsDisplay extends LitElement implements Layer {
}
}
private checkForAllianceExpirations() {
const myPlayer = this.game.myPlayer();
if (!myPlayer) return;
for (const alliance of myPlayer.alliances()) {
if (
alliance.expiresAt >
this.game.ticks() + this.game.config().allianceExtensionPromptOffset()
) {
continue;
}
if (
(this.alliancesCheckedAt.get(alliance.id) ?? 0) >=
this.game.ticks() - this.game.config().allianceExtensionPromptOffset()
) {
// We've already displayed a message for this alliance.
continue;
}
this.alliancesCheckedAt.set(alliance.id, this.game.ticks());
const other = this.game.player(alliance.other) as PlayerView;
this.addEvent({
description: translateText("events_display.about_to_expire", {
name: other.name(),
}),
type: MessageType.RENEW_ALLIANCE,
duration: this.game.config().allianceExtensionPromptOffset() - 3 * 10, // 3 second buffer
buttons: [
{
text: translateText("events_display.focus"),
className: "btn-gray",
action: () => this.eventBus.emit(new GoToPlayerEvent(other)),
preventClose: true,
},
{
text: translateText("events_display.renew_alliance", {
name: other.name(),
}),
className: "btn",
action: () =>
this.eventBus.emit(new SendAllianceExtensionIntentEvent(other)),
},
{
text: translateText("events_display.ignore"),
className: "btn-info",
action: () => {},
},
],
highlight: true,
createdAt: this.game.ticks(),
focusID: other.smallID(),
});
}
}
private addEvent(event: GameEvent) {
this.events = [...this.events, event];
if (this._hidden === true) {
+7 -6
View File
@@ -6,11 +6,12 @@ import {
RailroadUpdate,
} from "../../../core/game/GameUpdates";
import { GameView, UnitView } from "../../../core/game/GameView";
import { renderNumber } from "../../Utils";
import { AnimatedSpriteLoader } from "../AnimatedSpriteLoader";
import { Fx, FxType } from "../fx/Fx";
import { nukeFxFactory, ShockwaveFx } from "../fx/NukeFx";
import { SpriteFx } from "../fx/SpriteFx";
import { shortenNumber, TextFx } from "../fx/TextFx";
import { TextFx } from "../fx/TextFx";
import { UnitExplosionFx } from "../fx/UnitExplosionFx";
import { Layer } from "./Layer";
export class FxLayer implements Layer {
@@ -69,25 +70,25 @@ export class FxLayer implements Layer {
const workers = bonus.workers;
if (gold > 0) {
const shortened = shortenNumber(gold);
this.addTextFx(`+ ${shortened} gold`, x, y);
const shortened = renderNumber(gold);
this.addTextFx(`+ ${shortened}`, x, y);
y += 10; // increase y so the next popup starts bellow
}
if (troops > 0) {
const shortened = shortenNumber(troops);
const shortened = renderNumber(troops);
this.addTextFx(`+ ${shortened} troops`, x, y);
y += 10;
}
if (workers > 0) {
const shortened = shortenNumber(workers);
const shortened = renderNumber(workers);
this.addTextFx(`+ ${shortened} workers`, x, y);
}
}
addTextFx(text: string, x: number, y: number) {
const textFx = new TextFx(text, x, y, 500, 20);
const textFx = new TextFx(text, x, y, 1000, 20);
this.allFx.push(textFx);
}
+36 -14
View File
@@ -1,12 +1,13 @@
import { Colord } from "colord";
import { html, LitElement } from "lit";
import { customElement, state } from "lit/decorators.js";
import leaderboardRegularIcon from "../../../../resources/images/LeaderboardIconRegularWhite.svg";
import leaderboardSolidIcon from "../../../../resources/images/LeaderboardIconSolidWhite.svg";
import teamRegularIcon from "../../../../resources/images/TeamIconRegularWhite.svg";
import teamSolidIcon from "../../../../resources/images/TeamIconSolidWhite.svg";
import { GameMode } from "../../../core/game/Game";
import { GameView } from "../../../core/game/GameView";
import "../icons/LeaderboardRegularIcon";
import "../icons/LeaderboardSolidIcon";
import "../icons/TeamRegularIcon";
import "../icons/TeamSolidIcon";
import { translateText } from "../../Utils";
import { Layer } from "./Layer";
@customElement("game-left-sidebar")
@@ -66,10 +67,17 @@ export class GameLeftSidebar extends LitElement implements Layer {
return this.game?.config().gameConfig().gameMode === GameMode.Team;
}
private getTranslatedPlayerTeamLabel(): string {
if (!this.playerTeam) return "";
const translationKey = `team_colors.${this.playerTeam.toLowerCase()}`;
const translated = translateText(translationKey);
return translated === translationKey ? this.playerTeam : translated;
}
render() {
return html`
<aside
class=${`fixed top-[50px] lg:top-[10px] left-0 z-[1000] flex flex-col max-h-[calc(100vh-80px)] overflow-y-auto p-2 bg-slate-800/40 backdrop-blur-sm shadow-xs rounded-tr-lg rounded-br-lg transition-transform duration-300 ease-out transform ${
class=${`fixed top-[90px] left-0 z-[1000] flex flex-col max-h-[calc(100vh-80px)] overflow-y-auto p-2 bg-slate-800/40 backdrop-blur-sm shadow-xs rounded-tr-lg rounded-br-lg transition-transform duration-300 ease-out transform ${
this.isVisible ? "translate-x-0" : "-translate-x-full"
}`}
>
@@ -79,18 +87,27 @@ export class GameLeftSidebar extends LitElement implements Layer {
class="flex items-center w-full h-8 lg:h-10 text-white py-1 lg:p-2"
@contextmenu=${(e: Event) => e.preventDefault()}
>
Your team:
${translateText("help_modal.ui_your_team")}
<span style="color: ${this.playerColor.toRgbString()}">
${this.playerTeam} &#10687;
${this.getTranslatedPlayerTeamLabel()} &#10687;
</span>
</div>
`
: null}
<div class="flex items-center gap-2 space-x-2 text-white mb-2">
<div
class=${`flex items-center gap-2 space-x-2 text-white ${
this.isLeaderboardShow || this.isTeamLeaderboardShow ? "mb-2" : ""
}`}
>
<div class="w-6 h-6 cursor-pointer" @click=${this.toggleLeaderboard}>
${this.isLeaderboardShow
? html` <leaderboard-solid-icon></leaderboard-solid-icon>`
: html` <leaderboard-regular-icon></leaderboard-regular-icon>`}
<img
src=${this.isLeaderboardShow
? leaderboardSolidIcon
: leaderboardRegularIcon}
alt="treeIcon"
width="20"
height="20"
/>
</div>
${this.isTeamGame
? html`
@@ -98,9 +115,14 @@ export class GameLeftSidebar extends LitElement implements Layer {
class="w-6 h-6 cursor-pointer"
@click=${this.toggleTeamLeaderboard}
>
${this.isTeamLeaderboardShow
? html` <team-solid-icon></team-solid-icon>`
: html` <team-regular-icon></team-regular-icon>`}
<img
src=${this.isTeamLeaderboardShow
? teamSolidIcon
: teamRegularIcon}
alt="treeIcon"
width="20"
height="20"
/>
</div>
`
: null}
@@ -0,0 +1,139 @@
import { html, LitElement } from "lit";
import { customElement, state } from "lit/decorators.js";
import exitIcon from "../../../../resources/images/ExitIconWhite.svg";
import pauseIcon from "../../../../resources/images/PauseIconWhite.svg";
import playIcon from "../../../../resources/images/PlayIconWhite.svg";
import replayRegularIcon from "../../../../resources/images/ReplayRegularIconWhite.svg";
import replaySolidIcon from "../../../../resources/images/ReplaySolidIconWhite.svg";
import { EventBus } from "../../../core/EventBus";
import { GameType } from "../../../core/game/Game";
import { GameView } from "../../../core/game/GameView";
import { PauseGameEvent } from "../../Transport";
import { Layer } from "./Layer";
@customElement("game-right-sidebar")
export class GameRightSidebar extends LitElement implements Layer {
public game: GameView;
public eventBus: EventBus;
@state()
private _isSinglePlayer: boolean = false;
@state()
private _isReplayVisible: boolean = false;
@state()
private _isVisible: boolean = true;
@state()
private isPaused: boolean = false;
@state()
private isExistButtonVisible: boolean = true;
createRenderRoot() {
return this;
}
init() {
this._isSinglePlayer =
this.game?.config()?.gameConfig()?.gameType === GameType.Singleplayer ||
this.game.config().isReplay();
this._isVisible = true;
this.game.inSpawnPhase();
this.requestUpdate();
}
tick() {
if (!this.game.inSpawnPhase()) {
this.isExistButtonVisible = false;
}
}
private toggleReplayPanel(): void {
this._isReplayVisible = !this._isReplayVisible;
}
private onPauseButtonClick() {
this.isPaused = !this.isPaused;
this.eventBus.emit(new PauseGameEvent(this.isPaused));
}
private onExitButtonClick() {
const isAlive = this.game.myPlayer()?.isAlive();
if (isAlive) {
const isConfirmed = confirm("Are you sure you want to exit the game?");
if (!isConfirmed) return;
}
// redirect to the home page
window.location.href = "/";
}
render() {
return html`
<aside
class=${`fixed top-[90px] right-0 z-[1000] flex flex-col max-h-[calc(100vh-80px)] overflow-y-auto p-2 bg-slate-800/40 backdrop-blur-sm shadow-xs rounded-tl-lg rounded-bl-lg transition-transform duration-300 ease-out transform ${
this._isVisible ? "translate-x-0" : "translate-x-full"
}`}
@contextmenu=${(e: Event) => e.preventDefault()}
>
<div
class=${`flex justify-end items-center gap-2 text-white ${
this._isReplayVisible ? "mb-2" : ""
}`}
>
${this._isSinglePlayer || this.game?.config()?.isReplay()
? html`
<div
class="w-6 h-6 cursor-pointer"
@click=${this.toggleReplayPanel}
>
<img
src=${this._isReplayVisible
? replaySolidIcon
: replayRegularIcon}
alt="replay"
width="20"
height="20"
style="vertical-align: middle;"
/>
</div>
<div
class="w-6 h-6 cursor-pointer"
@click=${this.onPauseButtonClick}
>
<img
src=${this.isPaused ? playIcon : pauseIcon}
alt="play/pause"
width="20"
height="20"
style="vertical-align: middle;"
/>
</div>
${this.isExistButtonVisible
? html`
<div
class="w-6 h-6 cursor-pointer"
@click=${this.onExitButtonClick}
>
<img
src=${exitIcon}
alt="exit"
width="20"
height="20"
/>
</div>
`
: null}
`
: null}
</div>
<div class="block lg:flex flex-wrap gap-2">
<replay-panel
.isSingleplayer="${this._isSinglePlayer}"
.visible="${this._isReplayVisible}"
></replay-panel>
</div>
</aside>
`;
}
}
+471
View File
@@ -0,0 +1,471 @@
import { html, LitElement } from "lit";
import { customElement, query, state } from "lit/decorators.js";
import cityIcon from "../../../../resources/images/CityIconWhite.svg";
import darkModeIcon from "../../../../resources/images/DarkModeIconWhite.svg";
import emojiIcon from "../../../../resources/images/EmojiIconWhite.svg";
import exitIcon from "../../../../resources/images/ExitIconWhite.svg";
import explosionIcon from "../../../../resources/images/ExplosionIconWhite.svg";
import factoryIcon from "../../../../resources/images/FactoryIconWhite.svg";
import goldCoinIcon from "../../../../resources/images/GoldCoinIcon.svg";
import missileSiloIcon from "../../../../resources/images/MissileSiloUnit.png";
import mouseIcon from "../../../../resources/images/MouseIconWhite.svg";
import ninjaIcon from "../../../../resources/images/NinjaIconWhite.svg";
import populationIcon from "../../../../resources/images/PopulationIconSolidWhite.svg";
import portIcon from "../../../../resources/images/PortIcon.svg";
import samLauncherIcon from "../../../../resources/images/SamLauncherUnitWhite.png";
import settingsIcon from "../../../../resources/images/SettingIconWhite.svg";
import defensePostIcon from "../../../../resources/images/ShieldIconWhite.svg";
import treeIcon from "../../../../resources/images/TreeIconWhite.svg";
import troopIcon from "../../../../resources/images/TroopIconWhite.svg";
import workerIcon from "../../../../resources/images/WorkerIconWhite.svg";
import { translateText } from "../../../client/Utils";
import { EventBus } from "../../../core/EventBus";
import { UnitType } from "../../../core/game/Game";
import { GameUpdateType } from "../../../core/game/GameUpdates";
import { GameView } from "../../../core/game/GameView";
import { UserSettings } from "../../../core/game/UserSettings";
import { AlternateViewEvent, RefreshGraphicsEvent } from "../../InputHandler";
import { renderNumber, renderTroops } from "../../Utils";
import { Layer } from "./Layer";
@customElement("game-top-bar")
export class GameTopBar extends LitElement implements Layer {
public game: GameView;
public eventBus: EventBus;
private _userSettings: UserSettings = new UserSettings();
private _population = 0;
private _troops = 0;
private _cities = 0;
private _factories = 0;
private _workers = 0;
private _missileSilo = 0;
private _port = 0;
private _defensePost = 0;
private _samLauncher = 0;
private _lastPopulationIncreaseRate = 0;
private _popRateIsIncreasing = false;
private hasWinner = false;
@state()
private showSettingsMenu = false;
@state()
private alternateView: boolean = false;
@state()
private timer: number = 0;
@query(".settings-container")
private settingsContainer!: HTMLElement;
createRenderRoot() {
return this;
}
init() {
this.requestUpdate();
}
tick() {
this.updatePopulationIncrease();
const player = this.game?.myPlayer();
if (!player) return;
this._troops = player.troops();
this._workers = player.workers();
this._cities = player.totalUnitLevels(UnitType.City);
this._missileSilo = player.totalUnitLevels(UnitType.MissileSilo);
this._port = player.totalUnitLevels(UnitType.Port);
this._defensePost = player.totalUnitLevels(UnitType.DefensePost);
this._samLauncher = player.totalUnitLevels(UnitType.SAMLauncher);
this._factories = player.totalUnitLevels(UnitType.Factory);
const updates = this.game.updatesSinceLastTick();
if (updates) {
this.hasWinner = this.hasWinner || updates[GameUpdateType.Win].length > 0;
}
if (this.game.inSpawnPhase()) {
this.timer = 0;
} else if (!this.hasWinner && this.game.ticks() % 10 === 0) {
this.timer++;
}
this.requestUpdate();
}
connectedCallback() {
super.connectedCallback();
window.addEventListener("click", this.handleOutsideClick, true);
}
disconnectedCallback() {
window.removeEventListener("click", this.handleOutsideClick, true);
super.disconnectedCallback();
}
private handleOutsideClick = (event: MouseEvent) => {
if (
this.showSettingsMenu &&
this.settingsContainer &&
!this.settingsContainer.contains(event.target as Node)
) {
this.showSettingsMenu = false;
}
};
private onExitButtonClick() {
const isAlive = this.game.myPlayer()?.isAlive();
if (isAlive) {
const isConfirmed = confirm("Are you sure you want to exit the game?");
if (!isConfirmed) return;
}
// redirect to the home page
window.location.href = "/";
}
private onTerrainButtonClick() {
this.alternateView = !this.alternateView;
this.eventBus.emit(new AlternateViewEvent(this.alternateView));
this.requestUpdate();
}
private onToggleEmojisButtonClick() {
this._userSettings.toggleEmojis();
this.requestUpdate();
}
private onToggleSpecialEffectsButtonClick() {
this._userSettings.toggleFxLayer();
this.requestUpdate();
}
private onToggleDarkModeButtonClick() {
this._userSettings.toggleDarkMode();
this.requestUpdate();
this.eventBus.emit(new RefreshGraphicsEvent());
}
private onToggleRandomNameModeButtonClick() {
this._userSettings.toggleRandomName();
}
private onToggleLeftClickOpensMenu() {
this._userSettings.toggleLeftClickOpenMenu();
}
private toggleSettingsMenu() {
this.showSettingsMenu = !this.showSettingsMenu;
}
private updatePopulationIncrease() {
const player = this.game?.myPlayer();
if (player === null) return;
const popIncreaseRate = player.population() - this._population;
if (this.game.ticks() % 5 === 0) {
this._popRateIsIncreasing =
popIncreaseRate >= this._lastPopulationIncreaseRate;
this._lastPopulationIncreaseRate = popIncreaseRate;
}
}
private secondsToHms = (d: number): string => {
const h = Math.floor(d / 3600);
const m = Math.floor((d % 3600) / 60);
const s = Math.floor((d % 3600) % 60);
let time = d === 0 ? "-" : `${s}s`;
if (m > 0) time = `${m}m` + time;
if (h > 0) time = `${h}h` + time;
return time;
};
render() {
const myPlayer = this.game?.myPlayer();
if (!this.game || !myPlayer || this.game.inSpawnPhase()) {
return null;
}
const isAlt = this.game.config().isReplay();
if (isAlt) {
return html`
<div
class="fixed top-0 left-auto right-0 z-[1100] bg-slate-800/40 backdrop-blur-sm p-2 flex justify-center items-center"
>
<div
class="w-[70px] h-8 lg:w-24 lg:h-10 border border-slate-400 p-0.5 text-xs md:text-sm lg:text-base flex items-center text-white px-1"
>
${this.secondsToHms(this.timer)}
</div>
</div>
`;
}
const popRate = myPlayer
? this.game.config().populationIncreaseRate(myPlayer) * 10
: 0;
const maxPop = myPlayer ? this.game.config().maxPopulation(myPlayer) : 0;
const goldPerSecond = myPlayer
? this.game.config().goldAdditionRate(myPlayer) * 10n
: 0n;
return html`
<div
class="fixed top-0 min-h-[50px] lg:min-h-[80px] z-[1100] flex flex-wrap bg-slate-800/40 backdrop-blur-sm shadow-xs text-white text-xs md:text-sm lg:text-base left-0 right-0 grid-cols-4 p-1 md:px-1.5 lg:px-4"
>
<div
class="flex flex-1 basis-full justify-between items-center gap-1 w-full"
>
${myPlayer?.isAlive() && !this.game.inSpawnPhase()
? html`
<div class="overflow-x-auto hide-scrollbar flex-1 max-w-[85vw]">
<div
class="grid gap-1 grid-cols-[80px_100px_80px_minmax(80px,auto)] w-max md:gap-2 md:grid-cols-[90px_120px_90px_minmax(100px,auto)]"
>
<div
class="flex flex-wrap gap-1 flex-col bg-slate-800/20 border border-slate-400 p-0.5 md:px-1 lg:px-2"
>
<div class="flex gap-2 items-center justify-between">
<img
src=${goldCoinIcon}
alt="gold"
width="20"
height="20"
style="vertical-align: middle;"
/>
+${renderNumber(goldPerSecond)}
</div>
<div>${renderNumber(myPlayer.gold())}</div>
</div>
<div
class="flex flex-wrap gap-1 flex-col bg-slate-800/20 border border-slate-400 p-0.5 md:px-1 lg:px-2"
>
<div class="flex gap-2 items-center justify-between">
<img
src=${populationIcon}
alt="population"
width="20"
height="20"
style="vertical-align: middle;"
/>
<span
class="${this._popRateIsIncreasing
? "text-green-500"
: "text-yellow-500"}"
translate="no"
>
+${renderTroops(popRate)}
</span>
</div>
<div>
${renderTroops(myPlayer.population())} /
${renderTroops(maxPop)}
</div>
</div>
<div
class="flex bg-slate-800/20 border border-slate-400 p-0.5 md:px-1 lg:px-2"
>
<div class="flex flex-col flex-grow gap-1 w-full ">
<div class="flex gap-1">
<img
src=${troopIcon}
alt="troops"
width="20"
height="20"
style="vertical-align: middle;"
/>
${renderTroops(this._troops)}
</div>
<div class="flex gap-1">
<img
src=${workerIcon}
alt="gold"
width="20"
height="20"
style="vertical-align: middle;"
/>
${renderTroops(this._workers)}
</div>
</div>
</div>
<div
class="grid grid-rows-1 auto-cols-max grid-flow-col gap-1 bg-slate-800/20 border border-slate-400 p-0.5 md:px-1 lg:px-2 md:gap-2"
>
<div class="flex items-center gap-2">
<img
src=${cityIcon}
alt="gold"
width="20"
height="20"
style="vertical-align: middle;"
/>
${renderNumber(this._cities)}
</div>
<div class="flex items-center gap-2">
<img
src=${factoryIcon}
alt="gold"
width="20"
height="20"
style="vertical-align: middle;"
/>
${renderNumber(this._factories)}
</div>
<div class="flex items-center gap-2">
<img
src=${portIcon}
alt="gold"
width="20"
height="20"
style="vertical-align: middle;"
/>
${renderNumber(this._port)}
</div>
<div class="flex items-center gap-2">
<img
src=${defensePostIcon}
alt="gold"
width="20"
height="20"
style="vertical-align: middle;"
/>
${renderNumber(this._defensePost)}
</div>
<div class="flex items-center gap-2">
<img
src=${missileSiloIcon}
alt="gold"
width="20"
height="20"
style="vertical-align: middle;"
/>
${renderNumber(this._missileSilo)}
</div>
<div class="flex items-center gap-2">
<img
src=${samLauncherIcon}
alt="gold"
width="20"
height="20"
style="vertical-align: middle;"
/>
${renderNumber(this._samLauncher)}
</div>
</div>
</div>
</div>
`
: html`<div></div>`}
<div class="flex gap-1 items-center">
<div
class="w-[70px] h-8 lg:w-24 lg:h-10 border border-slate-400 p-0.5 text-xs md:text-sm lg:text-base flex items-center px-1"
>
${this.secondsToHms(this.timer)}
</div>
<div class="relative settings-container">
<img
class="cursor-pointer bg-slate-800/20 border border-slate-400 p-0.5"
src=${settingsIcon}
alt="settings"
width="28"
height="28"
style="vertical-align: middle;"
@click=${this.toggleSettingsMenu}
/>
${this.showSettingsMenu
? html`
<div
class="absolute right-0 mt-1.5 bg-slate-700 border border-slate-500 rounded shadow-lg z-[1100] w-max min-w-[10rem] whitespace-nowrap"
>
<button
class="flex gap-1 items-center w-full text-left px-2 py-1 hover:bg-slate-600 text-white text-sm"
@click="${this.onTerrainButtonClick}"
>
<img
src=${treeIcon}
alt="treeIcon"
width="20"
height="20"
/>
Toggle Terrain ${this.alternateView ? "On" : "Off"}
</button>
<button
class="flex gap-1 items-center w-full text-left px-2 py-1 hover:bg-slate-600 text-white text-sm"
@click="${this.onToggleEmojisButtonClick}"
>
<img
src=${emojiIcon}
alt="emojiIcon"
width="20"
height="20"
/>
${translateText("user_setting.emojis_label")}
${this._userSettings.emojis() ? "On" : "Off"}
</button>
<button
class="flex gap-1 items-center w-full text-left px-2 py-1 hover:bg-slate-600 text-white text-sm"
@click="${this.onToggleDarkModeButtonClick}"
>
<img
src=${darkModeIcon}
alt="darkModeIcon"
width="20"
height="20"
/>
${translateText("user_setting.dark_mode_label")}
${this._userSettings.darkMode() ? "On" : "Off"}
</button>
<button
class="flex gap-1 items-center w-full text-left px-2 py-1 hover:bg-slate-600 text-white text-sm"
@click="${this.onToggleSpecialEffectsButtonClick}"
>
<img
src=${explosionIcon}
alt="onExitButtonClick"
width="20"
height="20"
/>
${translateText("user_setting.special_effects_label")}
${this._userSettings.fxLayer() ? "On" : "Off"}
</button>
<button
class="flex gap-1 items-center w-full text-left px-2 py-1 hover:bg-slate-600 text-white text-sm"
@click="${this.onToggleRandomNameModeButtonClick}"
>
<img
src=${ninjaIcon}
alt="ninjaIcon"
width="20"
height="20"
/>
${translateText("user_setting.anonymous_names_label")}
${this._userSettings.anonymousNames() ? "On" : "Off"}
</button>
<button
class="flex gap-1 items-center w-full text-left px-2 py-1 hover:bg-slate-600 text-white text-sm"
@click="${this.onToggleLeftClickOpensMenu}"
>
<img
src=${mouseIcon}
alt="mouseIcon"
width="20"
height="20"
/>
Left click
${this._userSettings.leftClickOpensMenu()
? "On"
: "Off"}
</button>
<button
class="flex gap-1 items-center w-full text-left px-2 py-1 hover:bg-slate-600 text-white text-sm"
@click="${this.onExitButtonClick}"
>
<img
src=${exitIcon}
alt="exitIcon"
width="20"
height="20"
/>
Exit game
</button>
</div>
`
: null}
</div>
</div>
</div>
</div>
`;
}
}
+2 -2
View File
@@ -34,8 +34,8 @@ export class HeadsUpMessage extends LitElement implements Layer {
return html`
<div
class="flex items-center
w-full justify-evenly h-8 lg:h-10 top-0 lg:top-4 left-0 lg:left-4
class="flex items-center relative
w-full justify-evenly h-8 lg:h-10 md:top-[70px] left-0 lg:left-4
bg-opacity-60 bg-gray-900 rounded-md lg:rounded-lg
backdrop-blur-md text-white text-md lg:text-xl p-1 lg:p-2"
@contextmenu=${(e) => e.preventDefault()}
+3 -1
View File
@@ -187,7 +187,9 @@ export class Leaderboard extends LitElement implements Layer {
this.updateLeaderboard();
}}
>
${this.showTopFive ? "Show All" : "Show Top 5"}
${this.showTopFive
? translateText("leaderboard.show_all")
: translateText("leaderboard.show_top_5")}
</button>
<div
@@ -127,6 +127,7 @@ export class MainRadialMenu extends LitElement implements Layer {
playerPanel: this.playerPanel,
chatIntegration: this.chatIntegration,
closeMenu: () => this.closeMenu(),
eventBus: this.eventBus,
};
this.radialMenu.setRootMenuItems(rootMenuItems, centerButtonElement);
+2 -2
View File
@@ -198,8 +198,8 @@ export class NameLayer implements Layer {
element.style.aspectRatio = "3/4";
};
if (player.flag()) {
const flag = player.flag();
if (player.cosmetics.flag) {
const flag = player.cosmetics.flag;
if (flag !== undefined && flag !== null && flag.startsWith("!")) {
const flagWrapper = document.createElement("div");
applyFlagStyles(flagWrapper);
@@ -1,14 +1,8 @@
import { EventBus } from "../../../core/EventBus";
import {
Cell,
PlayerActions,
PlayerID,
UnitType,
} from "../../../core/game/Game";
import { Cell, PlayerActions, PlayerID } from "../../../core/game/Game";
import { TileRef } from "../../../core/game/GameMap";
import { PlayerView } from "../../../core/game/GameView";
import {
BuildUnitIntentEvent,
SendAllianceRequestIntentEvent,
SendAttackIntentEvent,
SendBoatAttackIntentEvent,
@@ -67,13 +61,6 @@ export class PlayerActionHandler {
): Promise<TileRef | false> {
return await player.bestTransportShipSpawn(tile);
}
handleBuildUnit(unitType: UnitType, cellX: number, cellY: number) {
this.eventBus.emit(
new BuildUnitIntentEvent(unitType, new Cell(cellX, cellY)),
);
}
handleSpawn(spawnCell: Cell) {
this.eventBus.emit(new SendSpawnIntentEvent(spawnCell));
}
@@ -207,21 +207,21 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
? "text-green-500"
: "text-white"}"
>
${player.flag()
? player.flag()!.startsWith("!")
${player.cosmetics.flag
? player.cosmetics.flag!.startsWith("!")
? html`<div
class="h-8 mr-1 aspect-[3/4] player-flag"
${ref((el) => {
if (el instanceof HTMLElement) {
requestAnimationFrame(() => {
renderPlayerFlag(player.flag()!, el);
renderPlayerFlag(player.cosmetics.flag!, el);
});
}
})}
></div>`
: html`<img
class="h-8 mr-1 aspect-[3/4]"
src=${"/flags/" + player.flag()! + ".svg"}
src=${"/flags/" + player.cosmetics.flag! + ".svg"}
/>`
: html``}
${player.name()}
@@ -352,11 +352,11 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
return html`
<div
class="flex w-full z-50 flex-col"
class="hidden lg:flex fixed top-[245px] right-0 w-full z-50 flex-col max-w-[180px]"
@contextmenu=${(e) => e.preventDefault()}
>
<div
class="bg-slate-800/40 backdrop-blur-sm shadow-xs rounded-lg shadow-lg backdrop-blur-sm transition-all duration-300 text-white text-lg md:text-base ${containerClasses}"
class="bg-slate-800/40 backdrop-blur-sm shadow-xs rounded-lg shadow-lg transition-all duration-300 text-white text-lg md:text-base ${containerClasses}"
>
${this.player !== null ? this.renderPlayerInfo(this.player) : ""}
${this.unit !== null ? this.renderUnitInfo(this.unit) : ""}
+3 -5
View File
@@ -176,11 +176,9 @@ export class PlayerPanel extends LitElement implements Layer {
if (myPlayer !== null && myPlayer.isAlive()) {
this.actions = await myPlayer.actions(this.tile);
if (this.actions?.interaction?.allianceCreatedAtTick !== undefined) {
const createdAt = this.actions.interaction.allianceCreatedAtTick;
const durationTicks = this.g.config().allianceDuration();
const expiryTick = createdAt + durationTicks;
const remainingTicks = expiryTick - this.g.ticks();
if (this.actions?.interaction?.allianceExpiresAt !== undefined) {
const expiresAt = this.actions.interaction.allianceExpiresAt;
const remainingTicks = expiresAt - this.g.ticks();
if (remainingTicks > 0) {
const remainingSeconds = Math.max(
-30
View File
@@ -381,36 +381,6 @@ export class RadialMenu implements Layer {
path.attr("filter", "url(#glow)");
path.attr("stroke-width", "3");
const color = disabled
? this.config.disabledColor
: d.data.color || "#333333";
path.attr("fill", color);
const subMenu =
this.params !== null ? d.data.subMenu?.(this.params) : null;
if (
subMenu &&
subMenu.length > 0 &&
!disabled &&
!(
this.currentLevel > 0 &&
d.data.id === this.selectedItemId &&
level === 0
)
) {
if (this.submenuHoverTimeout !== null) {
window.clearTimeout(this.submenuHoverTimeout);
}
// Set a small delay before opening submenu to prevent accidental triggers
this.submenuHoverTimeout = window.setTimeout(() => {
if (this.navigationInProgress) return;
this.navigationInProgress = true;
this.selectedItemId = d.data.id;
this.navigateToSubMenu(subMenu);
this.updateCenterButtonState("back");
}, 200);
}
};
const onMouseOut = (d: d3.PieArcDatum<MenuElement>, path: any) => {
@@ -1,3 +1,4 @@
import { Config } from "../../../core/configuration/Config";
import {
AllPlayers,
Cell,
@@ -25,6 +26,7 @@ import emojiIcon from "../../../../resources/images/EmojiIconWhite.svg";
import infoIcon from "../../../../resources/images/InfoIcon.svg";
import targetIcon from "../../../../resources/images/TargetIconWhite.svg";
import traitorIcon from "../../../../resources/images/TraitorIconWhite.svg";
import { EventBus } from "../../../core/EventBus";
export interface MenuElementParams {
myPlayer: PlayerView;
@@ -37,6 +39,7 @@ export interface MenuElementParams {
playerActionHandler: PlayerActionHandler;
playerPanel: PlayerPanel;
chatIntegration: ChatIntegration;
eventBus: EventBus;
closeMenu: () => void;
}
@@ -322,6 +325,32 @@ export const infoMenuElement: MenuElement = {
},
};
function getAllEnabledUnits(myPlayer: boolean, config: Config): Set<UnitType> {
const Units: Set<UnitType> = new Set<UnitType>();
const addStructureIfEnabled = (unitType: UnitType) => {
if (!config.isUnitDisabled(unitType)) {
Units.add(unitType);
}
};
if (myPlayer) {
addStructureIfEnabled(UnitType.City);
addStructureIfEnabled(UnitType.DefensePost);
addStructureIfEnabled(UnitType.Port);
addStructureIfEnabled(UnitType.MissileSilo);
addStructureIfEnabled(UnitType.SAMLauncher);
addStructureIfEnabled(UnitType.Factory);
} else {
addStructureIfEnabled(UnitType.Warship);
addStructureIfEnabled(UnitType.HydrogenBomb);
addStructureIfEnabled(UnitType.MIRV);
addStructureIfEnabled(UnitType.AtomBomb);
}
return Units;
}
export const buildMenuElement: MenuElement = {
id: Slot.Build,
name: "build",
@@ -332,21 +361,10 @@ export const buildMenuElement: MenuElement = {
subMenu: (params: MenuElementParams) => {
if (params === undefined) return [];
const unitTypes: Set<UnitType> = new Set<UnitType>();
if (params.selected === params.myPlayer) {
unitTypes.add(UnitType.City);
unitTypes.add(UnitType.DefensePost);
unitTypes.add(UnitType.Port);
unitTypes.add(UnitType.MissileSilo);
unitTypes.add(UnitType.SAMLauncher);
unitTypes.add(UnitType.Factory);
} else {
unitTypes.add(UnitType.Warship);
unitTypes.add(UnitType.HydrogenBomb);
unitTypes.add(UnitType.MIRV);
unitTypes.add(UnitType.AtomBomb);
}
const unitTypes: Set<UnitType> = getAllEnabledUnits(
params.selected === params.myPlayer,
params.game.config(),
);
const buildElements: MenuElement[] = flattenedBuildTable
.filter((item) => unitTypes.has(item.unitType))
.map((item: BuildItemDisplay) => ({
@@ -355,8 +373,10 @@ export const buildMenuElement: MenuElement = {
? item.key.replace("unit_type.", "")
: item.unitType.toString(),
disabled: (params: MenuElementParams) =>
!params.buildMenu.canBuild(item),
color: params.buildMenu.canBuild(item) ? COLORS.building : undefined,
!params.buildMenu.canBuildOrUpgrade(item),
color: params.buildMenu.canBuildOrUpgrade(item)
? COLORS.building
: undefined,
icon: item.icon,
tooltipItems: [
{ text: translateText(item.key || ""), className: "title" },
@@ -373,11 +393,15 @@ export const buildMenuElement: MenuElement = {
: null,
].filter((item): item is TooltipItem => item !== null),
action: (params: MenuElementParams) => {
params.playerActionHandler.handleBuildUnit(
item.unitType,
params.game.x(params.tile),
params.game.y(params.tile),
const buildableUnit = params.playerActions.buildableUnits.find(
(bu) => bu.type === item.unitType,
);
if (buildableUnit === undefined) {
return;
}
if (params.buildMenu.canBuildOrUpgrade(item)) {
params.buildMenu.sendBuildOrUpgrade(buildableUnit, params.tile);
}
params.closeMenu();
},
}));
+35 -77
View File
@@ -1,7 +1,6 @@
import { html, LitElement } from "lit";
import { customElement, state } from "lit/decorators.js";
import { customElement, property, state } from "lit/decorators.js";
import { EventBus } from "../../../core/EventBus";
import { GameType } from "../../../core/game/Game";
import { GameView } from "../../../core/game/GameView";
import { ReplaySpeedChangeEvent } from "../../InputHandler";
import {
@@ -16,27 +15,26 @@ export class ReplayPanel extends LitElement implements Layer {
public game: GameView | undefined;
public eventBus: EventBus | undefined;
@property({ type: Boolean })
visible: boolean = false;
@state()
private _replaySpeedMultiplier: number = defaultReplaySpeedMultiplier;
private _isSinglePlayer: boolean = false;
@state()
private _isVisible = false;
@property({ type: Boolean })
isSingleplayer = false;
init() {
this._isSinglePlayer =
this.game?.config().gameConfig().gameType === GameType.Singleplayer;
if (this._isSinglePlayer) {
this.setVisible(true);
}
createRenderRoot() {
return this; // Enable Tailwind CSS
}
tick() {
if (!this._isVisible && this.game?.config().isReplay()) {
this.setVisible(true);
}
init() {}
this.requestUpdate();
tick() {
if (!this.visible) return;
if (this.game!.ticks() % 10 === 0) {
this.requestUpdate();
}
}
onReplaySpeedChange(value: ReplaySpeedMultiplier) {
@@ -44,85 +42,45 @@ export class ReplayPanel extends LitElement implements Layer {
this.eventBus?.emit(new ReplaySpeedChangeEvent(value));
}
renderLayer(context: CanvasRenderingContext2D) {
// Render any necessary canvas elements
}
shouldTransform(): boolean {
renderLayer(_ctx: CanvasRenderingContext2D) {}
shouldTransform() {
return false;
}
setVisible(visible: boolean) {
this._isVisible = visible;
this.requestUpdate();
}
render() {
if (!this._isVisible) {
return html``;
}
if (!this.visible) return html``;
return html`
<div
class="bg-opacity-60 bg-gray-900 p-1 lg:p-2 rounded-es-sm lg:rounded-lg backdrop-blur-md"
@contextmenu=${(e) => e.preventDefault()}
@contextmenu=${(e: Event) => e.preventDefault()}
>
<label class="block mb-1 text-white" translate="no">
${this._isSinglePlayer
${this.isSingleplayer
? translateText("replay_panel.game_speed")
: translateText("replay_panel.replay_speed")}
</label>
<div class="grid grid-cols-2 gap-1">
<button
class="text-white font-bold py-0 rounded border transition ${this
._replaySpeedMultiplier === ReplaySpeedMultiplier.slow
? "bg-blue-500 border-gray-400"
: "border-gray-500"}"
@click=${() => {
this.onReplaySpeedChange(ReplaySpeedMultiplier.slow);
}}
>
×0.5
</button>
<button
class="text-white font-bold py-0 rounded border transition ${this
._replaySpeedMultiplier === ReplaySpeedMultiplier.normal
? "bg-blue-500 border-gray-400"
: "border-gray-500"}"
@click=${() => {
this.onReplaySpeedChange(ReplaySpeedMultiplier.normal);
}}
>
×1
</button>
<button
class="text-white font-bold py-0 rounded border transition ${this
._replaySpeedMultiplier === ReplaySpeedMultiplier.fast
? "bg-blue-500 border-gray-400"
: "border-gray-500"}"
@click=${() => {
this.onReplaySpeedChange(ReplaySpeedMultiplier.fast);
}}
>
×2
</button>
<button
class="text-white font-bold py-0 rounded border transition ${this
._replaySpeedMultiplier === ReplaySpeedMultiplier.fastest
? "bg-blue-500 border-gray-400"
: "border-gray-500"}"
@click=${() => {
this.onReplaySpeedChange(ReplaySpeedMultiplier.fastest);
}}
>
max
</button>
${this.renderSpeedButton(ReplaySpeedMultiplier.slow, "×0.5")}
${this.renderSpeedButton(ReplaySpeedMultiplier.normal, "×1")}
${this.renderSpeedButton(ReplaySpeedMultiplier.fast, "×2")}
${this.renderSpeedButton(ReplaySpeedMultiplier.fastest, "max")}
</div>
</div>
`;
}
createRenderRoot() {
return this; // Disable shadow DOM to allow Tailwind styles
private renderSpeedButton(value: ReplaySpeedMultiplier, label: string) {
const isActive = this._replaySpeedMultiplier === value;
return html`
<button
class="text-white font-bold py-0 rounded border transition ${isActive
? "bg-blue-500 border-gray-400"
: "border-gray-500"}"
@click=${() => this.onReplaySpeedChange(value)}
>
${label}
</button>
`;
}
}
+23 -12
View File
@@ -16,8 +16,11 @@ export class SpawnTimer implements Layer {
tick() {
if (this.game.inSpawnPhase()) {
this.ratios[0] =
this.game.ticks() / this.game.config().numSpawnPhaseTurns();
// During spawn phase, only one segment filling full width
this.ratios = [
this.game.ticks() / this.game.config().numSpawnPhaseTurns(),
];
this.colors = ["rgba(0, 128, 255, 0.7)"];
return;
}
@@ -33,18 +36,17 @@ export class SpawnTimer implements Layer {
const team = player.team();
if (team === null) throw new Error("Team is null");
const tiles = teamTiles.get(team) ?? 0;
const sum = tiles + player.numTilesOwned();
teamTiles.set(team, sum);
teamTiles.set(team, tiles + player.numTilesOwned());
}
const theme = this.game.config().theme();
const total = sumIterator(teamTiles.values());
if (total === 0) return;
for (const [team, count] of teamTiles) {
const ratio = count / total;
const color = theme.teamColor(team).toRgbString();
this.ratios.push(ratio);
this.colors.push(color);
this.colors.push(theme.teamColor(team).toRgbString());
}
}
@@ -53,12 +55,23 @@ export class SpawnTimer implements Layer {
}
renderLayer(context: CanvasRenderingContext2D) {
if (this.ratios === null) return;
if (this.ratios.length === 0) return;
if (this.colors.length === 0) return;
if (this.ratios.length === 0 || this.colors.length === 0) return;
const barHeight = 10;
const barWidth = this.transformHandler.width();
let yOffset: number;
if (this.game.inSpawnPhase()) {
// At spawn time, draw at top
yOffset = 0;
} else if (this.game.config().gameConfig().gameMode === GameMode.Team) {
// After spawn, only in team mode, offset based on screen width
const screenW = window.innerWidth;
yOffset = screenW > 1024 ? 80 : 58;
} else {
// Not spawn and not team mode: no bar
return;
}
let x = 0;
let filledRatio = 0;
@@ -67,7 +80,7 @@ export class SpawnTimer implements Layer {
const segmentWidth = barWidth * ratio;
context.fillStyle = this.colors[i];
context.fillRect(x, 0, segmentWidth, barHeight);
context.fillRect(x, yOffset, segmentWidth, barHeight);
x += segmentWidth;
filledRatio += ratio;
@@ -76,8 +89,6 @@ export class SpawnTimer implements Layer {
}
function sumIterator(values: MapIterator<number>) {
// To use reduce, we'd need to allocate an array:
// return Array.from(values).reduce((sum, v) => sum + v, 0);
let total = 0;
for (const value of values) {
total += value;
+226 -78
View File
@@ -1,4 +1,5 @@
import * as PIXI from "pixi.js";
import bitmapFont from "../../../../resources/fonts/round_6x6_modified.xml";
import anchorIcon from "../../../../resources/images/AnchorIcon.png";
import cityIcon from "../../../../resources/images/CityIcon.png";
import factoryIcon from "../../../../resources/images/FactoryUnit.png";
@@ -17,16 +18,20 @@ class StructureRenderInfo {
constructor(
public unit: UnitView,
public owner: PlayerID,
public pixiSprite: PIXI.Sprite,
public iconContainer: PIXI.Container,
public levelContainer: PIXI.Container,
public level: number = 0,
public underConstruction: boolean = true,
) {}
}
const ZOOM_THRESHOLD = 2.8; // below this zoom level, structures are not rendered
const ZOOM_THRESHOLD = 2.5;
const ICON_SIZE = 24;
const OFFSET_ZOOM_Y = 15; // offset for the y position of the icon to avoid hiding the structure beneath
const OFFSET_ZOOM_Y = 5; // offset for the y position of the icon to avoid hiding the structure beneath
export class StructureIconsLayer implements Layer {
private pixicanvas: HTMLCanvasElement;
private stage: PIXI.Container;
private iconsStage: PIXI.Container;
private levelsStage: PIXI.Container;
private shouldRedraw: boolean = true;
private textureCache: Map<string, PIXI.Texture> = new Map();
private theme: Theme;
@@ -54,14 +59,26 @@ export class StructureIconsLayer implements Layer {
}
async setupRenderer() {
try {
await PIXI.Assets.load(bitmapFont);
} catch (error) {
console.error("Failed to load bitmap font:", error);
}
this.renderer = new PIXI.WebGLRenderer();
this.pixicanvas = document.createElement("canvas");
this.pixicanvas.width = window.innerWidth;
this.pixicanvas.height = window.innerHeight;
this.stage = new PIXI.Container();
this.stage.position.set(0, 0);
this.stage.width = this.pixicanvas.width;
this.stage.height = this.pixicanvas.height;
this.iconsStage = new PIXI.Container();
this.iconsStage.position.set(0, 0);
this.iconsStage.width = this.pixicanvas.width;
this.iconsStage.height = this.pixicanvas.height;
this.levelsStage = new PIXI.Container();
this.levelsStage.position.set(0, 0);
this.levelsStage.width = this.pixicanvas.width;
this.levelsStage.height = this.pixicanvas.height;
await this.renderer.init({
canvas: this.pixicanvas,
resolution: 1,
@@ -103,7 +120,7 @@ export class StructureIconsLayer implements Layer {
}
resizeCanvas() {
if (this.renderer.view) {
if (this.renderer) {
this.pixicanvas.width = window.innerWidth;
this.pixicanvas.height = window.innerHeight;
this.renderer.resize(innerWidth, innerHeight, 1);
@@ -119,47 +136,84 @@ export class StructureIconsLayer implements Layer {
if (unitView === undefined) return;
if (unitView.isActive()) {
if (this.seenUnits.has(unitView)) {
// check if owner has changed
const render = this.renders.find(
(r) => r.unit.id() === unitView.id(),
);
if (render) {
this.ownerChangeCheck(render, unitView);
}
} else if (this.structures.has(unitView.type())) {
// new unit, create render info
this.seenUnits.add(unitView);
const render = new StructureRenderInfo(
unitView,
unitView.owner().id(),
this.createPixiSprite(unitView),
);
this.renders.push(render);
this.computeNewLocation(render);
this.shouldRedraw = true;
}
}
if (!unitView.isActive() && this.seenUnits.has(unitView)) {
const render = this.renders.find(
(r) => r.unit.id() === unitView.id(),
);
if (render) {
this.deleteStructure(render);
}
this.shouldRedraw = true;
return;
this.handleActiveUnit(unitView);
} else if (this.seenUnits.has(unitView)) {
this.handleInactiveUnit(unitView);
}
});
}
private findRenderByUnit(
unitView: UnitView,
): StructureRenderInfo | undefined {
return this.renders.find((render) => render.unit.id() === unitView.id());
}
private handleActiveUnit(unitView: UnitView) {
if (this.seenUnits.has(unitView)) {
const render = this.findRenderByUnit(unitView);
if (render) {
this.checkForConstructionState(render, unitView);
this.checkForOwnershipChange(render, unitView);
this.checkForLevelChange(render, unitView);
}
} else if (
this.structures.has(unitView.type()) ||
unitView.type() === UnitType.Construction
) {
this.addNewStructure(unitView);
}
}
private handleInactiveUnit(unitView: UnitView) {
const render = this.findRenderByUnit(unitView);
if (render) {
this.deleteStructure(render);
this.shouldRedraw = true;
}
}
private checkForConstructionState(
render: StructureRenderInfo,
unit: UnitView,
) {
if (
render.underConstruction &&
render.unit.type() !== UnitType.Construction
) {
render.underConstruction = false;
render.iconContainer?.destroy();
render.iconContainer = this.createIconSprite(unit);
this.shouldRedraw = true;
}
}
private checkForOwnershipChange(render: StructureRenderInfo, unit: UnitView) {
if (render.owner !== unit.owner().id()) {
render.owner = unit.owner().id();
render.iconContainer?.destroy();
render.iconContainer = this.createIconSprite(unit);
this.shouldRedraw = true;
}
}
private checkForLevelChange(render: StructureRenderInfo, unit: UnitView) {
if (render.level !== unit.level()) {
render.level = unit.level();
render.iconContainer?.destroy();
render.levelContainer?.destroy();
render.iconContainer = this.createIconSprite(unit);
render.levelContainer = this.createLevelSprite(unit);
this.shouldRedraw = true;
}
}
redraw() {
this.resizeCanvas();
}
renderLayer(mainContext: CanvasRenderingContext2D) {
if (!this.renderer || this.transformHandler.scale > ZOOM_THRESHOLD) {
if (!this.renderer) {
return;
}
@@ -170,38 +224,52 @@ export class StructureIconsLayer implements Layer {
}
if (this.transformHandler.hasChanged() || this.shouldRedraw) {
this.renderer.render(this.stage);
if (this.transformHandler.scale > ZOOM_THRESHOLD) {
this.renderer.render(this.levelsStage);
} else {
this.renderer.render(this.iconsStage);
}
this.shouldRedraw = false;
}
mainContext.drawImage(this.renderer.canvas, 0, 0);
}
private ownerChangeCheck(render: StructureRenderInfo, unit: UnitView) {
if (render.owner !== unit.owner().id()) {
render.owner = unit.owner().id();
render.pixiSprite?.destroy();
render.pixiSprite = this.createPixiSprite(unit);
this.shouldRedraw = true;
}
}
private createTexture(unit: UnitView): PIXI.Texture {
const cacheKey = `${unit.owner().id()}-${unit.type()}`;
const isConstruction = unit.type() === UnitType.Construction;
const constructionType = unit.constructionType();
if (isConstruction && constructionType === undefined) {
console.warn(
`Unit ${unit.id()} is a construction but has no construction type.`,
);
return PIXI.Texture.EMPTY;
}
const structureType = isConstruction ? constructionType! : unit.type();
const cacheKey = isConstruction
? `construction-${structureType}`
: `${unit.owner().id()}-${structureType}`;
if (this.textureCache.has(cacheKey)) {
return this.textureCache.get(cacheKey)!;
}
const structureCanvas = document.createElement("canvas");
structureCanvas.width = ICON_SIZE;
structureCanvas.height = ICON_SIZE;
const context = structureCanvas.getContext("2d")!;
context.fillStyle = this.theme
.territoryColor(unit.owner())
.lighten(0.1)
.toRgbString();
const borderColor = this.theme
.borderColor(unit.owner())
.darken(0.2)
.toRgbString();
let borderColor: string;
if (isConstruction) {
context.fillStyle = "rgb(198, 198, 198)";
borderColor = "rgb(128, 127, 127)";
} else {
context.fillStyle = this.theme
.territoryColor(unit.owner())
.lighten(0.06)
.toRgbString();
borderColor = this.theme
.borderColor(unit.owner())
.darken(0.08)
.toRgbString();
}
context.strokeStyle = borderColor;
context.beginPath();
context.arc(
@@ -214,9 +282,9 @@ export class StructureIconsLayer implements Layer {
context.fill();
context.lineWidth = 1;
context.stroke();
const structureInfo = this.structures.get(unit.type());
const structureInfo = this.structures.get(structureType);
if (!structureInfo?.image) {
console.warn(`Image not loaded for unit type: ${unit.type()}`);
console.warn(`Image not loaded for unit type: ${structureType}`);
return PIXI.Texture.from(structureCanvas);
}
context.drawImage(
@@ -229,20 +297,65 @@ export class StructureIconsLayer implements Layer {
return texture;
}
private createPixiSprite(unit: UnitView): PIXI.Sprite {
const sprite = new PIXI.Sprite(this.createTexture(unit));
sprite.anchor.set(0.5, 0.5);
private createLevelSprite(unit: UnitView): PIXI.Container {
return this.createUnitContainer(unit, {
addIcon: false,
stage: this.levelsStage,
});
}
private createIconSprite(unit: UnitView): PIXI.Container {
return this.createUnitContainer(unit, {
addIcon: true,
stage: this.iconsStage,
});
}
private createUnitContainer(
unit: UnitView,
options: { addIcon?: boolean; stage: PIXI.Container },
): PIXI.Container {
const parentContainer = new PIXI.Container();
const tile = unit.tile();
const worldX = this.game.x(tile);
const worldY = this.game.y(tile);
const screenPos = this.transformHandler.worldToScreenCoordinates(
new Cell(worldX, worldY),
);
sprite.x = screenPos.x;
sprite.y = screenPos.y - this.transformHandler.scale * OFFSET_ZOOM_Y;
sprite.scale.set(Math.min(1, this.transformHandler.scale));
this.stage.addChild(sprite);
return sprite;
if (options.addIcon) {
const sprite = new PIXI.Sprite(this.createTexture(unit));
sprite.anchor.set(0.5, 0.5);
parentContainer.addChild(sprite);
}
if (unit.level() > 1) {
const text = new PIXI.BitmapText({
text: unit.level().toString(),
style: {
fontFamily: "round_6x6_modified",
fontSize: 12,
},
});
text.anchor.set(0.5, 0.5);
text.position.y = -ICON_SIZE / 2 - 2;
parentContainer.addChild(text);
}
const posX = Math.round(screenPos.x);
let posY = Math.round(screenPos.y);
if (this.transformHandler.scale >= ZOOM_THRESHOLD) {
posY = Math.round(
screenPos.y - this.transformHandler.scale * OFFSET_ZOOM_Y,
);
}
parentContainer.position.set(posX, posY);
parentContainer.scale.set(Math.min(1, this.transformHandler.scale));
options.stage.addChild(parentContainer);
return parentContainer;
}
private getImageColored(
@@ -268,9 +381,14 @@ export class StructureIconsLayer implements Layer {
new Cell(worldX, worldY),
);
screenPos.x = Math.round(screenPos.x);
screenPos.y = Math.round(
screenPos.y - this.transformHandler.scale * OFFSET_ZOOM_Y,
);
if (this.transformHandler.scale >= ZOOM_THRESHOLD) {
// Adjust the y position based on zoom level to avoid hiding the structure beneath
screenPos.y = Math.round(
screenPos.y - this.transformHandler.scale * OFFSET_ZOOM_Y,
);
} else {
screenPos.y = Math.round(screenPos.y);
}
// Check if the sprite is on screen (with margin for partial visibility)
const margin = ICON_SIZE;
@@ -281,19 +399,49 @@ export class StructureIconsLayer implements Layer {
screenPos.y - margin < this.pixicanvas.height;
if (onScreen) {
render.pixiSprite.x = screenPos.x;
render.pixiSprite.y = screenPos.y;
render.pixiSprite.scale.set(Math.min(1, this.transformHandler.scale));
if (this.transformHandler.scale > ZOOM_THRESHOLD) {
render.levelContainer.x = screenPos.x;
render.levelContainer.y = screenPos.y;
} else {
render.iconContainer.x = screenPos.x;
render.iconContainer.y = screenPos.y;
render.iconContainer.scale.set(
Math.min(1, this.transformHandler.scale),
);
}
}
if (render.isOnScreen !== onScreen) {
// prevent unnecessary updates
render.isOnScreen = onScreen;
render.pixiSprite.visible = onScreen;
render.iconContainer.visible = onScreen;
render.levelContainer.visible = onScreen;
}
}
private addNewStructure(unitView: UnitView) {
this.seenUnits.add(unitView);
const render = new StructureRenderInfo(
unitView,
unitView.owner().id(),
this.createUnitContainer(unitView, {
addIcon: true,
stage: this.iconsStage,
}),
this.createUnitContainer(unitView, {
addIcon: false,
stage: this.levelsStage,
}),
unitView.level(),
unitView.type() === UnitType.Construction,
);
this.renders.push(render);
this.computeNewLocation(render);
this.shouldRedraw = true;
}
private deleteStructure(render: StructureRenderInfo) {
render.pixiSprite?.destroy();
render.iconContainer?.destroy();
render.levelContainer?.destroy();
this.renders = this.renders.filter((r) => r.unit !== render.unit);
this.seenUnits.delete(render.unit);
}
+4 -96
View File
@@ -1,10 +1,8 @@
import { colord, Colord } from "colord";
import { Theme } from "../../../core/configuration/Config";
import { EventBus } from "../../../core/EventBus";
import { MouseUpEvent } from "../../InputHandler";
import { TransformHandler } from "../TransformHandler";
import { Layer } from "./Layer";
import { UnitInfoModal } from "./UnitInfoModal";
import cityIcon from "../../../../resources/non-commercial/images/buildings/cityAlt1.png";
import factoryIcon from "../../../../resources/non-commercial/images/buildings/factoryAlt1.png";
@@ -18,12 +16,12 @@ import { GameUpdateType } from "../../../core/game/GameUpdates";
import { GameView, UnitView } from "../../../core/game/GameView";
const underConstructionColor = colord({ r: 150, g: 150, b: 150 });
const selectedUnitColor = colord({ r: 0, g: 255, b: 255 });
// Base radius values and scaling factor for unit borders and territories
const BASE_BORDER_RADIUS = 16.5;
const BASE_TERRITORY_RADIUS = 13.5;
const RADIUS_SCALE_FACTOR = 0.5;
const ZOOM_THRESHOLD = 2.5; // below this zoom level, structures are not rendered
interface UnitRenderConfig {
icon: string;
@@ -36,8 +34,6 @@ export class StructureLayer implements Layer {
private context: CanvasRenderingContext2D;
private unitIcons: Map<string, HTMLImageElement> = new Map();
private theme: Theme;
private selectedStructureUnit: UnitView | null = null;
private previouslySelected: UnitView | null = null;
private tempCanvas: HTMLCanvasElement;
private tempContext: CanvasRenderingContext2D;
@@ -79,14 +75,7 @@ export class StructureLayer implements Layer {
private game: GameView,
private eventBus: EventBus,
private transformHandler: TransformHandler,
private unitInfoModal: UnitInfoModal | null,
) {
if (!unitInfoModal) {
throw new Error(
"UnitInfoModal instance must be provided to StructureLayer.",
);
}
this.unitInfoModal = unitInfoModal;
this.theme = game.config().theme();
this.tempCanvas = document.createElement("canvas");
const tempContext = this.tempCanvas.getContext("2d");
@@ -131,7 +120,6 @@ export class StructureLayer implements Layer {
init() {
this.redraw();
this.eventBus.on(MouseUpEvent, (e) => this.onMouseUp(e));
}
redraw() {
@@ -151,6 +139,9 @@ export class StructureLayer implements Layer {
}
renderLayer(context: CanvasRenderingContext2D) {
if (this.transformHandler.scale <= ZOOM_THRESHOLD) {
return;
}
context.drawImage(
this.canvas,
-this.game.width() / 2,
@@ -224,9 +215,6 @@ export class StructureLayer implements Layer {
if (!unit.isActive()) return;
if (this.selectedStructureUnit === unit) {
borderColor = selectedUnitColor;
}
this.drawBorder(unit, borderColor, config);
// Render icon at 1/2 scale for better quality
@@ -279,84 +267,4 @@ export class StructureLayer implements Layer {
clearCell(cell: Cell) {
this.context.clearRect(cell.x * 2, cell.y * 2, 2, 2);
}
private findStructureUnitAtCell(
cell: { x: number; y: number },
maxDistance: number = 10,
): UnitView | null {
const targetRef = this.game.ref(cell.x, cell.y);
const allUnitTypes = Object.values(UnitType);
const nearby = this.game.nearbyUnits(targetRef, maxDistance, allUnitTypes);
for (const { unit } of nearby) {
if (unit.isActive() && this.isUnitTypeSupported(unit.type())) {
return unit;
}
}
return null;
}
private onMouseUp(event: MouseUpEvent) {
const cell = this.transformHandler.screenToWorldCoordinates(
event.x,
event.y,
);
if (!this.game.isValidCoord(cell.x, cell.y)) {
return;
}
const clickedUnit = this.findStructureUnitAtCell(cell);
this.previouslySelected = this.selectedStructureUnit;
if (clickedUnit) {
if (clickedUnit.owner() !== this.game.myPlayer()) {
return;
}
const wasSelected = this.previouslySelected === clickedUnit;
if (wasSelected) {
this.selectedStructureUnit = null;
if (this.previouslySelected) {
this.handleUnitRendering(this.previouslySelected);
}
this.unitInfoModal?.onCloseStructureModal();
} else {
this.selectedStructureUnit = clickedUnit;
if (
this.previouslySelected &&
this.previouslySelected !== clickedUnit
) {
this.handleUnitRendering(this.previouslySelected);
}
this.handleUnitRendering(clickedUnit);
const screenPos = this.transformHandler.worldToScreenCoordinates(cell);
const unitTile = clickedUnit.tile();
this.unitInfoModal?.onOpenStructureModal({
eventBus: this.eventBus,
unit: clickedUnit,
x: screenPos.x,
y: screenPos.y,
tileX: this.game.x(unitTile),
tileY: this.game.y(unitTile),
});
}
} else {
this.selectedStructureUnit = null;
if (this.previouslySelected) {
this.handleUnitRendering(this.previouslySelected);
}
this.unitInfoModal?.onCloseStructureModal();
}
}
public unSelectStructureUnit() {
if (this.selectedStructureUnit) {
this.previouslySelected = this.selectedStructureUnit;
this.selectedStructureUnit = null;
this.handleUnitRendering(this.previouslySelected);
}
}
}
+5 -5
View File
@@ -3,7 +3,7 @@ import { customElement, property } from "lit/decorators.js";
import { EventBus } from "../../../core/EventBus";
import { GameMode } from "../../../core/game/Game";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { renderNumber } from "../../Utils";
import { renderNumber, translateText } from "../../Utils";
import { Layer } from "./Layer";
interface TeamEntry {
@@ -113,22 +113,22 @@ export class TeamStats extends LitElement implements Layer {
<div
class="py-1.5 md:py-2.5 text-center border-b border-slate-500 cursor-pointer"
>
Team
${translateText("leaderboard.team")}
</div>
<div
class="py-1.5 md:py-2.5 text-center border-b border-slate-500 cursor-pointer"
>
Owned
${translateText("leaderboard.owned")}
</div>
<div
class="py-1.5 md:py-2.5 text-center border-b border-slate-500 cursor-pointer"
>
Gold
${translateText("leaderboard.gold")}
</div>
<div
class="py-1.5 md:py-2.5 text-center border-b border-slate-500 cursor-pointer"
>
Troops
${translateText("leaderboard.troops")}
</div>
</div>
${this.teams.map(
+5 -8
View File
@@ -307,7 +307,7 @@ export class TerritoryLayer implements Layer {
this.paintTile(tile, useBorderColor, 255);
}
} else {
const pattern = owner.pattern();
const pattern = owner.cosmetics.pattern;
const patternsEnabled = this.cachedTerritoryPatternsEnabled ?? false;
if (pattern === undefined || patternsEnabled === false) {
this.paintTile(tile, this.theme.territoryColor(owner), 150);
@@ -317,13 +317,10 @@ export class TerritoryLayer implements Layer {
const baseColor = this.theme.territoryColor(owner);
const decoder = owner.patternDecoder();
if (decoder !== undefined) {
const bit = decoder.isSet(x, y) ? 1 : 0;
const colorToUse = bit ? baseColor.darken(0.2) : baseColor;
this.paintTile(tile, colorToUse, 150);
} else {
this.paintTile(tile, baseColor, 150);
}
const color = decoder?.isSet(x, y)
? baseColor.darken(0.125)
: baseColor;
this.paintTile(tile, color, 150);
}
}
}
-93
View File
@@ -1,93 +0,0 @@
import { LitElement, html } from "lit";
import { customElement } from "lit/decorators.js";
import { translateText } from "../../../client/Utils";
import { GameView } from "../../../core/game/GameView";
import { renderNumber, renderTroops } from "../../Utils";
import { Layer } from "./Layer";
@customElement("top-bar")
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;
}
init() {
this.isVisible = true;
this.requestUpdate();
}
tick() {
this.updatePopulationIncrease();
this.requestUpdate();
}
private updatePopulationIncrease() {
const player = this.game?.myPlayer();
if (player === null) return;
const popIncreaseRate = player.population() - this._population;
if (this.game.ticks() % 5 === 0) {
this._popRateIsIncreasing =
popIncreaseRate >= this._lastPopulationIncreaseRate;
this._lastPopulationIncreaseRate = popIncreaseRate;
}
}
render() {
if (!this.isVisible) {
return html``;
}
const myPlayer = this.game?.myPlayer();
if (!myPlayer?.isAlive() || this.game?.inSpawnPhase()) {
return html``;
}
const popRate = this.game.config().populationIncreaseRate(myPlayer) * 10;
const maxPop = this.game.config().maxPopulation(myPlayer);
const goldPerSecond = this.game.config().goldAdditionRate(myPlayer) * 10n;
return html`
<div
class="fixed top-0 z-50 bg-slate-800/40 backdrop-blur-sm shadow-xs 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"
>
<!-- Pop section (takes 2 columns on desktop) -->
<div
class="sm:col-span-1 flex items-center space-x-1 overflow-x-auto whitespace-nowrap"
>
<span class="font-bold shrink-0"
>${translateText("control_panel.pop")}:</span
>
<span translate="no"
>${renderTroops(myPlayer.population())} /
${renderTroops(maxPop)}</span
>
<span
translate="no"
class="${this._popRateIsIncreasing
? "text-green-500"
: "text-yellow-500"}"
>(+${renderTroops(popRate)})</span
>
</div>
<!-- Gold section (takes 1 column on desktop) -->
<div
class="flex items-center space-x-2 overflow-x-auto whitespace-nowrap"
>
<span class="font-bold shrink-0"
>${translateText("control_panel.gold")}:</span
>
<span translate="no"
>${renderNumber(myPlayer.gold())}
(+${renderNumber(goldPerSecond)})</span
>
</div>
</div>
`;
}
}
+5 -1
View File
@@ -340,7 +340,11 @@ export class UILayer implements Layer {
// full hp/dead warships dont need a hp bar
this.allHealthBars.get(unit.id())?.clear();
this.allHealthBars.delete(unit.id());
} else if (unit.health() < maxHealth && unit.health() > 0) {
} else if (
unit.isActive() &&
unit.health() < maxHealth &&
unit.health() > 0
) {
this.allHealthBars.get(unit.id())?.clear();
const healthBar = new ProgressBar(
COLOR_PROGRESSION,
-273
View File
@@ -1,273 +0,0 @@
import { LitElement, css, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import { translateText } from "../../../client/Utils";
import { EventBus } from "../../../core/EventBus";
import { UnitType } from "../../../core/game/Game";
import { GameView, UnitView } from "../../../core/game/GameView";
import {
SendCreateTrainStationIntentEvent,
SendUpgradeStructureIntentEvent,
} from "../../Transport";
import { Layer } from "./Layer";
import { StructureLayer } from "./StructureLayer";
@customElement("unit-info-modal")
export class UnitInfoModal extends LitElement implements Layer {
@property({ type: Boolean }) open = false;
@property({ type: Number }) x = 0;
@property({ type: Number }) y = 0;
@property({ type: Object }) unit: UnitView | null = null;
public game: GameView;
public structureLayer: StructureLayer | null = null;
private eventBus: EventBus;
constructor() {
super();
}
init() {}
tick() {
if (this.unit) {
this.requestUpdate();
}
}
public onOpenStructureModal = ({
eventBus,
unit,
x,
y,
tileX,
tileY,
}: {
eventBus: EventBus;
unit: UnitView;
x: number;
y: number;
tileX: number;
tileY: number;
}) => {
if (!this.game) return;
this.x = x;
this.y = y;
this.eventBus = eventBus;
const targetRef = this.game.ref(tileX, tileY);
const allUnitTypes = Object.values(UnitType);
const matchingUnits = this.game.nearbyUnits(
targetRef,
10,
allUnitTypes,
({ unit }) => unit.isActive(),
);
if (matchingUnits.length > 0) {
matchingUnits.sort((a, b) => a.distSquared - b.distSquared);
this.unit = matchingUnits[0].unit;
} else {
this.unit = null;
}
this.open = this.unit !== null;
};
public onCloseStructureModal = () => {
this.open = false;
this.unit = null;
};
connectedCallback() {
super.connectedCallback();
}
disconnectedCallback() {
super.disconnectedCallback();
}
private buildUnitTypeTranslationString(): string {
if (!this.unit) return "unit_type.unknown"; // fallback stays the same
const unitType = this.unit.type().toLowerCase().replace(/\s+/g, "_");
return `unit_type.${unitType}`;
}
static styles = css`
:host {
position: fixed;
pointer-events: none;
z-index: 1000;
}
.modal {
pointer-events: auto;
background: rgba(30, 30, 30, 0.95);
color: #f8f8f8;
border: 1px solid #555;
padding: 12px 18px;
border-radius: 8px;
min-width: 220px;
max-width: 300px;
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.5);
font-family: "Segoe UI", sans-serif;
font-size: 15px;
line-height: 1.6;
backdrop-filter: blur(6px);
position: relative;
}
.modal strong {
color: #e0e0e0;
}
.close-button {
background: #d00;
color: #fff;
border: none;
border-radius: 4px;
font-size: 14px;
font-weight: bold;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
padding: 6px 12px;
}
.close-button:hover {
background: #a00;
}
.upgrade-button {
background: #3a0;
color: #fff;
border: none;
border-radius: 4px;
font-size: 14px;
font-weight: bold;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
padding: 6px 12px;
}
.upgrade-button:hover {
background: #0a0;
}
`;
render() {
if (!this.unit) return null;
const ticksLeftInCooldown = this.unit.ticksLeftInCooldown();
let configTimer;
switch (this.unit.type()) {
case UnitType.MissileSilo:
configTimer = this.game.config().SiloCooldown();
break;
case UnitType.SAMLauncher:
configTimer = this.game.config().SAMCooldown();
break;
}
let cooldown = 0;
if (ticksLeftInCooldown !== undefined && configTimer !== undefined) {
cooldown = configTimer - (this.game.ticks() - ticksLeftInCooldown);
}
const secondsLeft = Math.ceil(cooldown / 10);
return html`
<div
class="modal"
style="display: ${this.open ? "block" : "none"}; left: ${this
.x}px; top: ${this.y}px; position: absolute;"
>
<div style="margin-bottom: 8px; font-size: 16px; font-weight: bold;">
${translateText("unit_info_modal.structure_info")}
</div>
<div style="margin-bottom: 4px;">
<strong>${translateText("unit_info_modal.type")}:</strong>
${translateText(this.buildUnitTypeTranslationString()) ??
translateText("unit_info_modal.unit_type_unknown")}
<strong
style="display: ${this.game.unitInfo(this.unit.type()).upgradable
? "inline"
: "none"};"
>${translateText("unit_info_modal.level")}:</strong
>
${this.game.unitInfo(this.unit.type()).upgradable &&
this.unit.level?.()
? this.unit.level?.()
: ""}
</div>
${secondsLeft > 0
? html`<div style="margin-bottom: 4px;">
<strong>${translateText("unit_info_modal.cooldown")}</strong>
${secondsLeft}s
</div>`
: ""}
<div
style="margin-top: 14px; display: flex; justify-content: space-between;"
>
<button
@click=${() => {
if (this.unit) {
this.eventBus.emit(
new SendUpgradeStructureIntentEvent(
this.unit.id(),
this.unit.type(),
),
);
}
}}
class="upgrade-button"
title="${translateText("unit_info_modal.upgrade")}"
style="width: 100px; height: 32px; display: ${this.game.unitInfo(
this.unit.type(),
).upgradable
? "block"
: "none"};"
>
${translateText("unit_info_modal.upgrade")}
</button>
<button
@click=${() => {
if (this.unit) {
this.eventBus.emit(
new SendCreateTrainStationIntentEvent(this.unit.id()),
);
this.onCloseStructureModal();
if (this.structureLayer) {
this.structureLayer.unSelectStructureUnit();
}
}
}}
class="upgrade-button"
title="${translateText("unit_info_modal.create_station")}"
style="width: 100px; height: 32px;
display: ${this.unit.hasTrainStation() ||
!this.game.unitInfo(this.unit.type()).canBuildTrainStation
? "none"
: "block"};"
>
${translateText("unit_info_modal.create_station")}
</button>
<button
@click=${() => {
this.onCloseStructureModal();
if (this.structureLayer) {
this.structureLayer.unSelectStructureUnit();
}
}}
class="close-button"
title="${translateText("unit_info_modal.close")}"
style="width: 100px; height: 32px;"
>
${translateText("unit_info_modal.close")}
</button>
</div>
</div>
`;
}
}
+5 -3
View File
@@ -249,7 +249,9 @@ export class WinModal extends LitElement implements Layer {
const updates = this.game.updatesSinceLastTick();
const winUpdates = updates !== null ? updates[GameUpdateType.Win] : [];
winUpdates.forEach((wu) => {
if (wu.winner[0] === "team") {
if (wu.winner === undefined) {
// ...
} else if (wu.winner[0] === "team") {
this.eventBus.emit(new SendWinnerEvent(wu.winner, wu.allPlayersStats));
if (wu.winner[1] === this.game.myPlayer()?.team()) {
this._title = translateText("win_modal.your_team");
@@ -260,8 +262,8 @@ export class WinModal extends LitElement implements Layer {
}
this.show();
} else {
const winner = this.game.playerBySmallID(wu.winner[1]);
if (!winner.isPlayer()) return;
const winner = this.game.playerByClientID(wu.winner[1]);
if (!winner?.isPlayer()) return;
const winnerClient = winner.clientID();
if (winnerClient !== null) {
this.eventBus.emit(
+5 -9
View File
@@ -262,7 +262,6 @@
<div id="radialMenu" class="radial-menu"></div>
<div class="flex gap-2 fixed right-[10px] top-[10px] z-50 flex-col">
<options-menu></options-menu>
<replay-panel></replay-panel>
<player-info-overlay></player-info-overlay>
</div>
<div
@@ -272,20 +271,17 @@
</div>
<div
class="bottom-0 w-full flex-col-reverse sm:flex-row z-50"
class="bottom-0 w-full flex-col-reverse sm:flex-row z-50 md:w-[320px]"
style="position: fixed; pointer-events: none"
>
<div
class="w-full sm:w-2/3 sm:fixed sm:right-0 sm:bottom-0 sm:flex flex-col items-end"
class="w-full md:w-2/3 md:fixed sm:right-0 md:bottom-0 md:flex flex-col items-end"
style="pointer-events: none"
>
<chat-display></chat-display>
<events-display></events-display>
</div>
<div
class="w-[320px] flex flex-col items-center"
style="pointer-events: auto"
>
<div style="pointer-events: auto">
<control-panel></control-panel>
</div>
</div>
@@ -360,7 +356,8 @@
<build-menu></build-menu>
<win-modal></win-modal>
<game-starting-modal></game-starting-modal>
<top-bar></top-bar>
<game-top-bar></game-top-bar>
<game-right-sidebar></game-right-sidebar>
<player-panel></player-panel>
<help-modal></help-modal>
@@ -369,7 +366,6 @@
<chat-modal></chat-modal>
<user-setting></user-setting>
<multi-tab-modal></multi-tab-modal>
<unit-info-modal></unit-info-modal>
<news-modal></news-modal>
<game-left-sidebar></game-left-sidebar>
<spawn-ad></spawn-ad>
+7 -1
View File
@@ -14,7 +14,7 @@ function getAudience() {
return domainname;
}
function getApiBase() {
export function getApiBase() {
const domainname = getAudience();
return domainname === "localhost"
? (localStorage.getItem("apiHost") ?? "http://localhost:8787")
@@ -47,6 +47,12 @@ export function discordLogin() {
window.location.href = `${getApiBase()}/login/discord?redirect_uri=${window.location.href}`;
}
export function getAuthHeader(): string {
const token = getToken();
if (!token) return "";
return `Bearer ${token}`;
}
export async function logOut(allSessions: boolean = false) {
const token = localStorage.getItem("token");
if (token === null) return;
+9
View File
@@ -37,6 +37,14 @@
background: rgba(255, 255, 255, 0.3);
}
.hide-scrollbar {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE/Edge */
}
.hide-scrollbar::-webkit-scrollbar {
display: none; /* Chrome, Safari */
}
.start-game-button {
width: 100%;
max-width: 300px;
@@ -483,6 +491,7 @@ label.option-card:hover {
overflow-y: auto;
white-space: pre-wrap;
word-wrap: break-word;
max-height: 400px;
}
#error-modal button.copy-btn {
@@ -18,6 +18,8 @@ const unitOptions: { type: UnitType; translationKey: string }[] = [
{ type: UnitType.AtomBomb, translationKey: "unit_type.atom_bomb" },
{ type: UnitType.HydrogenBomb, translationKey: "unit_type.hydrogen_bomb" },
{ type: UnitType.MIRV, translationKey: "unit_type.mirv" },
{ type: UnitType.Train, translationKey: "unit_type.train" },
{ type: UnitType.Factory, translationKey: "unit_type.factory" },
];
export function renderUnitTypeOptions({
+4
View File
@@ -17,6 +17,8 @@ export const CosmeticsSchema = z.object({
z.string(),
z.object({
name: z.string(),
role_group: z.string().optional(),
flares: z.array(z.string()).optional(),
}),
),
color: z.record(
@@ -24,6 +26,8 @@ export const CosmeticsSchema = z.object({
z.object({
color: z.string(),
name: z.string(),
role_group: z.string().optional(),
flares: z.array(z.string()).optional(),
}),
),
}),
+3 -11
View File
@@ -42,8 +42,6 @@ export async function createGameRunner(
const humans = gameStart.players.map(
(p) =>
new PlayerInfo(
p.pattern,
p.flag,
p.clientID === clientID
? sanitize(p.username)
: fixProfaneUsername(sanitize(p.username)),
@@ -60,14 +58,7 @@ export async function createGameRunner(
new Nation(
new Cell(n.coordinates[0], n.coordinates[1]),
n.strength,
new PlayerInfo(
undefined,
n.flag || "",
n.name,
PlayerType.FakeHuman,
null,
random.nextID(),
),
new PlayerInfo(n.name, PlayerType.FakeHuman, null, random.nextID()),
),
);
@@ -205,12 +196,13 @@ export class GameRunner {
};
const alliance = player.allianceWith(other as Player);
if (alliance) {
actions.interaction.allianceCreatedAtTick = alliance.createdAt();
actions.interaction.allianceExpiresAt = alliance.expiresAt();
}
}
return actions;
}
public playerProfile(playerID: number): PlayerProfile {
const player = this.game.playerBySmallID(playerID);
if (!player.isPlayer()) {
+23 -25
View File
@@ -1,13 +1,15 @@
import { base64url } from "jose";
export class PatternDecoder {
private bytes: Uint8Array;
private tileWidth: number;
private tileHeight: number;
private scale: number;
constructor(base64: string) {
this.bytes = base64url.decode(base64);
readonly height: number;
readonly width: number;
readonly scale: number;
constructor(
base64: string,
base64urlDecode: (input: Uint8Array | string) => Uint8Array,
) {
this.bytes = base64urlDecode(base64);
if (this.bytes.length < 3) {
throw new Error(
@@ -24,10 +26,10 @@ export class PatternDecoder {
const byte2 = this.bytes[2];
this.scale = byte1 & 0x07;
this.tileWidth = (((byte2 & 0x03) << 5) | ((byte1 >> 3) & 0x1f)) + 2;
this.tileHeight = ((byte2 >> 2) & 0x3f) + 2;
this.width = (((byte2 & 0x03) << 5) | ((byte1 >> 3) & 0x1f)) + 2;
this.height = ((byte2 >> 2) & 0x3f) + 2;
const expectedBits = this.tileWidth * this.tileHeight;
const expectedBits = this.width * this.height;
const expectedBytes = (expectedBits + 7) >> 3; // Equivalent to: ceil(expectedBits / 8);
if (this.bytes.length - 3 < expectedBytes) {
throw new Error(
@@ -36,26 +38,22 @@ export class PatternDecoder {
}
}
getTileWidth(): number {
return this.tileWidth;
}
getTileHeight(): number {
return this.tileHeight;
}
getScale(): number {
return this.scale;
}
isSet(x: number, y: number): boolean {
const px = (x >> this.scale) % this.tileWidth;
const py = (y >> this.scale) % this.tileHeight;
const idx = py * this.tileWidth + px;
const px = (x >> this.scale) % this.width;
const py = (y >> this.scale) % this.height;
const idx = py * this.width + px;
const byteIndex = idx >> 3;
const bitIndex = idx & 7;
const byte = this.bytes[3 + byteIndex];
if (byte === undefined) throw new Error("Invalid pattern");
return (byte & (1 << bitIndex)) !== 0;
}
scaledHeight(): number {
return this.height << this.scale;
}
scaledWidth(): number {
return this.width << this.scale;
}
}
+15 -4
View File
@@ -1,3 +1,4 @@
import { base64url } from "jose";
import { z } from "zod/v4";
import quickChatData from "../../resources/QuickChat.json" with { type: "json" };
import {
@@ -25,6 +26,7 @@ export type Intent =
| CancelBoatIntent
| AllianceRequestIntent
| AllianceRequestReplyIntent
| AllianceExtensionIntent
| BreakAllianceIntent
| TargetPlayerIntent
| EmojiIntent
@@ -67,6 +69,9 @@ export type QuickChatIntent = z.infer<typeof QuickChatIntentSchema>;
export type MarkDisconnectedIntent = z.infer<
typeof MarkDisconnectedIntentSchema
>;
export type AllianceExtensionIntent = z.infer<
typeof AllianceExtensionIntentSchema
>;
export type Turn = z.infer<typeof TurnSchema>;
export type GameConfig = z.infer<typeof GameConfigSchema>;
@@ -170,12 +175,12 @@ export const UsernameSchema = SafeString;
export const FlagSchema = z.string().max(128).optional();
export const RequiredPatternSchema = z
.string()
.max(128)
.max(1403)
.base64url()
.refine(
(val) => {
try {
new PatternDecoder(val);
new PatternDecoder(val, base64url.decode);
return true;
} catch (e) {
console.error(JSON.stringify(e.message, null, 2));
@@ -217,6 +222,11 @@ const BaseIntentSchema = z.object({
clientID: ID,
});
export const AllianceExtensionIntentSchema = BaseIntentSchema.extend({
type: z.literal("allianceExtension"),
recipient: ID,
});
export const AttackIntentSchema = BaseIntentSchema.extend({
type: z.literal("attack"),
targetID: ID.nullable(),
@@ -358,6 +368,7 @@ const IntentSchema = z.discriminatedUnion("type", [
EmbargoIntentSchema,
MoveWarshipIntentSchema,
QuickChatIntentSchema,
AllianceExtensionIntentSchema,
]);
//
@@ -386,8 +397,8 @@ export const GameStartInfoSchema = z.object({
export const WinnerSchema = z
.union([
z.tuple([z.literal("player"), ID]),
z.tuple([z.literal("team"), SafeString]),
z.tuple([z.literal("player"), ID]).rest(ID),
z.tuple([z.literal("team"), SafeString]).rest(ID),
])
.optional();
export type Winner = z.infer<typeof WinnerSchema>;
+2
View File
@@ -62,6 +62,7 @@ export interface ServerConfig {
cloudflareApiToken(): string;
cloudflareConfigPath(): string;
cloudflareCredsPath(): string;
stripePublishableKey(): string;
}
export interface NukeMagnitude {
@@ -158,6 +159,7 @@ export interface Config {
nukeDeathFactor(humans: number, tilesOwned: number): number;
structureMinDist(): number;
isReplay(): boolean;
allianceExtensionPromptOffset(): number;
}
export interface Theme {
+15 -1
View File
@@ -66,6 +66,9 @@ const numPlayersConfig = {
} as const satisfies Record<GameMapType, [number, number, number]>;
export abstract class DefaultServerConfig implements ServerConfig {
stripePublishableKey(): string {
return process.env.STRIPE_PUBLISHABLE_KEY ?? "";
}
domain(): string {
return process.env.DOMAIN ?? "";
}
@@ -199,6 +202,11 @@ export class DefaultConfig implements Config {
private _userSettings: UserSettings | null,
private _isReplay: boolean,
) {}
stripePublishableKey(): string {
return process.env.STRIPE_PUBLISHABLE_KEY ?? "";
}
isReplay(): boolean {
return this._isReplay;
}
@@ -422,7 +430,6 @@ export class DefaultConfig implements Config {
),
territoryBound: true,
constructionDuration: this.instantBuild() ? 0 : 5 * 10,
upgradable: true,
};
case UnitType.SAMLauncher:
return {
@@ -469,6 +476,8 @@ export class DefaultConfig implements Config {
territoryBound: true,
constructionDuration: this.instantBuild() ? 0 : 2 * 10,
canBuildTrainStation: true,
experimental: true,
upgradable: true,
};
case UnitType.Construction:
return {
@@ -479,6 +488,7 @@ export class DefaultConfig implements Config {
return {
cost: () => 0n,
territoryBound: false,
experimental: true,
};
default:
assertNever(type);
@@ -837,4 +847,8 @@ export class DefaultConfig implements Config {
defensePostTargettingRange(): number {
return 75;
}
allianceExtensionPromptOffset(): number {
return 300; // 30 seconds before expiration
}
}
+14
View File
@@ -122,6 +122,20 @@ export class AttackExecution implements Execution {
// Record stats
this.mg.stats().attack(this._owner, this.target, this.startTroops);
for (const incoming of this._owner.incomingAttacks()) {
if (incoming.attacker() === this.target) {
// Target has opposing attack, cancel them out
if (incoming.troops() > this.attack.troops()) {
incoming.setTroops(incoming.troops() - this.attack.troops());
this.attack.delete();
this.active = false;
return;
} else {
this.attack.setTroops(this.attack.troops() - incoming.troops());
incoming.delete();
}
}
}
for (const outgoing of this._owner.outgoingAttacks()) {
if (
outgoing !== this.attack &&
+1 -8
View File
@@ -46,14 +46,7 @@ export class BotSpawner {
}
}
return new SpawnExecution(
new PlayerInfo(
undefined,
"",
botName,
PlayerType.Bot,
null,
this.random.nextID(),
),
new PlayerInfo(botName, PlayerType.Bot, null, this.random.nextID()),
tile,
);
}
+24 -1
View File
@@ -1,4 +1,5 @@
import {
Cell,
Execution,
Game,
Gold,
@@ -26,15 +27,37 @@ export class ConstructionExecution implements Execution {
private ticksUntilComplete: Tick;
private cost: Gold;
private tile: TileRef;
constructor(
private player: Player,
private tile: TileRef,
private constructionType: UnitType,
private tileOrCell: TileRef | Cell,
) {}
init(mg: Game, ticks: number): void {
this.mg = mg;
if (this.mg.config().isUnitDisabled(this.constructionType)) {
console.warn(
`cannot build construction ${this.constructionType} because it is disabled`,
);
this.active = false;
return;
}
if (this.tileOrCell instanceof Cell) {
if (!this.mg.isValidCoord(this.tileOrCell.x, this.tileOrCell.y)) {
console.warn(
`cannot build construction invalid coordinates ${this.tileOrCell.x}, ${this.tileOrCell.y}`,
);
this.active = false;
return;
}
this.tile = this.mg.ref(this.tileOrCell.x, this.tileOrCell.y);
} else {
this.tile = this.tileOrCell;
}
}
tick(ticks: number): void {
+7 -2
View File
@@ -1,7 +1,8 @@
import { Execution, Game } from "../game/Game";
import { Cell, Execution, Game } from "../game/Game";
import { PseudoRandom } from "../PseudoRandom";
import { ClientID, GameID, Intent, Turn } from "../Schemas";
import { simpleHash } from "../Util";
import { AllianceExtensionExecution } from "./alliance/AllianceExtensionExecution";
import { AllianceRequestExecution } from "./alliance/AllianceRequestExecution";
import { AllianceRequestReplyExecution } from "./alliance/AllianceRequestReplyExecution";
import { BreakAllianceExecution } from "./alliance/BreakAllianceExecution";
@@ -108,9 +109,13 @@ export class Executor {
case "build_unit":
return new ConstructionExecution(
player,
this.mg.ref(intent.x, intent.y),
intent.unit,
new Cell(intent.x, intent.y),
);
case "allianceExtension": {
return new AllianceExtensionExecution(player, intent.recipient);
}
case "upgrade_structure":
return new UpgradeStructureExecution(player, intent.unitId);
case "create_station":
+5 -2
View File
@@ -444,6 +444,9 @@ export class FakeHumanExecution implements Execution {
}
private maybeSpawnTrainStation(): boolean {
if (this.mg.config().isUnitDisabled(UnitType.Train)) {
return false;
}
if (this.player === null) throw new Error("not initialized");
const citiesWithoutStations = this.player.units().filter((unit) => {
switch (unit.type()) {
@@ -480,7 +483,7 @@ export class FakeHumanExecution implements Execution {
if (canBuild === false) {
return false;
}
this.mg.addExecution(new ConstructionExecution(this.player, tile, type));
this.mg.addExecution(new ConstructionExecution(this.player, type, tile));
return true;
}
@@ -519,7 +522,7 @@ export class FakeHumanExecution implements Execution {
return false;
}
this.mg.addExecution(
new ConstructionExecution(this.player, targetTile, UnitType.Warship),
new ConstructionExecution(this.player, UnitType.Warship, targetTile),
);
return true;
}
+1 -4
View File
@@ -72,10 +72,7 @@ export class PlayerExecution implements Execution {
const alliances = Array.from(this.player.alliances());
for (const alliance of alliances) {
if (
this.mg.ticks() - alliance.createdAt() >
this.mg.config().allianceDuration()
) {
if (alliance.expiresAt() <= this.mg.ticks()) {
alliance.expire();
}
}
+2 -2
View File
@@ -129,7 +129,7 @@ export class TradeShipExecution implements Execution {
const gold = this.mg.config().tradeShipGold(this.tilesTraveled);
if (this.wasCaptured) {
this.tradeShip!.owner().addGold(gold);
this.tradeShip!.owner().addGold(gold, this._dstPort.tile());
this.mg.displayMessage(
`Received ${renderNumber(gold)} gold from ship captured from ${this.origOwner.displayName()}`,
MessageType.CAPTURED_ENEMY_UNIT,
@@ -138,7 +138,7 @@ export class TradeShipExecution implements Execution {
);
} else {
this.srcPort.owner().addGold(gold);
this._dstPort.owner().addGold(gold);
this._dstPort.owner().addGold(gold, this._dstPort.tile());
this.mg.displayMessage(
`Received ${renderNumber(gold)} gold from trade with ${this.srcPort.owner().displayName()}`,
MessageType.RECEIVED_GOLD_FROM_TRADE,
+8 -1
View File
@@ -1,4 +1,4 @@
import { Execution, Game, Player, Unit } from "../game/Game";
import { Execution, Game, Player, Unit, UnitType } from "../game/Game";
import { TrainStation } from "../game/TrainStation";
import { PseudoRandom } from "../PseudoRandom";
import { TrainExecution } from "./TrainExecution";
@@ -21,6 +21,13 @@ export class TrainStationExecution implements Execution {
init(mg: Game, ticks: number): void {
this.mg = mg;
if (this.mg.config().isUnitDisabled(UnitType.Train)) {
this.active = false;
console.warn(`train station is disabled`);
return;
}
this.random = new PseudoRandom(mg.ticks());
this.unit = this.player.units().find((unit) => unit.id() === this.unitId);
@@ -0,0 +1,61 @@
import {
Execution,
Game,
MessageType,
Player,
PlayerID,
} from "../../game/Game";
export class AllianceExtensionExecution implements Execution {
constructor(
private readonly from: Player,
private readonly toID: PlayerID,
) {}
init(mg: Game, ticks: number): void {
if (!mg.hasPlayer(this.toID)) {
console.warn(
`[AllianceExtensionExecution] Player ${this.toID} not found`,
);
return;
}
const to = mg.player(this.toID);
const alliance = this.from.allianceWith(to);
if (!alliance) {
console.warn(
`[AllianceExtensionExecution] No alliance to extend between ${this.from.id()} and ${this.toID}`,
);
return;
}
// Mark this player's intent to extend
alliance.addExtensionRequest(this.from);
if (alliance.canExtend()) {
alliance.extend();
mg.displayMessage(
"alliance.renewed",
MessageType.ALLIANCE_ACCEPTED,
this.from.id(),
);
mg.displayMessage(
"alliance.renewed",
MessageType.ALLIANCE_ACCEPTED,
this.toID,
);
}
}
tick(ticks: number): void {
// No-op
}
isActive(): boolean {
return false;
}
activeDuringSpawnPhase(): boolean {
return false;
}
}
+39 -3
View File
@@ -1,12 +1,20 @@
import { Game, MutableAlliance, Player, Tick } from "./Game";
export class AllianceImpl implements MutableAlliance {
private extensionRequestedRequestor_: boolean = false;
private extensionRequestedRecipient_: boolean = false;
private expiresAt_: Tick;
constructor(
private readonly mg: Game,
readonly requestor_: Player,
readonly recipient_: Player,
readonly createdAtTick_: Tick,
) {}
private readonly createdAt_: Tick,
private readonly id_: number,
) {
this.expiresAt_ = createdAt_ + mg.config().allianceDuration();
}
other(player: Player): Player {
if (this.requestor_ === player) {
@@ -24,10 +32,38 @@ export class AllianceImpl implements MutableAlliance {
}
createdAt(): Tick {
return this.createdAtTick_;
return this.createdAt_;
}
expire(): void {
this.mg.expireAlliance(this);
}
addExtensionRequest(player: Player): void {
if (this.requestor_ === player) {
this.extensionRequestedRequestor_ = true;
} else if (this.recipient_ === player) {
this.extensionRequestedRecipient_ = true;
}
}
canExtend(): boolean {
return (
this.extensionRequestedRequestor_ && this.extensionRequestedRecipient_
);
}
public id(): number {
return this.id_;
}
extend(): void {
this.extensionRequestedRequestor_ = false;
this.extensionRequestedRecipient_ = false;
this.expiresAt_ = this.mg.ticks() + this.mg.config().allianceDuration();
}
expiresAt(): Tick {
return this.expiresAt_;
}
}
+18 -5
View File
@@ -133,6 +133,7 @@ export interface UnitInfo {
constructionDuration?: number;
upgradable?: boolean;
canBuildTrainStation?: boolean;
experimental?: boolean;
}
export enum UnitType {
@@ -338,20 +339,23 @@ export interface Alliance {
requestor(): Player;
recipient(): Player;
createdAt(): Tick;
expiresAt(): Tick;
other(player: Player): Player;
}
export interface MutableAlliance extends Alliance {
expire(): void;
other(player: Player): Player;
canExtend(): boolean;
addExtensionRequest(player: Player): void;
id(): number;
extend(): void;
}
export class PlayerInfo {
public readonly clan: string | null;
constructor(
public readonly pattern: string | undefined,
public readonly flag: string | undefined,
public readonly name: string,
public readonly playerType: PlayerType,
// null if bot.
@@ -498,7 +502,7 @@ export interface Player {
workers(): number;
troops(): number;
targetTroopRatio(): number;
addGold(toAdd: Gold): void;
addGold(toAdd: Gold, tile?: TileRef): void;
removeGold(toRemove: Gold): Gold;
addWorkers(toAdd: number): void;
removeWorkers(toRemove: number): void;
@@ -518,6 +522,7 @@ export interface Player {
spawnTile: TileRef,
params: UnitParams<T>,
): Unit;
upgradeUnit(unit: Unit): void;
captureUnit(unit: Unit): void;
@@ -537,6 +542,7 @@ export interface Player {
incomingAllianceRequests(): AllianceRequest[];
outgoingAllianceRequests(): AllianceRequest[];
alliances(): MutableAlliance[];
expiredAlliances(): Alliance[];
allies(): Player[];
isAlliedWith(other: Player): boolean;
allianceWith(other: Player): MutableAlliance | null;
@@ -592,7 +598,6 @@ export interface Player {
}
export interface Game extends GameMap {
expireAlliance(alliance: Alliance);
// Map & Dimensions
isOnMap(cell: Cell): boolean;
width(): number;
@@ -614,6 +619,10 @@ export interface Game extends GameMap {
teams(): Team[];
// Alliances
alliances(): MutableAlliance[];
expireAlliance(alliance: Alliance): void;
// Game State
ticks(): Tick;
inSpawnPhase(): boolean;
@@ -674,6 +683,8 @@ export interface PlayerActions {
export interface BuildableUnit {
canBuild: TileRef | false;
// unit id of the existing unit that can be upgraded, or false if it cannot be upgraded.
canUpgrade: number | false;
type: UnitType;
cost: Gold;
}
@@ -695,7 +706,7 @@ export interface PlayerInteraction {
canTarget: boolean;
canDonate: boolean;
canEmbargo: boolean;
allianceCreatedAtTick?: Tick;
allianceExpiresAt?: Tick;
}
export interface EmojiMessage {
@@ -730,6 +741,7 @@ export enum MessageType {
SENT_TROOPS_TO_PLAYER,
RECEIVED_TROOPS_FROM_PLAYER,
CHAT,
RENEW_ALLIANCE,
}
// Message categories used for filtering events in the EventsDisplay
@@ -760,6 +772,7 @@ export const MESSAGE_TYPE_CATEGORIES: Record<MessageType, MessageCategory> = {
[MessageType.ALLIANCE_REQUEST]: MessageCategory.ALLIANCE,
[MessageType.ALLIANCE_BROKEN]: MessageCategory.ALLIANCE,
[MessageType.ALLIANCE_EXPIRED]: MessageCategory.ALLIANCE,
[MessageType.RENEW_ALLIANCE]: MessageCategory.ALLIANCE,
[MessageType.SENT_GOLD_TO_PLAYER]: MessageCategory.TRADE,
[MessageType.RECEIVED_GOLD_FROM_PLAYER]: MessageCategory.TRADE,
[MessageType.RECEIVED_GOLD_FROM_TRADE]: MessageCategory.TRADE,
+40 -5
View File
@@ -1,5 +1,5 @@
import { Config } from "../configuration/Config";
import { AllPlayersStats, ClientID } from "../Schemas";
import { AllPlayersStats, ClientID, Winner } from "../Schemas";
import { simpleHash } from "../Util";
import { AllianceImpl } from "./AllianceImpl";
import { AllianceRequestImpl } from "./AllianceRequestImpl";
@@ -15,6 +15,7 @@ import {
GameMode,
GameUpdates,
MessageType,
MutableAlliance,
Nation,
Player,
PlayerID,
@@ -77,6 +78,9 @@ export class GameImpl implements Game {
private botTeam: Team = ColoredTeams.Bot;
private _railNetwork: RailNetwork = createRailNetwork(this);
// Used to assign unique IDs to each new alliance
private nextAllianceID: number = 0;
constructor(
private _humans: PlayerInfo[],
private _nations: Nation[],
@@ -146,6 +150,11 @@ export class GameImpl implements Game {
owner(ref: TileRef): Player | TerraNullius {
return this.playerBySmallID(this.ownerID(ref));
}
alliances(): MutableAlliance[] {
return this.alliances_;
}
playerBySmallID(id: number): Player | TerraNullius {
if (id === 0) {
return this.terraNullius();
@@ -231,11 +240,20 @@ export class GameImpl implements Game {
const requestor = request.requestor();
const recipient = request.recipient();
const existing = requestor.allianceWith(recipient);
if (existing) {
throw new Error(
`cannot accept alliance request, already allied with ${recipient.name()}`,
);
}
// Create and register the new alliance
const alliance = new AllianceImpl(
this,
requestor as PlayerImpl,
recipient as PlayerImpl,
this._ticks,
this.nextAllianceID++,
);
this.alliances_.push(alliance);
(request.requestor() as PlayerImpl).pastOutgoingAllianceRequests.push(
@@ -596,14 +614,31 @@ export class GameImpl implements Game {
setWinner(winner: Player | Team, allPlayersStats: AllPlayersStats): void {
this.addUpdate({
type: GameUpdateType.Win,
winner:
typeof winner === "string"
? ["team", winner]
: ["player", winner.smallID()],
winner: this.makeWinner(winner),
allPlayersStats,
});
}
private makeWinner(winner: string | Player): Winner | undefined {
if (typeof winner === "string") {
return [
"team",
winner,
...this.players()
.filter((p) => p.team() === winner && p.clientID() !== null)
.map((p) => p.clientID()!),
];
} else {
const clientId = winner.clientID();
if (clientId === null) return;
return [
"player",
clientId,
// TODO: Assists (vote for peace)
];
}
}
teams(): Team[] {
if (this._config.gameConfig().gameMode !== GameMode.Team) {
return [];
+18 -5
View File
@@ -1,4 +1,4 @@
import { AllPlayersStats, ClientID } from "../Schemas";
import { AllPlayersStats, ClientID, Winner } from "../Schemas";
import {
EmojiMessage,
GameUpdates,
@@ -36,6 +36,7 @@ export enum GameUpdateType {
AllianceRequestReply,
BrokeAlliance,
AllianceExpired,
AllianceExtension,
TargetPlayer,
Emoji,
Win,
@@ -60,6 +61,7 @@ export type GameUpdate =
| WinUpdate
| HashUpdate
| UnitIncomingUpdate
| AllianceExtensionUpdate
| BonusEventUpdate
| RailroadUpdate;
@@ -133,8 +135,6 @@ export interface PlayerUpdate {
type: GameUpdateType.Player;
nameViewData?: NameViewData;
clientID: ClientID | null;
pattern: string | undefined;
flag: string | undefined;
name: string;
displayName: string;
id: PlayerID;
@@ -157,10 +157,18 @@ export interface PlayerUpdate {
outgoingAttacks: AttackUpdate[];
incomingAttacks: AttackUpdate[];
outgoingAllianceRequests: PlayerID[];
alliances: AllianceView[];
hasSpawned: boolean;
betrayals?: bigint;
}
export interface AllianceView {
id: number;
other: PlayerID;
createdAt: Tick;
expiresAt: Tick;
}
export interface AllianceRequestUpdate {
type: GameUpdateType.AllianceRequest;
requestorID: number;
@@ -186,6 +194,12 @@ export interface AllianceExpiredUpdate {
player2ID: number;
}
export interface AllianceExtensionUpdate {
type: GameUpdateType.AllianceExtension;
playerID: number;
allianceID: number;
}
export interface TargetPlayerUpdate {
type: GameUpdateType.TargetPlayer;
playerID: number;
@@ -218,8 +232,7 @@ export type DisplayChatMessageUpdate = {
export interface WinUpdate {
type: GameUpdateType.Win;
allPlayersStats: AllPlayersStats;
// Player id or team name.
winner: ["player", number] | ["team", Team];
winner: Winner;
}
export interface HashUpdate {
+56 -25
View File
@@ -1,6 +1,7 @@
import { base64url } from "jose";
import { Config } from "../configuration/Config";
import { PatternDecoder } from "../PatternDecoder";
import { ClientID, GameID } from "../Schemas";
import { ClientID, GameID, Player } from "../Schemas";
import { createRandomName } from "../Util";
import { WorkerClient } from "../worker/WorkerClient";
import {
@@ -9,11 +10,9 @@ import {
GameUpdates,
Gold,
NameViewData,
Player,
PlayerActions,
PlayerBorderTiles,
PlayerID,
PlayerInfo,
PlayerProfile,
PlayerType,
Team,
@@ -26,18 +25,25 @@ import {
} from "./Game";
import { GameMap, TileRef, TileUpdate } from "./GameMap";
import {
AllianceView,
AttackUpdate,
GameUpdateType,
GameUpdateViewData,
PlayerUpdate,
UnitUpdate,
} from "./GameUpdates";
import { TerrainMapData } from "./TerrainMapLoader";
import { TerraNulliusImpl } from "./TerraNulliusImpl";
import { UnitGrid } from "./UnitGrid";
import { UserSettings } from "./UserSettings";
const userSettings: UserSettings = new UserSettings();
interface PlayerCosmetics {
pattern?: string | undefined;
flag?: string | undefined;
}
export class UnitView {
public _wasUpdated = true;
public lastPos: TileRef[] = [];
@@ -145,6 +151,7 @@ export class PlayerView {
private game: GameView,
public data: PlayerUpdate,
public nameData: NameViewData,
public cosmetics: PlayerCosmetics,
) {
if (data.clientID === game.myClientID()) {
this.anonymousName = this.data.name;
@@ -155,7 +162,9 @@ export class PlayerView {
);
}
this.decoder =
data.pattern === undefined ? undefined : new PatternDecoder(data.pattern);
this.cosmetics.pattern === undefined
? undefined
: new PatternDecoder(this.cosmetics.pattern, base64url.decode);
}
patternDecoder(): PatternDecoder | undefined {
@@ -202,13 +211,6 @@ export class PlayerView {
smallID(): number {
return this.data.smallID;
}
flag(): string | undefined {
return this.data.flag;
}
pattern(): string | undefined {
return this.data.pattern;
}
name(): string {
return this.anonymousName !== null && userSettings.anonymousNames()
@@ -236,7 +238,7 @@ export class PlayerView {
isAlive(): boolean {
return this.data.isAlive;
}
isPlayer(): this is Player {
isPlayer(): this is PlayerView {
return true;
}
numTilesOwned(): number {
@@ -264,10 +266,17 @@ export class PlayerView {
targetTroopRatio(): number {
return this.data.targetTroopRatio;
}
troops(): number {
return this.data.troops;
}
totalUnitLevels(type: UnitType): number {
return this.units(type)
.map((unit) => unit.level())
.reduce((a, b) => a + b, 0);
}
isAlliedWith(other: PlayerView): boolean {
return this.data.allies.some((n) => other.smallID() === n);
}
@@ -284,6 +293,10 @@ export class PlayerView {
return this.data.outgoingAllianceRequests.some((id) => other.id() === id);
}
alliances(): AllianceView[] {
return this.data.alliances;
}
hasEmbargoAgainst(other: PlayerView): boolean {
return this.data.embargoes.has(other.id());
}
@@ -306,16 +319,7 @@ export class PlayerView {
outgoingEmojis(): EmojiMessage[] {
return this.data.outgoingEmojis;
}
info(): PlayerInfo {
return new PlayerInfo(
this.pattern(),
this.flag(),
this.name(),
this.type(),
this.clientID(),
this.id(),
);
}
hasSpawned(): boolean {
return this.data.hasSpawned;
}
@@ -338,16 +342,35 @@ export class GameView implements GameMap {
private toDelete = new Set<number>();
private _cosmetics: Map<string, PlayerCosmetics> = new Map();
private _map: GameMap;
constructor(
public worker: WorkerClient,
private _config: Config,
private _map: GameMap,
private _mapData: TerrainMapData,
private _myClientID: ClientID,
private _gameID: GameID,
private _hunans: Player[],
) {
this._map = this._mapData.gameMap;
this.lastUpdate = null;
this.unitGrid = new UnitGrid(_map);
this.unitGrid = new UnitGrid(this._map);
this._cosmetics = new Map(
this._hunans.map((h) => [
h.clientID,
{ flag: h.flag, pattern: h.pattern } satisfies PlayerCosmetics,
]),
);
for (const nation of this._mapData.manifest.nations) {
// Nations don't have client ids, so we use their name as the key instead.
this._cosmetics.set(nation.name, {
flag: nation.flag,
});
}
}
isOnEdgeOfMap(ref: TileRef): boolean {
return this._map.isOnEdgeOfMap(ref);
}
@@ -379,7 +402,15 @@ export class GameView implements GameMap {
} else {
this._players.set(
pu.id,
new PlayerView(this, pu, gu.playerNameViewData[pu.id]),
new PlayerView(
this,
pu,
gu.playerNameViewData[pu.id],
// First check human by clientID, then check nation by name.
this._cosmetics.get(pu.clientID ?? "") ??
this._cosmetics.get(pu.name) ??
{},
),
);
}
});
+59 -12
View File
@@ -42,7 +42,12 @@ import {
} from "./Game";
import { GameImpl } from "./GameImpl";
import { andFN, manhattanDistFN, TileRef } from "./GameMap";
import { AttackUpdate, GameUpdateType, PlayerUpdate } from "./GameUpdates";
import {
AllianceView,
AttackUpdate,
GameUpdateType,
PlayerUpdate,
} from "./GameUpdates";
import {
bestShoreDeploymentSource,
canBuildTransportShip,
@@ -85,6 +90,7 @@ export class PlayerImpl implements Player {
private _displayName: string;
public pastOutgoingAllianceRequests: AllianceRequest[] = [];
private _expiredAlliances: Alliance[] = [];
private targets_: Target[] = [];
@@ -128,8 +134,6 @@ export class PlayerImpl implements Player {
return {
type: GameUpdateType.Player,
clientID: this.clientID(),
pattern: this.pattern(),
flag: this.flag(),
name: this.name(),
displayName: this.displayName(),
id: this.id(),
@@ -168,6 +172,15 @@ export class PlayerImpl implements Player {
} satisfies AttackUpdate;
}),
outgoingAllianceRequests: outgoingAllianceRequests,
alliances: this.alliances().map(
(a) =>
({
id: a.id(),
other: a.other(this).id(),
createdAt: a.createdAt(),
expiresAt: a.expiresAt(),
}) satisfies AllianceView,
),
hasSpawned: this.hasSpawned(),
betrayals: stats?.betrayals,
};
@@ -177,14 +190,6 @@ export class PlayerImpl implements Player {
return this._smallID;
}
pattern(): string | undefined {
return this.playerInfo.pattern;
}
flag(): string | undefined {
return this.playerInfo.flag;
}
name(): string {
return this._name;
}
@@ -352,6 +357,10 @@ export class PlayerImpl implements Player {
);
}
expiredAlliances(): Alliance[] {
return [...this._expiredAlliances];
}
allies(): Player[] {
return this.alliances().map((a) => a.other(this));
}
@@ -689,8 +698,17 @@ export class PlayerImpl implements Player {
return this._gold;
}
addGold(toAdd: Gold): void {
addGold(toAdd: Gold, tile?: TileRef): void {
this._gold += toAdd;
if (tile) {
this.mg.addUpdate({
type: GameUpdateType.BonusEvent,
tile,
gold: Number(toAdd),
workers: 0,
troops: 0,
});
}
}
removeGold(toRemove: Gold): Gold {
@@ -785,6 +803,27 @@ export class PlayerImpl implements Player {
return b;
}
// Returns the existing unit that can be upgraded,
// or false if it cannot be upgraded.
// New units of the same type can upgrade existing units.
// e.g. if a place a new city here, can it upgrade an existing city?
private canUpgradeExistingUnit(
type: UnitType,
targetTile: TileRef,
): Unit | false {
if (!this.mg.config().unitInfo(type).upgradable) {
return false;
}
const range = this.mg.config().structureMinDist();
const existing = this.mg
.nearbyUnits(targetTile, range, type)
.sort((a, b) => a.distSquared - b.distSquared);
if (existing.length > 0) {
return existing[0].unit;
}
return false;
}
upgradeUnit(unit: Unit) {
const cost = this.mg.unitInfo(unit.type()).cost(this);
this.removeGold(cost);
@@ -794,11 +833,19 @@ export class PlayerImpl implements Player {
public buildableUnits(tile: TileRef): BuildableUnit[] {
const validTiles = this.validStructureSpawnTiles(tile);
return Object.values(UnitType).map((u) => {
let canUpgrade: number | false = false;
if (!this.mg.inSpawnPhase()) {
const existingUnit = this.canUpgradeExistingUnit(u, tile);
if (existingUnit !== false) {
canUpgrade = existingUnit.id();
}
}
return {
type: u,
canBuild: this.mg.inSpawnPhase()
? false
: this.canBuild(u, tile, validTiles),
canUpgrade: canUpgrade,
cost: this.mg.config().unitInfo(u).cost(this),
} as BuildableUnit;
});
+1 -8
View File
@@ -21,14 +21,7 @@ class CityStopHandler implements TrainStopHandler {
trainExecution: TrainExecution,
): void {
const goldBonus = mg.config().trainGold();
station.unit.owner().addGold(goldBonus);
mg.addUpdate({
type: GameUpdateType.BonusEvent,
tile: station.tile(),
gold: Number(goldBonus),
workers: 0,
troops: 0,
});
station.unit.owner().addGold(goldBonus, station.tile());
}
}
+4
View File
@@ -36,3 +36,7 @@ declare module "*.html" {
const content: string;
export default content;
}
declare module "*.xml" {
const value: string;
export default value;
}
+10 -1
View File
@@ -21,7 +21,7 @@ import {
} from "../core/Schemas";
import { createGameRecord } from "../core/Util";
import { GameEnv, ServerConfig } from "../core/configuration/Config";
import { GameType } from "../core/game/Game";
import { GameType, UnitType } from "../core/game/Game";
import { archive } from "./Archive";
import { Client } from "./Client";
import { gatekeeper } from "./Gatekeeper";
@@ -222,6 +222,15 @@ export class GameServer {
);
return;
}
if (
clientMsg.intent.type === "create_station" &&
this.gameConfig.disabledUnits?.includes(UnitType.Train)
) {
this.log.warn(
`create_station is disabled, client: ${client.clientID}`,
);
return;
}
this.addIntent(clientMsg.intent);
}
if (clientMsg.type === "ping") {
+1 -2
View File
@@ -7,7 +7,6 @@ import {
import { OpenTelemetryTransportV3 } from "@opentelemetry/winston-transport";
import * as dotenv from "dotenv";
import winston from "winston";
import { GameEnv } from "../core/configuration/Config";
import { getServerConfigFromServer } from "../core/configuration/ConfigLoader";
import { getOtelResource } from "./OtelResource";
dotenv.config();
@@ -21,7 +20,7 @@ const loggerProvider = new LoggerProvider({
resource,
});
if (config.env() === GameEnv.Prod && config.otelEnabled()) {
if (config.otelEnabled()) {
console.log("OTEL enabled");
// Configure OpenTelemetry endpoint with basic auth (if provided)
const headers = {};
+8 -1
View File
@@ -1,5 +1,11 @@
import { getServerConfigFromServer } from "../core/configuration/ConfigLoader";
import { Difficulty, GameMapType, GameMode, GameType } from "../core/game/Game";
import {
Difficulty,
GameMapType,
GameMode,
GameType,
UnitType,
} from "../core/game/Game";
import { PseudoRandom } from "../core/PseudoRandom";
import { GameConfig } from "../core/Schemas";
import { logger } from "./Logger";
@@ -61,6 +67,7 @@ export class MapPlaylist {
gameMode: mode,
playerTeams: numPlayerTeams,
bots: 400,
disabledUnits: [UnitType.Train, UnitType.Factory],
} satisfies GameConfig;
}
+96 -3
View File
@@ -2,7 +2,10 @@ import { Cosmetics } from "../core/CosmeticSchemas";
import { PatternDecoder } from "../core/PatternDecoder";
export class PrivilegeChecker {
constructor(private cosmetics: Cosmetics) {}
constructor(
private cosmetics: Cosmetics,
private b64urlDecode: (base64: string) => Uint8Array,
) {}
isPatternAllowed(
base64: string,
@@ -14,7 +17,7 @@ export class PrivilegeChecker {
if (found === undefined) {
try {
// Ensure that the pattern will not throw for clients
new PatternDecoder(base64);
new PatternDecoder(base64, this.b64urlDecode);
} catch (e) {
// Pattern is invalid
return "invalid";
@@ -37,7 +40,7 @@ export class PrivilegeChecker {
if (
roles !== undefined &&
roles.some((role) =>
this.cosmetics.role_groups[groupName].includes(role),
this.cosmetics.role_groups[groupName]?.includes(role),
)
) {
// Player is in a role group for this pattern
@@ -55,4 +58,94 @@ export class PrivilegeChecker {
return "restricted";
}
isCustomFlagAllowed(
flag: string,
roles: readonly string[] | undefined,
flares: readonly string[] | undefined,
): true | "restricted" | "invalid" {
if (!flag.startsWith("!")) return "invalid";
const code = flag.slice(1);
if (!code) return "invalid";
const segments = code.split("_");
if (segments.length === 0) return "invalid";
const MAX_LAYERS = 6; // Maximum number of layers allowed
if (segments.length > MAX_LAYERS) return "invalid";
const superFlare = flares?.includes("flag:*") ?? false;
for (const segment of segments) {
const [layerKey, colorKey] = segment.split("-");
if (!layerKey || !colorKey) return "invalid";
const layer = this.cosmetics.flag.layers[layerKey];
const color = this.cosmetics.flag.color[colorKey];
if (!layer || !color) return "invalid";
// Super-flare bypasses all restrictions
if (superFlare) {
continue;
}
// Check layer restrictions
const layerSpec = layer;
let layerAllowed = false;
if (!layerSpec.role_group && !layerSpec.flares) {
layerAllowed = true;
} else {
// By role
if (layerSpec.role_group) {
const allowedRoles =
this.cosmetics.role_groups[layerSpec.role_group] || [];
if (roles?.some((r) => allowedRoles.includes(r))) {
layerAllowed = true;
}
}
// By flare
if (
layerSpec.flares &&
flares?.some((f) => layerSpec.flares?.includes(f))
) {
layerAllowed = true;
}
// By named flag:layer:{name}
if (flares?.includes(`flag:layer:${layerSpec.name}`)) {
layerAllowed = true;
}
}
// Check color restrictions
const colorSpec = color;
let colorAllowed = false;
if (!colorSpec.role_group && !colorSpec.flares) {
colorAllowed = true;
} else {
// By role
if (colorSpec.role_group) {
const allowedRoles =
this.cosmetics.role_groups[colorSpec.role_group] || [];
if (roles?.some((r) => allowedRoles.includes(r))) {
colorAllowed = true;
}
}
// By flare
if (
colorSpec.flares &&
flares?.some((f) => colorSpec.flares?.includes(f))
) {
colorAllowed = true;
}
// By named flag:color:{name}
if (flares?.includes(`flag:color:${colorSpec.name}`)) {
colorAllowed = true;
}
}
// If either part is restricted, block
if (!(layerAllowed && colorAllowed)) {
return "restricted";
}
}
return true;
}
}
+34 -6
View File
@@ -2,6 +2,7 @@ import express, { NextFunction, Request, Response } from "express";
import rateLimit from "express-rate-limit";
import http from "http";
import ipAnonymize from "ip-anonymize";
import { base64url } from "jose";
import path from "path";
import { fileURLToPath } from "url";
import { WebSocket, WebSocketServer } from "ws";
@@ -11,7 +12,7 @@ import { getServerConfigFromServer } from "../core/configuration/ConfigLoader";
import { COSMETICS } from "../core/CosmeticSchemas";
import { GameType } from "../core/game/Game";
import {
ClientJoinMessageSchema,
ClientMessageSchema,
GameRecord,
GameRecordSchema,
ServerErrorMessage,
@@ -44,9 +45,9 @@ export function startWorker() {
const gm = new GameManager(config, log);
const privilegeChecker = new PrivilegeChecker(COSMETICS);
const privilegeChecker = new PrivilegeChecker(COSMETICS, base64url.decode);
if (config.env() === GameEnv.Prod && config.otelEnabled()) {
if (config.otelEnabled()) {
initWorkerMetrics(gm);
}
@@ -301,12 +302,12 @@ export function startWorker() {
try {
// Parse and handle client messages
const parsed = ClientJoinMessageSchema.safeParse(
const parsed = ClientMessageSchema.safeParse(
JSON.parse(message.toString()),
);
if (!parsed.success) {
const error = z.prettifyError(parsed.error);
log.warn("Error parsing join message client", error);
log.warn("Error parsing client message", error);
ws.send(
JSON.stringify({
type: "error",
@@ -318,6 +319,22 @@ export function startWorker() {
}
const clientMsg = parsed.data;
if (clientMsg.type === "ping") {
// Ignore ping
return;
} else if (clientMsg.type !== "join") {
const error = `Invalid message before join: ${JSON.stringify(clientMsg)}`;
log.warn(error);
ws.send(
JSON.stringify({
type: "error",
error,
} satisfies ServerErrorMessage),
);
ws.close(1002, "ClientJoinMessageSchema");
return;
}
// Verify this worker should handle this game
const expectedWorkerId = config.workerIndex(clientMsg.gameID);
if (expectedWorkerId !== workerId) {
@@ -355,7 +372,18 @@ export function startWorker() {
// Check if the flag is allowed
if (clientMsg.flag !== undefined) {
// TODO: Implement custom flag validation
if (clientMsg.flag.startsWith("!")) {
const allowed = privilegeChecker.isCustomFlagAllowed(
clientMsg.flag,
roles,
flares,
);
if (allowed !== true) {
log.warn(`Custom flag ${allowed}: ${clientMsg.flag}`);
ws.close(1002, `Custom flag ${allowed}`);
return;
}
}
}
// Check if the pattern is allowed