diff --git a/src/client/graphics/PlayerIcons.ts b/src/client/graphics/PlayerIcons.ts new file mode 100644 index 000000000..6f1602578 --- /dev/null +++ b/src/client/graphics/PlayerIcons.ts @@ -0,0 +1,154 @@ +import allianceIcon from "../../../resources/images/AllianceIcon.svg"; +import allianceRequestBlackIcon from "../../../resources/images/AllianceRequestBlackIcon.svg"; +import allianceRequestWhiteIcon from "../../../resources/images/AllianceRequestWhiteIcon.svg"; +import crownIcon from "../../../resources/images/CrownIcon.svg"; +import disconnectedIcon from "../../../resources/images/DisconnectedIcon.svg"; +import embargoBlackIcon from "../../../resources/images/EmbargoBlackIcon.svg"; +import embargoWhiteIcon from "../../../resources/images/EmbargoWhiteIcon.svg"; +import nukeRedIcon from "../../../resources/images/NukeIconRed.svg"; +import nukeWhiteIcon from "../../../resources/images/NukeIconWhite.svg"; +import targetIcon from "../../../resources/images/TargetIcon.svg"; +import traitorIcon from "../../../resources/images/TraitorIcon.svg"; +import { AllPlayers, nukeTypes } from "../../core/game/Game"; +import { GameView, PlayerView } from "../../core/game/GameView"; + +export type PlayerIconId = + | "crown" + | "traitor" + | "disconnected" + | "alliance" + | "alliance-request" + | "target" + | "emoji" + | "embargo" + | "nuke"; + +export type PlayerIconKind = "image" | "emoji"; + +export interface PlayerIconDescriptor { + id: PlayerIconId; + kind: PlayerIconKind; + /** Image URL for image icons */ + src?: string; + /** Text content for emoji icons */ + text?: string; + /** Whether the icon should be visually centered over the name */ + center?: boolean; +} + +export interface PlayerIconParams { + game: GameView; + player: PlayerView; + /** Whether the alliance icon (handshake) should be included */ + includeAllianceIcon: boolean; + /** Player currently in first place, used for the crown icon */ + firstPlace: PlayerView | null; +} + +export function getFirstPlacePlayer(game: GameView): PlayerView | null { + const sorted = game + .playerViews() + .sort((a, b) => b.numTilesOwned() - a.numTilesOwned()); + + return sorted.length > 0 ? sorted[0] : null; +} + +export function getPlayerIcons( + params: PlayerIconParams, +): PlayerIconDescriptor[] { + const { game, player, includeAllianceIcon, firstPlace } = params; + + const myPlayer = game.myPlayer(); + const userSettings = game.config().userSettings(); + const isDarkMode = userSettings?.darkMode() ?? false; + const emojisEnabled = userSettings?.emojis() ?? false; + + const icons: PlayerIconDescriptor[] = []; + + // Crown icon for first place + if (player === firstPlace) { + icons.push({ id: "crown", kind: "image", src: crownIcon }); + } + + // Traitor icon + if (player.isTraitor()) { + icons.push({ id: "traitor", kind: "image", src: traitorIcon }); + } + + // Disconnected icon + if (player.isDisconnected()) { + icons.push({ id: "disconnected", kind: "image", src: disconnectedIcon }); + } + + // Alliance icon + if ( + includeAllianceIcon && + myPlayer !== null && + myPlayer.isAlliedWith(player) + ) { + icons.push({ id: "alliance", kind: "image", src: allianceIcon }); + } + + // Alliance request icon (theme dependent) + if (myPlayer !== null && player.isRequestingAllianceWith(myPlayer)) { + const allianceRequestIcon = isDarkMode + ? allianceRequestWhiteIcon + : allianceRequestBlackIcon; + icons.push({ + id: "alliance-request", + kind: "image", + src: allianceRequestIcon, + }); + } + + // Target icon (centered on the map, but regular in overlays) + if (myPlayer !== null && new Set(myPlayer.transitiveTargets()).has(player)) { + icons.push({ id: "target", kind: "image", src: targetIcon, center: true }); + } + + // Emoji handling + if (emojisEnabled) { + const emojis = player + .outgoingEmojis() + .filter( + (emoji) => + emoji.recipientID === AllPlayers || + emoji.recipientID === myPlayer?.smallID(), + ); + + if (emojis.length > 0) { + icons.push({ + id: "emoji", + kind: "emoji", + text: emojis[0].message, + }); + } + } + + // Embargo icon (theme dependent) + if (myPlayer?.hasEmbargo(player)) { + const embargoIcon = isDarkMode ? embargoWhiteIcon : embargoBlackIcon; + icons.push({ id: "embargo", kind: "image", src: embargoIcon }); + } + + // Nuke icon (different color depending on whether the local player is the target) + const nukesSentByOtherPlayer = game.units(...nukeTypes).filter((unit) => { + const isSendingNuke = player.id() === unit.owner().id(); + const notMyPlayer = !myPlayer || unit.owner().id() !== myPlayer.id(); + return isSendingNuke && notMyPlayer && unit.isActive(); + }); + + const isMyPlayerTarget = nukesSentByOtherPlayer.some((unit) => { + const detonationDst = unit.targetTile(); + if (!detonationDst || !myPlayer) return false; + const targetId = game.owner(detonationDst).id(); + return targetId === myPlayer.id(); + }); + + if (nukesSentByOtherPlayer.length > 0) { + const icon = isMyPlayerTarget ? nukeRedIcon : nukeWhiteIcon; + icons.push({ id: "nuke", kind: "image", src: icon }); + } + + return icons; +} diff --git a/src/client/graphics/layers/NameLayer.ts b/src/client/graphics/layers/NameLayer.ts index 3965c8922..649315cf6 100644 --- a/src/client/graphics/layers/NameLayer.ts +++ b/src/client/graphics/layers/NameLayer.ts @@ -1,29 +1,23 @@ -import allianceIcon from "../../../../resources/images/AllianceIcon.svg"; -import allianceRequestBlackIcon from "../../../../resources/images/AllianceRequestBlackIcon.svg"; -import allianceRequestWhiteIcon from "../../../../resources/images/AllianceRequestWhiteIcon.svg"; -import crownIcon from "../../../../resources/images/CrownIcon.svg"; -import disconnectedIcon from "../../../../resources/images/DisconnectedIcon.svg"; -import embargoBlackIcon from "../../../../resources/images/EmbargoBlackIcon.svg"; -import embargoWhiteIcon from "../../../../resources/images/EmbargoWhiteIcon.svg"; -import nukeRedIcon from "../../../../resources/images/NukeIconRed.svg"; -import nukeWhiteIcon from "../../../../resources/images/NukeIconWhite.svg"; import shieldIcon from "../../../../resources/images/ShieldIconBlack.svg"; -import targetIcon from "../../../../resources/images/TargetIcon.svg"; -import traitorIcon from "../../../../resources/images/TraitorIcon.svg"; import { renderPlayerFlag } from "../../../core/CustomFlag"; import { EventBus } from "../../../core/EventBus"; import { PseudoRandom } from "../../../core/PseudoRandom"; import { Theme } from "../../../core/configuration/Config"; -import { AllPlayers, Cell, nukeTypes } from "../../../core/game/Game"; +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 { + getFirstPlacePlayer, + getPlayerIcons, + PlayerIconId, +} from "../PlayerIcons"; import { TransformHandler } from "../TransformHandler"; import { Layer } from "./Layer"; class RenderInfo { - public icons: Map = new Map(); // Track icon elements + public icons: Map = new Map(); // Track icon elements constructor( public player: PlayerView, @@ -43,51 +37,18 @@ export class NameLayer implements Layer { private rand = new PseudoRandom(10); private renders: RenderInfo[] = []; private seenPlayers: Set = new Set(); - private traitorIconImage: HTMLImageElement; - private disconnectedIconImage: HTMLImageElement; - private allianceRequestBlackIconImage: HTMLImageElement; - private allianceRequestWhiteIconImage: HTMLImageElement; - private allianceIconImage: HTMLImageElement; - private targetIconImage: HTMLImageElement; - private crownIconImage: HTMLImageElement; - private embargoBlackIconImage: HTMLImageElement; - private embargoWhiteIconImage: HTMLImageElement; - private nukeWhiteIconImage: HTMLImageElement; - private nukeRedIconImage: HTMLImageElement; private shieldIconImage: HTMLImageElement; private container: HTMLDivElement; - private firstPlace: PlayerView | null = null; private theme: Theme = this.game.config().theme(); private userSettings: UserSettings = new UserSettings(); private isVisible: boolean = true; + private firstPlace: PlayerView | null = null; constructor( private game: GameView, private transformHandler: TransformHandler, private eventBus: EventBus, ) { - this.traitorIconImage = new Image(); - this.traitorIconImage.src = traitorIcon; - this.disconnectedIconImage = new Image(); - this.disconnectedIconImage.src = disconnectedIcon; - this.allianceIconImage = new Image(); - this.allianceIconImage.src = allianceIcon; - this.allianceRequestBlackIconImage = new Image(); - this.allianceRequestBlackIconImage.src = allianceRequestBlackIcon; - this.allianceRequestWhiteIconImage = new Image(); - this.allianceRequestWhiteIconImage.src = allianceRequestWhiteIcon; - this.crownIconImage = new Image(); - this.crownIconImage.src = crownIcon; - this.targetIconImage = new Image(); - this.targetIconImage.src = targetIcon; - this.embargoBlackIconImage = new Image(); - this.embargoBlackIconImage.src = embargoBlackIcon; - this.embargoWhiteIconImage = new Image(); - this.embargoWhiteIconImage.src = embargoWhiteIcon; - this.nukeWhiteIconImage = new Image(); - this.nukeWhiteIconImage.src = nukeWhiteIcon; - this.nukeRedIconImage = new Image(); - this.nukeRedIconImage.src = nukeRedIcon; this.shieldIconImage = new Image(); this.shieldIconImage.src = shieldIcon; } @@ -172,12 +133,9 @@ export class NameLayer implements Layer { if (this.game.ticks() % 10 !== 0) { return; } - const sorted = this.game - .playerViews() - .sort((a, b) => b.numTilesOwned() - a.numTilesOwned()); - if (sorted.length > 0) { - this.firstPlace = sorted[0]; - } + + // Precompute the first-place player for performance + this.firstPlace = getFirstPlacePlayer(this.game); for (const player of this.game.playerViews()) { if (player.isAlive()) { @@ -404,251 +362,89 @@ export class NameLayer implements Layer { ".player-icons", ) as HTMLDivElement; const iconSize = Math.min(render.fontSize * 1.5, 48); - const myPlayer = this.game.myPlayer(); - const isDarkMode = this.userSettings.darkMode(); - // Crown icon - const existingCrown = iconsDiv.querySelector('[data-icon="crown"]'); - if (render.player === this.firstPlace) { - if (!existingCrown) { - iconsDiv.appendChild( - this.createIconElement( - this.crownIconImage.src, - iconSize, - "crown", - false, - ), - ); + // Compute which icons should be shown for this player using shared logic + const icons = getPlayerIcons({ + game: this.game, + player: render.player, + includeAllianceIcon: true, + firstPlace: this.firstPlace, + }); + + // Build a set of desired icon IDs + const desiredIconIds = new Set(icons.map((icon) => icon.id)); + + // Remove any icons that are no longer needed + for (const [id, element] of render.icons) { + if (!desiredIconIds.has(id)) { + element.remove(); + render.icons.delete(id); } - } else if (existingCrown) { - existingCrown.remove(); } - // Traitor icon - let existingTraitor = iconsDiv.querySelector('[data-icon="traitor"]'); - if (render.player.isTraitor()) { - const remainingTicks = render.player.getTraitorRemainingTicks(); - // Use precise seconds (not rounded) for smoother transitions, rounded to 0.5s intervals - const remainingSeconds = Math.round((remainingTicks / 10) * 2) / 2; + // Add or update icons that should be shown + for (const icon of icons) { + if (icon.kind === "emoji" && icon.text) { + let emojiDiv = render.icons.get(icon.id) as HTMLDivElement | undefined; - if (!existingTraitor) { - existingTraitor = this.createIconElement( - this.traitorIconImage.src, - iconSize, - "traitor", - ); - iconsDiv.appendChild(existingTraitor); - } + if (!emojiDiv) { + emojiDiv = document.createElement("div"); + emojiDiv.style.position = "absolute"; + emojiDiv.style.top = "50%"; + emojiDiv.style.transform = "translateY(-50%)"; + iconsDiv.appendChild(emojiDiv); + render.icons.set(icon.id, emojiDiv); + } - // Apply flashing animation - smooth speed increase starting at 15s - if (existingTraitor instanceof HTMLImageElement) { - if (remainingSeconds <= 15) { - // Smooth transition: starts at 1s at 15 seconds, decreases to 0.2s at 0 seconds - // Using cubic ease-out for slower, more gradual acceleration - const clampedSeconds = Math.max(0, Math.min(15, remainingSeconds)); - const normalizedTime = clampedSeconds / 15; // 0 to 1 (1 = 15s remaining, 0 = 0s remaining) + emojiDiv.textContent = icon.text; + emojiDiv.style.fontSize = `${iconSize}px`; + } else if (icon.kind === "image" && icon.src) { + let imgElement = render.icons.get(icon.id) as + | HTMLImageElement + | undefined; - // Cubic ease-out: slower acceleration, smoother transition - const easedProgress = 1 - Math.pow(1 - normalizedTime, 3); + if (!imgElement) { + imgElement = this.createIconElement(icon.src, iconSize, icon.center); + iconsDiv.appendChild(imgElement); + render.icons.set(icon.id, imgElement); + } - const maxDuration = 1.0; // Slow flash at 15 seconds - const minDuration = 0.2; // Fast flash at 0 seconds - const duration = - minDuration + (maxDuration - minDuration) * easedProgress; - const animationDuration = `${duration.toFixed(2)}s`; + // Update src if it changed (e.g., nuke red/white or dark-mode icons) + if (imgElement.src !== icon.src) { + imgElement.src = icon.src; + } - existingTraitor.style.animation = `traitorFlash ${animationDuration} infinite`; - existingTraitor.style.animationTimingFunction = "ease-in-out"; - } else { - // Don't flash if more than 15 seconds remaining - existingTraitor.style.animation = "none"; + imgElement.style.width = `${iconSize}px`; + imgElement.style.height = `${iconSize}px`; + + // Traitor flashing - smooth speed increase starting at 15s + if (icon.id === "traitor") { + const remainingTicks = render.player.getTraitorRemainingTicks(); + // Use precise seconds (not rounded) for smoother transitions, rounded to 0.5s intervals + const remainingSeconds = Math.round((remainingTicks / 10) * 2) / 2; + + if (remainingSeconds <= 15) { + // Smooth transition: starts at 1s at 15 seconds, decreases to 0.2s at 0 seconds + // Using cubic ease-out for slower, more gradual acceleration + const clampedSeconds = Math.max(0, Math.min(15, remainingSeconds)); + const normalizedTime = clampedSeconds / 15; // 0 to 1 (1 = 15s remaining, 0 = 0s remaining) + + // Cubic ease-out: slower acceleration, smoother transition + const easedProgress = 1 - Math.pow(1 - normalizedTime, 3); + const maxDuration = 1.0; // Slow flash at 15 seconds + const minDuration = 0.2; // Fast flash at 0 seconds + const duration = + minDuration + (maxDuration - minDuration) * easedProgress; + const animationDuration = `${duration.toFixed(2)}s`; + + imgElement.style.animation = `traitorFlash ${animationDuration} infinite`; + imgElement.style.animationTimingFunction = "ease-in-out"; + } else { + // Don't flash if more than 15 seconds remaining + imgElement.style.animation = "none"; + } } } - } else if (existingTraitor) { - existingTraitor.remove(); - } - - // Disconnected icon - const existingDisconnected = iconsDiv.querySelector( - '[data-icon="disconnected"]', - ); - if (render.player.isDisconnected()) { - if (!existingDisconnected) { - iconsDiv.appendChild( - this.createIconElement( - this.disconnectedIconImage.src, - iconSize, - "disconnected", - ), - ); - } - } else if (existingDisconnected) { - existingDisconnected.remove(); - } - - // Alliance icon - const existingAlliance = iconsDiv.querySelector('[data-icon="alliance"]'); - if (myPlayer !== null && myPlayer.isAlliedWith(render.player)) { - if (!existingAlliance) { - iconsDiv.appendChild( - this.createIconElement( - this.allianceIconImage.src, - iconSize, - "alliance", - ), - ); - } - } else if (existingAlliance) { - existingAlliance.remove(); - } - - // Alliance request icon - let existingRequestAlliance = iconsDiv.querySelector( - '[data-icon="alliance-request"]', - ); - const isThemeAllianceRequestIcon = - existingRequestAlliance?.getAttribute("dark-mode") === - isDarkMode.toString(); - const AllianceRequestIconImageSrc = isDarkMode - ? this.allianceRequestWhiteIconImage.src - : this.allianceRequestBlackIconImage.src; - - if (myPlayer !== null && render.player.isRequestingAllianceWith(myPlayer)) { - // Create new icon to match theme - if (existingRequestAlliance && !isThemeAllianceRequestIcon) { - existingRequestAlliance.remove(); - existingRequestAlliance = null; - } - - if (!existingRequestAlliance) { - iconsDiv.appendChild( - this.createIconElement( - AllianceRequestIconImageSrc, - iconSize, - "alliance-request", - ), - ); - } - } else if (existingRequestAlliance) { - existingRequestAlliance.remove(); - } - - // Target icon - const existingTarget = iconsDiv.querySelector('[data-icon="target"]'); - if ( - myPlayer !== null && - new Set(myPlayer.transitiveTargets()).has(render.player) - ) { - if (!existingTarget) { - iconsDiv.appendChild( - this.createIconElement( - this.targetIconImage.src, - iconSize, - "target", - true, - ), - ); - } - } else if (existingTarget) { - existingTarget.remove(); - } - - // Emoji handling - const existingEmoji = iconsDiv.querySelector('[data-icon="emoji"]'); - const emojis = render.player - .outgoingEmojis() - .filter( - (emoji) => - emoji.recipientID === AllPlayers || - emoji.recipientID === myPlayer?.smallID(), - ); - - if (this.game.config().userSettings()?.emojis() && emojis.length > 0) { - if (!existingEmoji) { - const emojiDiv = document.createElement("div"); - emojiDiv.setAttribute("data-icon", "emoji"); - emojiDiv.style.fontSize = `${iconSize}px`; - emojiDiv.textContent = emojis[0].message; - emojiDiv.style.position = "absolute"; - emojiDiv.style.top = "50%"; - emojiDiv.style.transform = "translateY(-50%)"; - iconsDiv.appendChild(emojiDiv); - } - } else if (existingEmoji) { - existingEmoji.remove(); - } - - // Embargo icon - let existingEmbargo = iconsDiv.querySelector('[data-icon="embargo"]'); - const isThemeEmbargoIcon = - existingEmbargo?.getAttribute("dark-mode") === isDarkMode.toString(); - const embargoIconImageSrc = isDarkMode - ? this.embargoWhiteIconImage.src - : this.embargoBlackIconImage.src; - - if (myPlayer?.hasEmbargo(render.player)) { - // Create new icon to match theme - if (existingEmbargo && !isThemeEmbargoIcon) { - existingEmbargo.remove(); - existingEmbargo = null; - } - - if (!existingEmbargo) { - iconsDiv.appendChild( - this.createIconElement(embargoIconImageSrc, iconSize, "embargo"), - ); - } - } else if (existingEmbargo) { - existingEmbargo.remove(); - } - - const nukesSentByOtherPlayer = this.game.units().filter((unit) => { - const isSendingNuke = render.player.id() === unit.owner().id(); - const notMyPlayer = !myPlayer || unit.owner().id() !== myPlayer.id(); - return ( - nukeTypes.includes(unit.type()) && - isSendingNuke && - notMyPlayer && - unit.isActive() - ); - }); - const isMyPlayerTarget = nukesSentByOtherPlayer.find((unit) => { - const detonationDst = unit.targetTile(); - if (detonationDst === undefined) return false; - const targetId = this.game.owner(detonationDst).id(); - return myPlayer && targetId === myPlayer.id(); - }); - const existingNuke = iconsDiv.querySelector( - '[data-icon="nuke"]', - ) as HTMLImageElement; - - if (existingNuke) { - if (nukesSentByOtherPlayer.length === 0) { - existingNuke.remove(); - } else if ( - isMyPlayerTarget && - existingNuke.src !== this.nukeRedIconImage.src - ) { - existingNuke.src = this.nukeRedIconImage.src; - } else if ( - !isMyPlayerTarget && - existingNuke.src !== this.nukeWhiteIconImage.src - ) { - existingNuke.src = this.nukeWhiteIconImage.src; - } - } else if (nukesSentByOtherPlayer.length > 0) { - if (!existingNuke) { - const icon = isMyPlayerTarget - ? this.nukeRedIconImage.src - : this.nukeWhiteIconImage.src; - iconsDiv.appendChild(this.createIconElement(icon, iconSize, "nuke")); - } - } - // Update all icon sizes - const icons = iconsDiv.getElementsByTagName("img"); - for (const icon of icons) { - icon.style.width = `${iconSize}px`; - icon.style.height = `${iconSize}px`; } // Position element with scale @@ -661,14 +457,12 @@ export class NameLayer implements Layer { private createIconElement( src: string, size: number, - id: string, center: boolean = false, ): HTMLImageElement { const icon = document.createElement("img"); icon.src = src; icon.style.width = `${size}px`; icon.style.height = `${size}px`; - icon.setAttribute("data-icon", id); icon.setAttribute("dark-mode", this.userSettings.darkMode().toString()); if (center) { icon.style.position = "absolute"; diff --git a/src/client/graphics/layers/PlayerInfoOverlay.ts b/src/client/graphics/layers/PlayerInfoOverlay.ts index 83505eb28..12fd07f87 100644 --- a/src/client/graphics/layers/PlayerInfoOverlay.ts +++ b/src/client/graphics/layers/PlayerInfoOverlay.ts @@ -28,6 +28,7 @@ import { renderTroops, translateText, } from "../../Utils"; +import { getFirstPlacePlayer, getPlayerIcons } from "../PlayerIcons"; import { TransformHandler } from "../TransformHandler"; import { Layer } from "./Layer"; import { CloseRadialMenuEvent } from "./RadialMenu"; @@ -221,6 +222,33 @@ export class PlayerInfoOverlay extends LitElement implements Layer { return renderDuration(remainingSeconds); } + private renderPlayerNameIcons(player: PlayerView) { + const firstPlace = getFirstPlacePlayer(this.game); + const icons = getPlayerIcons({ + game: this.game, + player, + // Because we already show the alliance icon next to the alliance expiration timer, we don't need to show it a second time in this render + includeAllianceIcon: false, + firstPlace, + }); + + if (icons.length === 0) { + return html``; + } + + return html` + ${icons.map((icon) => + icon.kind === "emoji" && icon.text + ? html`${icon.text}` + : icon.kind === "image" && icon.src + ? html`` + : html``, + )} + `; + } + private renderPlayerInfo(player: PlayerView) { const myPlayer = this.game.myPlayer(); const isFriendly = myPlayer?.isFriendly(player); @@ -306,7 +334,8 @@ export class PlayerInfoOverlay extends LitElement implements Layer { src=${"/flags/" + player.cosmetics.flag! + ".svg"} />` : html``} - ${player.name()} + ${player.name()} + ${this.renderPlayerNameIcons(player)}