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")}

@@ -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``} +
    + ${mapName { + (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", {