mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-07 08:25:58 +00:00
Merge branch 'main' into multi-lobby
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
`,
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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} ⦿
|
||||
${this.getTranslatedPlayerTeamLabel()} ⦿
|
||||
</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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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()}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) : ""}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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>;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Vendored
+4
@@ -36,3 +36,7 @@ declare module "*.html" {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
declare module "*.xml" {
|
||||
const value: string;
|
||||
export default value;
|
||||
}
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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 = {};
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user