Merge branch 'main' into player-text-opacity

This commit is contained in:
bijx
2026-03-21 01:49:34 -04:00
10 changed files with 428 additions and 219 deletions
+36 -29
View File
@@ -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",
+2 -2
View File
@@ -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",
+8 -5
View File
@@ -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",
+9 -12
View File
@@ -624,10 +624,11 @@ export class HelpModal extends BaseModal {
${translateText("help_modal.ui_options_desc")}
</p>
<ul class="space-y-2 list-disc pl-4 text-white/60">
<li>${translateText("help_modal.option_pause")}</li>
<li>${translateText("help_modal.option_timer")}</li>
<li>${translateText("help_modal.option_exit")}</li>
<li>${translateText("help_modal.option_speed")}</li>
<li>${translateText("help_modal.option_pause")}</li>
<li>${translateText("help_modal.option_settings")}</li>
<li>${translateText("help_modal.option_exit")}</li>
</ul>
</div>
</div>
@@ -718,8 +719,7 @@ export class HelpModal extends BaseModal {
<li class="flex items-center gap-3">
<img
src="/images/InfoIcon.svg"
class="w-5 h-5 opacity-80"
loading="lazy"
class="w-8 h-8 scale-75 origin-left"
/>
<span>${translateText("help_modal.radial_info")}</span>
</li>
@@ -848,14 +848,11 @@ export class HelpModal extends BaseModal {
<span>${translateText("help_modal.info_emoji")}</span>
</li>
<li class="flex items-center gap-3">
<div
class="flex items-center justify-center w-8 h-8 opacity-80"
>
<img
src="/images/helpModal/stopTrading.webp"
class="w-full h-full object-contain"
/>
</div>
<img
src="/images/StopIconWhite.png"
class="w-8 h-8 scale-75 origin-left"
loading="lazy"
/>
<span>${translateText("help_modal.info_trade")}</span>
</li>
</ul>
+199 -41
View File
@@ -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`<lobby-config-item
.label=${translateText("host_modal.crowded")}
.value=${translateText("common.enabled")}
></lobby-config-item>`,
);
if (
pm?.isHardNations ||
(c.gameType === GameType.Private && c.difficulty !== Difficulty.Easy)
)
cards.push(
html`<lobby-config-item
.label=${translateText("difficulty.difficulty")}
.value=${translateText(`difficulty.${c.difficulty.toLowerCase()}`)}
></lobby-config-item>`,
);
if (c.infiniteTroops)
cards.push(
html`<lobby-config-item
.label=${translateText("host_modal.infinite_troops")}
.value=${translateText("common.enabled")}
></lobby-config-item>`,
);
if (c.infiniteGold)
cards.push(
html`<lobby-config-item
.label=${translateText("host_modal.infinite_gold")}
.value=${translateText("common.enabled")}
></lobby-config-item>`,
);
if (c.instantBuild)
cards.push(
html`<lobby-config-item
.label=${translateText("host_modal.instant_build")}
.value=${translateText("common.enabled")}
></lobby-config-item>`,
);
if (c.randomSpawn)
cards.push(
html`<lobby-config-item
.label=${translateText("host_modal.random_spawn")}
.value=${translateText("common.enabled")}
></lobby-config-item>`,
);
if (c.maxTimerValue)
cards.push(
html`<lobby-config-item
.label=${translateText("private_lobby.game_length")}
.value=${`${c.maxTimerValue} min`}
></lobby-config-item>`,
);
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`<lobby-config-item
.label=${translateText("private_lobby.pvp_immunity")}
.value=${immunityValue}
></lobby-config-item>`,
);
}
if (c.startingGold)
cards.push(
html`<lobby-config-item
.label=${translateText("private_lobby.starting_gold")}
.value=${`${parseFloat((c.startingGold / 1_000_000).toPrecision(12))}M`}
></lobby-config-item>`,
);
if (c.goldMultiplier)
cards.push(
html`<lobby-config-item
.label=${translateText("host_modal.gold_multiplier")}
.value=${`x${c.goldMultiplier}`}
></lobby-config-item>`,
);
if (c.disableAlliances)
cards.push(
html`<lobby-config-item
.label=${translateText(
"public_game_modifier.disable_alliances_label",
)}
.value=${translateText("common.disabled")}
></lobby-config-item>`,
);
if ((isTeam && !c.donateGold) || (!isTeam && c.donateGold))
cards.push(
html`<lobby-config-item
.label=${translateText("host_modal.donate_gold")}
.value=${translateText(
c.donateGold ? "common.enabled" : "common.disabled",
)}
></lobby-config-item>`,
);
if ((isTeam && !c.donateTroops) || (!isTeam && c.donateTroops))
cards.push(
html`<lobby-config-item
.label=${translateText("host_modal.donate_troops")}
.value=${translateText(
c.donateTroops ? "common.enabled" : "common.disabled",
)}
></lobby-config-item>`,
);
const isCompact =
c.gameMapSize === GameMapSize.Compact || c.publicGameModifiers?.isCompact;
if (isCompact)
cards.push(
html`<lobby-config-item
.label=${translateText("host_modal.compact_map")}
.value=${translateText("common.enabled")}
></lobby-config-item>`,
);
{
const defaultBots = isCompact ? 100 : 400;
if (c.bots !== defaultBots)
cards.push(
html`<lobby-config-item
.label=${translateText("host_modal.bots")}
.value=${String(c.bots)}
></lobby-config-item>`,
);
}
{
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`<lobby-config-item
.label=${translateText("host_modal.nations")}
.value=${String(c.nations)}
></lobby-config-item>`,
);
}
if (c.nations === "disabled" && !(c.gameType === GameType.Public && isTeam))
cards.push(
html`<lobby-config-item
.label=${translateText("host_modal.nations")}
.value=${translateText("common.disabled")}
></lobby-config-item>`,
);
return html`
<div class="grid grid-cols-2 sm:grid-cols-3 gap-2">
<lobby-config-item
.label=${translateText("map.map")}
.value=${mapName}
></lobby-config-item>
<lobby-config-item
.label=${translateText("host_modal.mode")}
.value=${modeName}
></lobby-config-item>
${modifiers.map(
(m) => html`
<lobby-config-item
.label=${translateText(m.labelKey)}
.value=${m.formattedValue ??
(m.value !== undefined
? renderNumber(m.value)
: translateText("common.enabled"))}
></lobby-config-item>
`,
)}
${c.gameMode !== GameMode.FFA &&
c.playerTeams &&
c.playerTeams !== HumansVsNations
? html`
<lobby-config-item
.label=${typeof c.playerTeams === "string"
? translateText("host_modal.team_type")
: translateText("host_modal.team_count")}
.value=${typeof c.playerTeams === "string"
? translateText("host_modal.teams_" + c.playerTeams)
: c.playerTeams.toString()}
></lobby-config-item>
`
: html``}
<div class="flex items-center gap-3 mb-6">
<img
src=${thumbnailUrl}
alt=${mapName ?? c.gameMap}
class="w-20 h-20 rounded-lg object-cover border border-white/10 shrink-0"
@error=${(e: Event) => {
(e.target as HTMLImageElement).style.display = "none";
}}
/>
<div class="flex flex-col gap-1">
<span class="text-lg font-bold text-white">${mapName}</span>
<span class="text-sm text-white/60">${modeSubtitle}</span>
</div>
</div>
${cards.length > 0
? html`<div class="grid grid-cols-2 sm:grid-cols-3 gap-2 mb-6">
${cards}
</div>`
: html``}
${this.renderDisabledUnits()}
`;
}
@@ -495,7 +651,9 @@ export class JoinLobbyModal extends BaseModal {
};
return html`
<div class="mt-4 p-3 bg-red-500/10 border border-red-500/20 rounded-lg">
<div
class="mt-4 mb-6 p-3 bg-red-500/10 border border-red-500/20 rounded-lg"
>
<div
class="text-xs font-bold text-red-400 uppercase tracking-widest mb-2"
>
+24
View File
@@ -13,6 +13,12 @@ import {
} from "../core/validations/username";
import { crazyGamesSDK } from "./CrazyGamesSDK";
interface LangSelectorLike {
currentLang?: string;
translations?: Record<string, string>;
defaultTranslations?: Record<string, string>;
}
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<LangSelectorLike & Element>(
"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) {
+81 -129
View File
@@ -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<PlayerIconId, HTMLElement> = 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<PlayerView> = 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(
@@ -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();
}
}
}
+1 -1
View File
@@ -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,
+48
View File
@@ -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", {