diff --git a/package-lock.json b/package-lock.json
index 59350bf82..d3ad3cf89 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -85,8 +85,8 @@
"lit": "^3.3.1",
"lit-markdown": "^1.3.2",
"mrmime": "^2.0.0",
- "pixi-filters": "^6.1.4",
- "pixi.js": "^8.11.0",
+ "pixi-filters": "^6.1.5",
+ "pixi.js": "^8.17.1",
"prettier": "^3.5.3",
"prettier-plugin-organize-imports": "^4.1.0",
"prettier-plugin-sh": "^0.17.4",
@@ -4198,13 +4198,6 @@
"@types/node": "*"
}
},
- "node_modules/@types/css-font-loading-module": {
- "version": "0.0.12",
- "resolved": "https://registry.npmjs.org/@types/css-font-loading-module/-/css-font-loading-module-0.0.12.tgz",
- "integrity": "sha512-x2tZZYkSxXqWvTDgveSynfjq/T2HyiZHXb00j/+gy19yp70PHCizM48XFdjBCWH7eHBD0R5i/pw9yMBP/BH5uA==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/@types/d3": {
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
@@ -5219,16 +5212,16 @@
}
},
"node_modules/@webgpu/types": {
- "version": "0.1.61",
- "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.61.tgz",
- "integrity": "sha512-w2HbBvH+qO19SB5pJOJFKs533CdZqxl3fcGonqL321VHkW7W/iBo6H8bjDy6pr/+pbMwIu5dnuaAxH7NxBqUrQ==",
+ "version": "0.1.69",
+ "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.69.tgz",
+ "integrity": "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==",
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/@xmldom/xmldom": {
- "version": "0.8.10",
- "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz",
- "integrity": "sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==",
+ "version": "0.8.11",
+ "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz",
+ "integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -7108,9 +7101,9 @@
}
},
"node_modules/earcut": {
- "version": "3.0.1",
- "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.1.tgz",
- "integrity": "sha512-0l1/0gOjESMeQyYaK5IDiPNvFeu93Z/cO0TjZh9eZ1vyCtZnA7KMZ8rQggpsJHIbGSdrqYq9OhuveadOVHCshw==",
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz",
+ "integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==",
"dev": true,
"license": "ISC"
},
@@ -10277,9 +10270,9 @@
}
},
"node_modules/pixi-filters": {
- "version": "6.1.4",
- "resolved": "https://registry.npmjs.org/pixi-filters/-/pixi-filters-6.1.4.tgz",
- "integrity": "sha512-6QdkhR8hZ/jXyV7GZG8R0UKkRy9jPeZsOnHaQiKSFEe4tGJ4PfUG90vaC9eyi7g+YKxhKLpNOXu6tmO1+R2tpQ==",
+ "version": "6.1.5",
+ "resolved": "https://registry.npmjs.org/pixi-filters/-/pixi-filters-6.1.5.tgz",
+ "integrity": "sha512-Ewb/J+kxAbaNN+0/ATJbglAJG+skGJfh7BIDP3ILIDdD6wWk1p0pGa25pVf1T8hGBOQSUNVAmwwJBwkj+cyLLA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -10290,22 +10283,26 @@
}
},
"node_modules/pixi.js": {
- "version": "8.11.0",
- "resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-8.11.0.tgz",
- "integrity": "sha512-dyuThzncsgEgJZnvd/A/5x6IkUERbK+phXqUQrI+0C6WE+8xqGH5VChRTLecemhgZF0kQ+gZOM3tJTX9937xpg==",
+ "version": "8.17.1",
+ "resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-8.17.1.tgz",
+ "integrity": "sha512-OB4TpZHrP5RYy+7FqmFrAc0IHRhfOoNIfF4sVeinvK3aG1r2pYrSMneJAKi9+WvGKC70Dj7GEpZ2OZGB6o/xdg==",
"dev": true,
"license": "MIT",
+ "workspaces": [
+ "examples",
+ "playground"
+ ],
"dependencies": {
"@pixi/colord": "^2.9.6",
- "@types/css-font-loading-module": "^0.0.12",
"@types/earcut": "^3.0.0",
- "@webgpu/types": "^0.1.40",
- "@xmldom/xmldom": "^0.8.10",
- "earcut": "^3.0.1",
+ "@webgpu/types": "^0.1.69",
+ "@xmldom/xmldom": "^0.8.11",
+ "earcut": "^3.0.2",
"eventemitter3": "^5.0.1",
"gifuct-js": "^2.1.2",
"ismobilejs": "^1.1.1",
- "parse-svg-path": "^0.1.2"
+ "parse-svg-path": "^0.1.2",
+ "tiny-lru": "^11.4.7"
},
"funding": {
"type": "opencollective",
@@ -11629,6 +11626,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/tiny-lru": {
+ "version": "11.4.7",
+ "resolved": "https://registry.npmjs.org/tiny-lru/-/tiny-lru-11.4.7.tgz",
+ "integrity": "sha512-w/Te7uMUVeH0CR8vZIjr+XiN41V+30lkDdK+NRIDCUYKKuL9VcmaUEmaPISuwGhLlrTGh5yu18lENtR9axSxYw==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
diff --git a/package.json b/package.json
index fe1e43329..bba7b5d9f 100644
--- a/package.json
+++ b/package.json
@@ -69,8 +69,8 @@
"lit": "^3.3.1",
"lit-markdown": "^1.3.2",
"mrmime": "^2.0.0",
- "pixi-filters": "^6.1.4",
- "pixi.js": "^8.11.0",
+ "pixi-filters": "^6.1.5",
+ "pixi.js": "^8.17.1",
"prettier": "^3.5.3",
"prettier-plugin-organize-imports": "^4.1.0",
"prettier-plugin-sh": "^0.17.4",
diff --git a/resources/lang/en.json b/resources/lang/en.json
index 6bd6cc6aa..2a219dd9d 100644
--- a/resources/lang/en.json
+++ b/resources/lang/en.json
@@ -121,12 +121,13 @@
"ui_options": "Options",
"ui_options_desc": "The following elements can be found inside:",
"ui_playeroverlay": "Player info overlay",
- "ui_playeroverlay_desc": "When you hover over a country, the Player info overlay is displayed under Options. It shows the type of player: Human, Nation, or Tribe. A Nation's attitude towards you, ranging from Hostile to Friendly. And defending troops, gold, plus the number of Warships and various buildings the player has.",
+ "ui_playeroverlay_desc": "When you hover over a country, the Player info overlay appears. It shows the player type (Human, Nation, or Tribe), a Nation's attitude toward you (Hostile to Friendly), defending troops, gold, and the number of Warships and buildings they have.",
"ui_wilderness": "Wilderness",
- "option_pause": "Pause/Unpause the game - Only available in single player mode.",
+ "option_pause": "Pause/Unpause the game - Unavailable in public games.",
+ "option_speed": "Speed - Adjust the game speed. Unavailable in public games.",
"option_timer": "Timer - Time passed since the start of the game.",
"option_exit": "Exit button.",
- "option_settings": "Settings - Open the settings menu. Inside you can toggle the Alternate view, Emojis, Dark Mode, Ninja (anonymous/random names mode), and action on left click.",
+ "option_settings": "Settings - Open the settings menu. Inside you can toggle things like Alternate view, Emojis, Dark Mode, Hidden names, action on left click and more.",
"radial_title": "Radial menu",
"radial_desc": "Right clicking (or touch on mobile) opens the Radial menu. Right click outside it to close it. From the menu you can:",
"radial_build": "Open the Build menu.",
@@ -367,7 +368,10 @@
"error": "An error occurred. Please try again or contact support.",
"joined_waiting": "Lobby joined! Waiting for host to start...",
"version_mismatch": "This game was created with a different version. Cannot join.",
- "disabled_units": "Disabled Units"
+ "disabled_units": "Disabled Units",
+ "game_length": "Game length",
+ "pvp_immunity": "PVP immunity duration",
+ "starting_gold": "Starting Gold"
},
"public_lobby": {
"title": "Waiting for Game Start...",
@@ -405,7 +409,6 @@
"title": "Create Private Lobby",
"mode": "Mode",
"team_count": "Number of Teams",
- "team_type": "Team Type",
"options_title": "Options",
"bots": "Tribes: ",
"bots_disabled": "Disabled",
diff --git a/src/client/HelpModal.ts b/src/client/HelpModal.ts
index d70accd7e..b66336d9d 100644
--- a/src/client/HelpModal.ts
+++ b/src/client/HelpModal.ts
@@ -624,10 +624,11 @@ export class HelpModal extends BaseModal {
${translateText("help_modal.ui_options_desc")}
- - ${translateText("help_modal.option_pause")}
- ${translateText("help_modal.option_timer")}
- - ${translateText("help_modal.option_exit")}
+ - ${translateText("help_modal.option_speed")}
+ - ${translateText("help_modal.option_pause")}
- ${translateText("help_modal.option_settings")}
+ - ${translateText("help_modal.option_exit")}
@@ -718,8 +719,7 @@ export class HelpModal extends BaseModal {
${translateText("help_modal.radial_info")}
@@ -848,14 +848,11 @@ export class HelpModal extends BaseModal {
${translateText("help_modal.info_emoji")}
-
-

-
+
${translateText("help_modal.info_trade")}
diff --git a/src/client/JoinLobbyModal.ts b/src/client/JoinLobbyModal.ts
index 4bea53d43..3d6ff86c9 100644
--- a/src/client/JoinLobbyModal.ts
+++ b/src/client/JoinLobbyModal.ts
@@ -2,13 +2,10 @@ import { html, TemplateResult } from "lit";
import { customElement, property, query, state } from "lit/decorators.js";
import {
calculateServerTimeOffset,
- getActiveModifiers,
- getGameModeLabel,
getMapName,
getSecondsUntilServerTimestamp,
getServerNow,
renderDuration,
- renderNumber,
translateText,
} from "../client/Utils";
import { EventBus } from "../core/EventBus";
@@ -22,11 +19,18 @@ import {
PublicGameInfo,
} from "../core/Schemas";
import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
-import { GameMode, GameType, HumansVsNations } from "../core/game/Game";
+import {
+ Difficulty,
+ GameMapSize,
+ GameMode,
+ GameType,
+ HumansVsNations,
+} from "../core/game/Game";
import { getApiBase } from "./Api";
import { crazyGamesSDK } from "./CrazyGamesSDK";
import { JoinLobbyEvent } from "./Main";
import { terrainMapFileLoader } from "./TerrainMapFileLoader";
+import { normaliseMapKey } from "./Utils";
import { BaseModal } from "./components/BaseModal";
import "./components/CopyButton";
import "./components/LobbyConfigItem";
@@ -426,45 +430,197 @@ export class JoinLobbyModal extends BaseModal {
const c = this.gameConfig;
const mapName = getMapName(c.gameMap);
- const modeName = getGameModeLabel(c);
- const modifiers = getActiveModifiers(c.publicGameModifiers);
+ const normalizedMap = normaliseMapKey(c.gameMap);
+ const thumbnailUrl = `/maps/${encodeURIComponent(normalizedMap)}/thumbnail.webp`;
+ const isTeam = c.gameMode === GameMode.Team;
+
+ let modeSubtitle: string;
+ if (!isTeam) {
+ modeSubtitle = translateText("game_mode.ffa");
+ } else if (c.playerTeams === HumansVsNations) {
+ modeSubtitle = translateText("host_modal.teams_Humans Vs Nations");
+ } else if (typeof c.playerTeams === "string") {
+ modeSubtitle = translateText("host_modal.teams_" + c.playerTeams);
+ } else if (typeof c.playerTeams === "number") {
+ modeSubtitle = translateText("public_lobby.teams", {
+ num: c.playerTeams,
+ });
+ } else {
+ modeSubtitle = translateText("game_mode.ffa");
+ }
+
+ const pm = c.publicGameModifiers;
+ const cards: TemplateResult[] = [];
+ if (pm?.isCrowded)
+ cards.push(
+ html``,
+ );
+ if (
+ pm?.isHardNations ||
+ (c.gameType === GameType.Private && c.difficulty !== Difficulty.Easy)
+ )
+ cards.push(
+ html``,
+ );
+ if (c.infiniteTroops)
+ cards.push(
+ html``,
+ );
+ if (c.infiniteGold)
+ cards.push(
+ html``,
+ );
+ if (c.instantBuild)
+ cards.push(
+ html``,
+ );
+ if (c.randomSpawn)
+ cards.push(
+ html``,
+ );
+ if (c.maxTimerValue)
+ cards.push(
+ html``,
+ );
+ if (
+ c.spawnImmunityDuration &&
+ Math.round(c.spawnImmunityDuration / 10) !== 5
+ ) {
+ const totalSeconds = Math.round(c.spawnImmunityDuration / 10);
+ const immunityValue =
+ totalSeconds < 60
+ ? `${totalSeconds}s`
+ : totalSeconds % 60 > 0
+ ? `${Math.floor(totalSeconds / 60)}m ${totalSeconds % 60}s`
+ : `${Math.floor(totalSeconds / 60)} min`;
+ cards.push(
+ html``,
+ );
+ }
+ if (c.startingGold)
+ cards.push(
+ html``,
+ );
+ if (c.goldMultiplier)
+ cards.push(
+ html``,
+ );
+ if (c.disableAlliances)
+ cards.push(
+ html``,
+ );
+ if ((isTeam && !c.donateGold) || (!isTeam && c.donateGold))
+ cards.push(
+ html``,
+ );
+ if ((isTeam && !c.donateTroops) || (!isTeam && c.donateTroops))
+ cards.push(
+ html``,
+ );
+ const isCompact =
+ c.gameMapSize === GameMapSize.Compact || c.publicGameModifiers?.isCompact;
+ if (isCompact)
+ cards.push(
+ html``,
+ );
+ {
+ const defaultBots = isCompact ? 100 : 400;
+ if (c.bots !== defaultBots)
+ cards.push(
+ html``,
+ );
+ }
+ {
+ const defaultNations = isCompact
+ ? Math.max(0, Math.floor(this.nationCount * 0.25))
+ : this.nationCount;
+ if (typeof c.nations === "number" && c.nations !== defaultNations)
+ cards.push(
+ html``,
+ );
+ }
+ if (c.nations === "disabled" && !(c.gameType === GameType.Public && isTeam))
+ cards.push(
+ html``,
+ );
return html`
-
-
-
- ${modifiers.map(
- (m) => html`
-
- `,
- )}
- ${c.gameMode !== GameMode.FFA &&
- c.playerTeams &&
- c.playerTeams !== HumansVsNations
- ? html`
-
- `
- : html``}
+
+

{
+ (e.target as HTMLImageElement).style.display = "none";
+ }}
+ />
+
+ ${mapName}
+ ${modeSubtitle}
+
+ ${cards.length > 0
+ ? html`
+ ${cards}
+
`
+ : html``}
${this.renderDisabledUnits()}
`;
}
@@ -495,7 +651,9 @@ export class JoinLobbyModal extends BaseModal {
};
return html`
-
+
diff --git a/src/client/UsernameInput.ts b/src/client/UsernameInput.ts
index cb1030705..69082ebea 100644
--- a/src/client/UsernameInput.ts
+++ b/src/client/UsernameInput.ts
@@ -13,6 +13,12 @@ import {
} from "../core/validations/username";
import { crazyGamesSDK } from "./CrazyGamesSDK";
+interface LangSelectorLike {
+ currentLang?: string;
+ translations?: Record
;
+ defaultTranslations?: Record;
+}
+
const usernameKey: string = "username";
const clanTagKey: string = "clanTag";
@@ -23,6 +29,7 @@ export class UsernameInput extends LitElement {
@property({ type: String }) validationError: string = "";
private _isValid: boolean = true;
+ private _lastValidatedLang: string | null = null;
// Remove static styles since we're using Tailwind
@@ -60,6 +67,23 @@ export class UsernameInput extends LitElement {
});
}
+ protected updated(): void {
+ // Re-validate when translations become available or language changes,
+ // since initial validation may run before translations are loaded.
+ if (this.validationError) {
+ const langSelector = document.querySelector(
+ "lang-selector",
+ );
+ const lang = langSelector?.currentLang;
+ const hasTranslations =
+ langSelector?.translations ?? langSelector?.defaultTranslations;
+ if (hasTranslations && lang && lang !== this._lastValidatedLang) {
+ this._lastValidatedLang = lang;
+ this.validateAndStore();
+ }
+ }
+ }
+
private loadStoredUsername() {
const storedUsername = localStorage.getItem(usernameKey);
if (storedUsername) {
diff --git a/src/client/graphics/layers/NameLayer.ts b/src/client/graphics/layers/NameLayer.ts
index b973d2b1d..5016c2693 100644
--- a/src/client/graphics/layers/NameLayer.ts
+++ b/src/client/graphics/layers/NameLayer.ts
@@ -6,7 +6,7 @@ import { Cell } from "../../../core/game/Game";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { UserSettings } from "../../../core/game/UserSettings";
import { AlternateViewEvent } from "../../InputHandler";
-import { createCanvas, renderNumber, renderTroops } from "../../Utils";
+import { renderTroops } from "../../Utils";
import {
computeAllianceClipPath,
createAllianceProgressIcon,
@@ -16,11 +16,22 @@ import {
} from "../PlayerIcons";
import { TransformHandler } from "../TransformHandler";
import { Layer } from "./Layer";
-import shieldIcon from "/images/ShieldIconBlack.svg?url";
+
+const PLAYER_NAME = "player-name";
+const PLAYER_NAME_SPAN = "player-name-span";
+const PLAYER_TROOPS = "player-troops";
+const PLAYER_ICONS = "player-icons";
+const PLAYER_FLAG = "player-flag";
class RenderInfo {
public icons: Map = new Map(); // Track icon elements
+ public nameDiv: HTMLDivElement;
+ public nameSpan: HTMLSpanElement | null;
+ public troopsDiv: HTMLDivElement;
+ public flagDiv: HTMLDivElement | null;
+ public iconsDiv: HTMLDivElement;
+
constructor(
public player: PlayerView,
public lastRenderCalc: number,
@@ -28,39 +39,41 @@ class RenderInfo {
public fontSize: number,
public fontColor: string,
public element: HTMLElement,
- ) {}
+ ) {
+ // Traverse the DOM once, upon creation
+ this.nameDiv = element.querySelector(`.${PLAYER_NAME}`) as HTMLDivElement;
+ this.nameSpan = element.querySelector(
+ `.${PLAYER_NAME_SPAN}`,
+ ) as HTMLSpanElement | null;
+ this.troopsDiv = element.querySelector(
+ `.${PLAYER_TROOPS}`,
+ ) as HTMLDivElement;
+ this.flagDiv = element.querySelector(
+ `.${PLAYER_FLAG}`,
+ ) as HTMLDivElement | null;
+ this.iconsDiv = element.querySelector(`.${PLAYER_ICONS}`) as HTMLDivElement;
+ }
}
export class NameLayer implements Layer {
- private canvas: HTMLCanvasElement;
private lastChecked = 0;
private renderCheckRate = 100;
private renderRefreshRate = 500;
private rand = new PseudoRandom(10);
private renders: RenderInfo[] = [];
private seenPlayers: Set = new Set();
- private shieldIconImage: HTMLImageElement;
private container: HTMLDivElement;
private theme: Theme = this.game.config().theme();
private userSettings: UserSettings = new UserSettings();
private isVisible: boolean = true;
private firstPlace: PlayerView | null = null;
+ private lastContainerTransform: string = "";
constructor(
private game: GameView,
private transformHandler: TransformHandler,
private eventBus: EventBus,
- ) {
- this.shieldIconImage = new Image();
- this.shieldIconImage.src = shieldIcon;
- this.shieldIconImage = new Image();
- this.shieldIconImage.src = shieldIcon;
- }
-
- resizeCanvas() {
- this.canvas.width = window.innerWidth;
- this.canvas.height = window.innerHeight;
- }
+ ) {}
shouldTransform(): boolean {
return false;
@@ -71,10 +84,6 @@ export class NameLayer implements Layer {
}
public init() {
- this.canvas = createCanvas();
- window.addEventListener("resize", () => this.resizeCanvas());
- this.resizeCanvas();
-
this.container = document.createElement("div");
this.container.style.position = "fixed";
this.container.style.left = "50%";
@@ -109,12 +118,13 @@ export class NameLayer implements Layer {
}
}
- private updateElementVisibility(render: RenderInfo) {
+ private updateElementVisibility(render: RenderInfo, baseSize?: number) {
if (!render.player.nameLocation() || !render.player.isAlive()) {
return;
}
- const baseSize = Math.max(1, Math.floor(render.player.nameLocation().size));
+ baseSize =
+ baseSize ?? Math.max(1, Math.floor(render.player.nameLocation().size));
const size = this.transformHandler.scale * baseSize;
const isOnScreen = render.location
? this.transformHandler.isOnScreen(render.location)
@@ -160,7 +170,7 @@ export class NameLayer implements Layer {
}
}
- public renderLayer(mainContex: CanvasRenderingContext2D) {
+ public renderLayer() {
const screenPosOld = this.transformHandler.worldToScreenCoordinates(
new Cell(0, 0),
);
@@ -168,7 +178,11 @@ export class NameLayer implements Layer {
screenPosOld.x - window.innerWidth / 2,
screenPosOld.y - window.innerHeight / 2,
);
- this.container.style.transform = `translate(${screenPos.x}px, ${screenPos.y}px) scale(${this.transformHandler.scale})`;
+ const newTransform = `translate(${screenPos.x}px, ${screenPos.y}px) scale(${this.transformHandler.scale})`;
+ if (this.lastContainerTransform !== newTransform) {
+ this.container.style.transform = newTransform;
+ this.lastContainerTransform = newTransform;
+ }
const now = Date.now();
if (now > this.lastChecked + this.renderCheckRate) {
@@ -177,14 +191,6 @@ export class NameLayer implements Layer {
this.renderPlayerInfo(render);
}
}
-
- mainContex.drawImage(
- this.canvas,
- 0,
- 0,
- mainContex.canvas.width,
- mainContex.canvas.height,
- );
}
private createPlayerElement(player: PlayerView): HTMLDivElement {
@@ -196,7 +202,7 @@ export class NameLayer implements Layer {
element.style.gap = "0px";
const iconsDiv = document.createElement("div");
- iconsDiv.classList.add("player-icons");
+ iconsDiv.classList.add(PLAYER_ICONS);
iconsDiv.style.display = "flex";
iconsDiv.style.gap = "4px";
iconsDiv.style.justifyContent = "center";
@@ -207,7 +213,7 @@ export class NameLayer implements Layer {
const nameDiv = document.createElement("div");
const applyFlagStyles = (element: HTMLElement): void => {
- element.classList.add("player-flag");
+ element.classList.add(PLAYER_FLAG);
element.style.opacity = "0.8";
element.style.zIndex = "1";
element.style.aspectRatio = "3/4";
@@ -227,7 +233,7 @@ export class NameLayer implements Layer {
nameDiv.appendChild(flagImg);
}
}
- nameDiv.classList.add("player-name");
+ nameDiv.classList.add(PLAYER_NAME);
nameDiv.style.color = this.theme.textColor(player);
nameDiv.style.fontFamily = this.theme.font();
nameDiv.style.whiteSpace = "nowrap";
@@ -238,13 +244,13 @@ export class NameLayer implements Layer {
nameDiv.style.alignItems = "center";
const nameSpan = document.createElement("span");
- nameSpan.className = "player-name-span";
+ nameSpan.className = PLAYER_NAME_SPAN;
nameSpan.textContent = player.displayName();
nameDiv.appendChild(nameSpan);
element.appendChild(nameDiv);
const troopsDiv = document.createElement("div");
- troopsDiv.classList.add("player-troops");
+ troopsDiv.classList.add(PLAYER_TROOPS);
troopsDiv.setAttribute("translate", "no");
troopsDiv.textContent = renderTroops(player.troops());
troopsDiv.style.color = this.theme.textColor(player);
@@ -253,33 +259,6 @@ export class NameLayer implements Layer {
troopsDiv.style.marginTop = "-5%";
element.appendChild(troopsDiv);
- // TODO: Remove the shield icon.
- /* eslint-disable no-constant-condition */
- if (false) {
- const shieldDiv = document.createElement("div");
- shieldDiv.classList.add("player-shield");
- shieldDiv.style.zIndex = "3";
- shieldDiv.style.marginTop = "-5%";
- shieldDiv.style.display = "flex";
- shieldDiv.style.alignItems = "center";
- shieldDiv.style.gap = "0px";
- const shieldImg = document.createElement("img");
- shieldImg.src = this.shieldIconImage.src;
- shieldImg.style.width = "16px";
- shieldImg.style.height = "16px";
-
- const shieldSpan = document.createElement("span");
- shieldSpan.textContent = "0";
- shieldSpan.style.color = "black";
- shieldSpan.style.fontSize = "10px";
- shieldSpan.style.marginTop = "-2px";
-
- shieldDiv.appendChild(shieldImg);
- shieldDiv.appendChild(shieldSpan);
- element.appendChild(shieldDiv);
- }
- /* eslint-enable no-constant-condition */
-
// Start off invisible so it doesn't flash at 0,0
element.style.display = "none";
@@ -297,85 +276,57 @@ export class NameLayer implements Layer {
return;
}
- const oldLocation = render.location;
- render.location = new Cell(
- render.player.nameLocation().x,
- render.player.nameLocation().y,
- );
+ // Update location and size, show or hide dependent on those
+ const nameLocation = render.player.nameLocation();
+ const newX = nameLocation.x;
+ const newY = nameLocation.y;
- // Calculate base size and scale
- const baseSize = Math.max(1, Math.floor(render.player.nameLocation().size));
- render.fontSize = Math.max(4, Math.floor(baseSize * 0.4));
- render.fontColor = this.theme.textColor(render.player);
+ if (
+ !render.location ||
+ render.location.x !== newX ||
+ render.location.y !== newY
+ ) {
+ render.location = new Cell(newX, newY);
+ }
- // Update element visibility (handles Ctrl key, size, and screen position)
- this.updateElementVisibility(render);
+ const baseSize = Math.max(1, Math.floor(nameLocation.size));
+ this.updateElementVisibility(render, baseSize);
- // If element is hidden, don't continue with rendering
if (render.element.style.display === "none") {
return;
}
- // Throttle updates
+ // Throttle further updates
const now = Date.now();
if (now - render.lastRenderCalc <= this.renderRefreshRate) {
return;
}
render.lastRenderCalc = now + this.rand.nextInt(0, 100);
- // Update text sizes
- const nameDiv = render.element.querySelector(
- ".player-name",
- ) as HTMLDivElement;
- const flagDiv = render.element.querySelector(
- ".player-flag",
- ) as HTMLDivElement;
- const troopsDiv = render.element.querySelector(
- ".player-troops",
- ) as HTMLDivElement;
+ // Update text sizes and opacity
+ render.fontSize = Math.max(4, Math.floor(baseSize * 0.4));
+ render.fontColor = this.theme.textColor(render.player);
const nameOpacityPercent = this.userSettings.playerNameOpacity();
const nameOpacity = nameOpacityPercent / 100;
const hideFlag = nameOpacityPercent === 0;
- nameDiv.style.fontSize = `${render.fontSize}px`;
- nameDiv.style.lineHeight = `${render.fontSize}px`;
- nameDiv.style.color = render.fontColor;
- const span = nameDiv.querySelector(
- ".player-name-span",
- ) as HTMLSpanElement | null;
- if (span) {
- span.textContent = render.player.displayName();
- span.style.opacity = `${nameOpacity}`;
- }
- if (flagDiv) {
- flagDiv.style.height = `${render.fontSize}px`;
- flagDiv.style.display = hideFlag ? "none" : "";
- }
- troopsDiv.style.fontSize = `${render.fontSize}px`;
- troopsDiv.style.opacity = `${nameOpacity}`;
- troopsDiv.style.color = render.fontColor;
- troopsDiv.textContent = renderTroops(render.player.troops());
- const density = renderNumber(
- render.player.troops() / render.player.numTilesOwned(),
- );
- const shieldDiv: HTMLDivElement | null =
- render.element.querySelector(".player-shield");
- const shieldImg = shieldDiv?.querySelector("img");
- const shieldNumber = shieldDiv?.querySelector("span");
- if (shieldImg) {
- shieldImg.style.width = `${render.fontSize * 0.8}px`;
- shieldImg.style.height = `${render.fontSize * 0.8}px`;
+ render.nameDiv.style.fontSize = `${render.fontSize}px`;
+ render.nameDiv.style.lineHeight = `${render.fontSize}px`;
+ render.nameDiv.style.color = render.fontColor;
+ if (render.nameSpan) {
+ render.nameSpan.textContent = render.player.displayName();
+ render.nameSpan.style.opacity = `${nameOpacity}`;
}
- if (shieldNumber) {
- shieldNumber.style.fontSize = `${render.fontSize * 0.6}px`;
- shieldNumber.style.marginTop = `${-render.fontSize * 0.1}px`;
- shieldNumber.textContent = density;
+ if (render.flagDiv) {
+ render.flagDiv.style.height = `${render.fontSize}px`;
+ render.flagDiv.style.display = hideFlag ? "none" : "";
}
+ render.troopsDiv.style.fontSize = `${render.fontSize}px`;
+ render.troopsDiv.style.opacity = `${nameOpacity}`;
+ render.troopsDiv.style.color = render.fontColor;
+ render.troopsDiv.textContent = renderTroops(render.player.troops());
// Handle icons
- const iconsDiv = render.element.querySelector(
- ".player-icons",
- ) as HTMLDivElement;
const iconSize = Math.min(render.fontSize * 1.5, 48);
// Compute which icons should be shown for this player using shared logic
@@ -407,7 +358,7 @@ export class NameLayer implements Layer {
emojiDiv.style.position = "absolute";
emojiDiv.style.top = "50%";
emojiDiv.style.transform = "translateY(-50%)";
- iconsDiv.appendChild(emojiDiv);
+ render.iconsDiv.appendChild(emojiDiv);
render.icons.set(icon.id, emojiDiv);
}
@@ -444,7 +395,7 @@ export class NameLayer implements Layer {
hasExtensionRequest,
this.userSettings.darkMode(),
);
- iconsDiv.appendChild(allianceWrapper);
+ render.iconsDiv.appendChild(allianceWrapper);
render.icons.set(icon.id, allianceWrapper);
} else {
// Update existing alliance icon
@@ -484,7 +435,7 @@ export class NameLayer implements Layer {
if (!imgElement) {
imgElement = this.createIconElement(icon.src, iconSize, icon.center);
- iconsDiv.appendChild(imgElement);
+ render.iconsDiv.appendChild(imgElement);
render.icons.set(icon.id, imgElement);
}
@@ -527,10 +478,11 @@ export class NameLayer implements Layer {
}
// Position element with scale
- if (render.location && render.location !== oldLocation) {
- const scale = Math.min(baseSize * 0.25, 3);
- render.element.style.transform = `translate(${render.location.x}px, ${render.location.y}px) translate(-50%, -50%) scale(${scale})`;
- }
+ // Even when positionChanged is false: Scale update otherwise sometimes only happens after seconds which looks buggy.
+ // Because of sometimes overlapping delays of 20 ticks for nameLocation() (largestClusterBoundingBox in PlayerExecution)
+ // and the 500ms renderRefreshRate in NameLayer.
+ const scale = Math.min(baseSize * 0.25, 3);
+ render.element.style.transform = `translate(${newX}px, ${newY}px) translate(-50%, -50%) scale(${scale})`;
}
private createIconElement(
diff --git a/src/core/execution/TransportShipExecution.ts b/src/core/execution/TransportShipExecution.ts
index 3504e691c..e24a9ac3b 100644
--- a/src/core/execution/TransportShipExecution.ts
+++ b/src/core/execution/TransportShipExecution.ts
@@ -4,6 +4,7 @@ import {
Game,
MessageType,
Player,
+ PlayerType,
TerraNullius,
Unit,
UnitType,
@@ -76,6 +77,16 @@ export class TransportShipExecution implements Execution {
return;
}
+ if (this.target.isPlayer()) {
+ const targetPlayer = this.target as Player;
+ if (
+ targetPlayer.type() !== PlayerType.Bot &&
+ this.attacker.type() !== PlayerType.Bot
+ ) {
+ this.rejectIncomingAllianceRequests(targetPlayer);
+ }
+ }
+
if (this.target.isPlayer() && !this.attacker.canAttackPlayer(this.target)) {
this.active = false;
return;
@@ -290,4 +301,13 @@ export class TransportShipExecution implements Execution {
isActive(): boolean {
return this.active;
}
+
+ private rejectIncomingAllianceRequests(target: Player) {
+ const request = this.attacker
+ .incomingAllianceRequests()
+ .find((ar) => ar.requestor() === target);
+ if (request !== undefined) {
+ request.reject();
+ }
+ }
}
diff --git a/src/server/GameManager.ts b/src/server/GameManager.ts
index 11e2a87b9..ceecc8526 100644
--- a/src/server/GameManager.ts
+++ b/src/server/GameManager.ts
@@ -71,7 +71,7 @@ export class GameManager {
gameMap: GameMapType.World,
gameType: GameType.Private,
gameMapSize: GameMapSize.Normal,
- difficulty: Difficulty.Medium,
+ difficulty: Difficulty.Easy,
nations: "default",
infiniteGold: false,
infiniteTroops: false,
diff --git a/tests/Attack.test.ts b/tests/Attack.test.ts
index dbf05b7c2..5f741559f 100644
--- a/tests/Attack.test.ts
+++ b/tests/Attack.test.ts
@@ -333,6 +333,54 @@ describe("Attack race condition with alliance requests", () => {
});
});
+describe("Transport ship alliance rejection", () => {
+ beforeEach(async () => {
+ game = await setup("ocean_and_land", {
+ infiniteGold: true,
+ instantBuild: true,
+ infiniteTroops: true,
+ });
+
+ const playerAInfo = new PlayerInfo(
+ "playerA",
+ PlayerType.Human,
+ null,
+ "playerA_id",
+ );
+ // close to the water to send boats
+ playerA = addPlayerToGame(playerAInfo, game, game.ref(7, 0));
+
+ const playerBInfo = new PlayerInfo(
+ "playerB",
+ PlayerType.Human,
+ null,
+ "playerB_id",
+ );
+ playerB = addPlayerToGame(playerBInfo, game, game.ref(7, 15));
+
+ while (game.inSpawnPhase()) {
+ game.executeNextTick();
+ }
+ });
+
+ test("Should cancel alliance requests if the recipient sends a transport ship", async () => {
+ // Player A sends alliance request to Player B
+ const allianceRequest = playerA.createAllianceRequest(playerB);
+ expect(allianceRequest).not.toBeNull();
+ expect(playerB.incomingAllianceRequests()).toHaveLength(1);
+
+ // Player B sends a transport ship toward Player A's territory
+ game.addExecution(new TransportShipExecution(playerB, game.ref(7, 0), 0));
+
+ // Execute a tick to process the transport ship launch
+ game.executeNextTick();
+
+ // Alliance request should be rejected since player B sent a naval invasion
+ expect(playerA.outgoingAllianceRequests()).toHaveLength(0);
+ expect(playerB.incomingAllianceRequests()).toHaveLength(0);
+ });
+});
+
describe("Attack immunity", () => {
beforeEach(async () => {
game = await setup("ocean_and_land", {