diff --git a/src/client/graphics/PlayerIcons.ts b/src/client/graphics/PlayerIcons.ts index 827974eff..935de9388 100644 --- a/src/client/graphics/PlayerIcons.ts +++ b/src/client/graphics/PlayerIcons.ts @@ -19,18 +19,43 @@ const questionMarkIcon = assetUrl("images/QuestionMarkIcon.svg"); const targetIcon = assetUrl("images/TargetIcon.svg"); const traitorIcon = assetUrl("images/TraitorIcon.svg"); -export type PlayerIconId = - | "crown" - | "traitor" - | "disconnected" - | "alliance" - | "alliance-request" - | "target" - | "emoji" - | "embargo" - | "nuke"; +let allianceIconTemplate: HTMLDivElement | undefined; -export type PlayerIconKind = "image" | "emoji"; +export const ALLIANCE_ICON_ID = "alliance" as const; +const ALLIANCE_PROGRESS_OVERLAY_CLASS = "alliance-progress-overlay"; +const ALLIANCE_QUESTION_MARK_CLASS = "alliance-question-mark"; +export const TRAITOR_ICON_ID = "traitor" as const; +const CROWN_ICON_ID = "crown" as const; +const DISCONNECTED_ICON_ID = "disconnected" as const; +const ALLIANCE_REQUEST_ICON_ID = "alliance-request" as const; +const TARGET_ICON_ID = "target" as const; +const EMOJI_ICON_ID = "emoji" as const; +const EMBARGO_ICON_ID = "embargo" as const; +const NUKE_ICON_ID = "nuke" as const; + +export const IMAGE_ICON_KIND = "image" as const; +export const EMOJI_ICON_KIND = "emoji" as const; + +export type PlayerIconId = + | typeof CROWN_ICON_ID + | typeof TRAITOR_ICON_ID + | typeof DISCONNECTED_ICON_ID + | typeof ALLIANCE_ICON_ID + | typeof ALLIANCE_REQUEST_ICON_ID + | typeof TARGET_ICON_ID + | typeof EMOJI_ICON_ID + | typeof EMBARGO_ICON_ID + | typeof NUKE_ICON_ID; + +export type PlayerIconKind = typeof IMAGE_ICON_KIND | typeof EMOJI_ICON_KIND; + +export type AllianceProgressIconRefs = { + wrapper: HTMLDivElement; + base: HTMLImageElement; + overlay: HTMLDivElement; + colored: HTMLImageElement; + questionMark: HTMLImageElement; +}; export interface PlayerIconDescriptor { id: PlayerIconId; @@ -50,6 +75,9 @@ export interface PlayerIconParams { includeAllianceIcon: boolean; /** Player currently in first place, used for the crown icon */ firstPlace: PlayerView | null; + alliancesDisabled: boolean; + darkMode?: boolean; + transitiveTargets?: PlayerView[]; } export function getFirstPlacePlayer(game: GameView): PlayerView | null { @@ -63,71 +91,99 @@ export function getFirstPlacePlayer(game: GameView): PlayerView | null { export function getPlayerIcons( params: PlayerIconParams, ): PlayerIconDescriptor[] { - const { game, player, includeAllianceIcon, firstPlace } = params; + const { + game, + player, + includeAllianceIcon, + firstPlace, + alliancesDisabled, + darkMode, + transitiveTargets, + } = params; const myPlayer = game.myPlayer(); const userSettings = game.config().userSettings(); - const isDarkMode = userSettings?.darkMode() ?? false; + const isDarkMode = darkMode ?? userSettings?.darkMode() ?? false; const emojisEnabled = userSettings?.emojis() ?? false; + const alliancesOff = alliancesDisabled ?? game.config().disableAlliances(); const icons: PlayerIconDescriptor[] = []; // Crown icon for first place if (player === firstPlace) { - icons.push({ id: "crown", kind: "image", src: crownIcon }); + icons.push({ id: CROWN_ICON_ID, kind: IMAGE_ICON_KIND, src: crownIcon }); } // Traitor icon if (player.isTraitor()) { - icons.push({ id: "traitor", kind: "image", src: traitorIcon }); + icons.push({ + id: TRAITOR_ICON_ID, + kind: IMAGE_ICON_KIND, + 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, + id: DISCONNECTED_ICON_ID, + kind: IMAGE_ICON_KIND, + src: disconnectedIcon, }); } + if (!alliancesOff) { + // Alliance icon + if ( + includeAllianceIcon && + myPlayer !== null && + myPlayer.isAlliedWith(player) + ) { + icons.push({ + id: ALLIANCE_ICON_ID, + kind: IMAGE_ICON_KIND, + src: allianceIcon, + }); + } + + // Alliance request icon (theme dependent) + if (myPlayer !== null && player.isRequestingAllianceWith(myPlayer)) { + const allianceRequestIcon = isDarkMode + ? allianceRequestWhiteIcon + : allianceRequestBlackIcon; + icons.push({ + id: ALLIANCE_REQUEST_ICON_ID, + kind: IMAGE_ICON_KIND, + 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 }); + const targets = transitiveTargets ?? myPlayer?.transitiveTargets() ?? []; + if (targets.includes(player)) { + icons.push({ + id: TARGET_ICON_ID, + kind: IMAGE_ICON_KIND, + src: targetIcon, + center: true, + }); } // Emoji handling if (emojisEnabled) { - const emojis = player + const emoji = player .outgoingEmojis() - .filter( - (emoji) => - emoji.recipientID === AllPlayers || - emoji.recipientID === myPlayer?.smallID(), + .find( + (e) => + e.recipientID === AllPlayers || e.recipientID === myPlayer?.smallID(), ); - if (emojis.length > 0) { + if (emoji) { icons.push({ - id: "emoji", - kind: "emoji", - text: emojis[0].message, + id: EMOJI_ICON_ID, + kind: EMOJI_ICON_KIND, + text: emoji.message, }); } } @@ -135,91 +191,153 @@ export function getPlayerIcons( // Embargo icon (theme dependent) if (myPlayer?.hasEmbargo(player)) { const embargoIcon = isDarkMode ? embargoWhiteIcon : embargoBlackIcon; - icons.push({ id: "embargo", kind: "image", src: embargoIcon }); + icons.push({ + id: EMBARGO_ICON_ID, + kind: IMAGE_ICON_KIND, + src: embargoIcon, + }); } // Nuke icon (different color depending on whether the local player is the target) - const nukesSentByOtherPlayer = game.units(...Nukes.types).filter((unit) => { - const isSendingNuke = player.id() === unit.owner().id(); - const notMyPlayer = !myPlayer || unit.owner().id() !== myPlayer.id(); - return isSendingNuke && notMyPlayer && unit.isActive(); - }); + if (!myPlayer || player.id() !== myPlayer.id()) { + let hasActiveNukes = false; + let isMyPlayerTarget = false; + const playerNukes = player.units(...Nukes.types); - const isMyPlayerTarget = nukesSentByOtherPlayer.some((unit) => { - const detonationDst = unit.targetTile(); - if (!detonationDst || !myPlayer) return false; - const targetId = game.owner(detonationDst).id(); - return targetId === myPlayer.id(); - }); + for (const nuke of playerNukes) { + if (nuke.isActive()) { + hasActiveNukes = true; - if (nukesSentByOtherPlayer.length > 0) { - const icon = isMyPlayerTarget ? nukeRedIcon : nukeWhiteIcon; - icons.push({ id: "nuke", kind: "image", src: icon }); + const detonationDst = nuke.targetTile(); + if ( + myPlayer && + detonationDst && + game.owner(detonationDst).id() === myPlayer.id() + ) { + isMyPlayerTarget = true; + break; + } + } + } + + if (hasActiveNukes) { + const icon = isMyPlayerTarget ? nukeRedIcon : nukeWhiteIcon; + icons.push({ id: NUKE_ICON_ID, kind: IMAGE_ICON_KIND, src: icon }); + } } return icons; } -export function createAllianceProgressIcon( +export function createAllianceProgressIconRefs( size: number, fraction: number, hasExtensionRequest: boolean, - darkMode: boolean, -): HTMLDivElement { + darkMode: string, +): AllianceProgressIconRefs { + if (!allianceIconTemplate) { + allianceIconTemplate = document.createElement("div"); + allianceIconTemplate.setAttribute("data-icon", ALLIANCE_ICON_ID); + allianceIconTemplate.style.position = "relative"; + allianceIconTemplate.style.display = "inline-block"; + allianceIconTemplate.style.flexShrink = "0"; + + const base = document.createElement("img"); + base.src = allianceIconFaded; + base.style.display = "block"; + allianceIconTemplate.appendChild(base); + + const overlay = document.createElement("div"); + overlay.className = ALLIANCE_PROGRESS_OVERLAY_CLASS; + overlay.style.position = "absolute"; + overlay.style.left = "0"; + overlay.style.top = "0"; + overlay.style.width = "100%"; + overlay.style.height = "100%"; + + const colored = document.createElement("img"); + colored.src = allianceIcon; // green icon + colored.style.display = "block"; + overlay.appendChild(colored); + + allianceIconTemplate.appendChild(overlay); + + const questionMark = document.createElement("img"); + questionMark.className = ALLIANCE_QUESTION_MARK_CLASS; + questionMark.src = questionMarkIcon; + questionMark.style.position = "absolute"; + questionMark.style.left = "0"; + questionMark.style.top = "0"; + questionMark.style.pointerEvents = "none"; + allianceIconTemplate.appendChild(questionMark); + } + // Wrapper - const wrapper = document.createElement("div"); - wrapper.setAttribute("data-icon", "alliance"); - wrapper.setAttribute("dark-mode", darkMode.toString()); - wrapper.style.position = "relative"; + const wrapper = allianceIconTemplate.cloneNode(true) as HTMLDivElement; + wrapper.setAttribute("dark-mode", darkMode); wrapper.style.width = `${size}px`; wrapper.style.height = `${size}px`; - wrapper.style.display = "inline-block"; - wrapper.style.flexShrink = "0"; // Base faded icon (full) - const base = document.createElement("img"); - base.src = allianceIconFaded; + // No QuerySelector here since we know the structure and it avoids overhead each call + const base = wrapper.childNodes[0] as HTMLImageElement; base.style.width = `${size}px`; base.style.height = `${size}px`; - base.style.display = "block"; - base.setAttribute("dark-mode", darkMode.toString()); - wrapper.appendChild(base); + base.setAttribute("dark-mode", darkMode); // Overlay container for green portion, clipped from the top via clip-path - const overlay = document.createElement("div"); - overlay.className = "alliance-progress-overlay"; - overlay.style.position = "absolute"; - overlay.style.left = "0"; - overlay.style.top = "0"; - overlay.style.width = "100%"; - overlay.style.height = "100%"; + const overlay = wrapper.childNodes[1] as HTMLDivElement; overlay.style.clipPath = computeAllianceClipPath(fraction); - const colored = document.createElement("img"); - colored.src = allianceIcon; // green icon + const colored = overlay.childNodes[0] as HTMLImageElement; colored.style.width = `${size}px`; colored.style.height = `${size}px`; - colored.style.display = "block"; - colored.setAttribute("dark-mode", darkMode.toString()); - overlay.appendChild(colored); - - wrapper.appendChild(overlay); + colored.setAttribute("dark-mode", darkMode); // Question mark overlay (shown when there's a pending extension request) - const questionMark = document.createElement("img"); - questionMark.className = "alliance-question-mark"; - questionMark.src = questionMarkIcon; - questionMark.style.position = "absolute"; - questionMark.style.left = "0"; - questionMark.style.top = "0"; + const questionMark = wrapper.childNodes[2] as HTMLImageElement; questionMark.style.width = `${size}px`; questionMark.style.height = `${size}px`; questionMark.style.display = hasExtensionRequest ? "block" : "none"; - questionMark.style.pointerEvents = "none"; - questionMark.setAttribute("dark-mode", darkMode.toString()); - wrapper.appendChild(questionMark); + questionMark.setAttribute("dark-mode", darkMode); - return wrapper; + return { + wrapper, + base, + overlay, + colored, + questionMark, + }; +} + +export function updateAllianceProgressIconRefs( + refs: AllianceProgressIconRefs, + size: number, + fraction: number, + hasExtensionRequest: boolean, + darkMode: string, +): void { + refs.wrapper.style.width = `${size}px`; + refs.wrapper.style.height = `${size}px`; + refs.wrapper.style.flexShrink = "0"; + + refs.base.style.width = `${size}px`; + refs.base.style.height = `${size}px`; + refs.base.setAttribute("dark-mode", darkMode); + + refs.colored.style.width = `${size}px`; + refs.colored.style.height = `${size}px`; + refs.colored.setAttribute("dark-mode", darkMode); + refs.overlay.style.clipPath = computeAllianceClipPath(fraction); + + if (!hasExtensionRequest) { + refs.questionMark.style.display = "none"; + } else { + refs.questionMark.style.width = `${size}px`; + refs.questionMark.style.height = `${size}px`; + refs.questionMark.style.display = "block"; + refs.questionMark.setAttribute("dark-mode", darkMode); + } } export function computeAllianceClipPath(fraction: number): string { diff --git a/src/client/graphics/layers/NameLayer.ts b/src/client/graphics/layers/NameLayer.ts index 3862b0ce5..701e09c26 100644 --- a/src/client/graphics/layers/NameLayer.ts +++ b/src/client/graphics/layers/NameLayer.ts @@ -1,18 +1,24 @@ import { assetUrl } from "src/core/AssetUrls"; import { EventBus } from "../../../core/EventBus"; import { PseudoRandom } from "../../../core/PseudoRandom"; -import { Theme } from "../../../core/configuration/Config"; +import { Config, Theme } from "../../../core/configuration/Config"; import { Cell } from "../../../core/game/Game"; import { GameView, PlayerView } from "../../../core/game/GameView"; import { UserSettings } from "../../../core/game/UserSettings"; import { AlternateViewEvent } from "../../InputHandler"; import { renderTroops } from "../../Utils"; import { - computeAllianceClipPath, - createAllianceProgressIcon, + ALLIANCE_ICON_ID, + AllianceProgressIconRefs, + createAllianceProgressIconRefs, + EMOJI_ICON_KIND, getFirstPlacePlayer, getPlayerIcons, + IMAGE_ICON_KIND, + PlayerIconDescriptor, PlayerIconId, + TRAITOR_ICON_ID, + updateAllianceProgressIconRefs, } from "../PlayerIcons"; import { TransformHandler } from "../TransformHandler"; import { Layer } from "./Layer"; @@ -24,13 +30,8 @@ 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; + public icons: Map = new Map(); + public allianceIconRefs: AllianceProgressIconRefs | null = null; constructor( public player: PlayerView, @@ -39,23 +40,17 @@ 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; - } + public nameDiv: HTMLDivElement, + public nameSpan: HTMLSpanElement, + public troopsDiv: HTMLDivElement, + public flagImg: HTMLImageElement, + public iconsDiv: HTMLDivElement, + public lastTransform: string = "", + ) {} } export class NameLayer implements Layer { + private config: Config; private lastChecked = 0; private renderCheckRate = 100; private renderRefreshRate = 500; @@ -63,11 +58,18 @@ export class NameLayer implements Layer { private renders: RenderInfo[] = []; private seenPlayers: Set = new Set(); private container: HTMLDivElement; - private theme: Theme = this.game.config().theme(); + private theme: Theme; private userSettings: UserSettings = new UserSettings(); private isVisible: boolean = true; private firstPlace: PlayerView | null = null; + private allianceDuration: number; + private alliancesDisabled: boolean = false; + private myPlayer: PlayerView | null = null; private lastContainerTransform: string = ""; + private basePlayerTemplate: HTMLDivElement; + private iconTemplate: HTMLImageElement; + private iconCenterTemplate: HTMLImageElement; + private emojiTemplate: HTMLDivElement; constructor( private game: GameView, @@ -79,9 +81,7 @@ export class NameLayer implements Layer { return false; } - redraw() { - this.theme = this.game.config().theme(); - } + redraw() {} // not affected by Canvas/WebGL context loss as this layer is DOM-based public init() { this.container = document.createElement("div"); @@ -107,6 +107,27 @@ export class NameLayer implements Layer { `; this.container.appendChild(style); + this.myPlayer = this.game.myPlayer(); + this.config = this.game.config(); + this.theme = this.config.theme(); + + this.alliancesDisabled = this.config.disableAlliances(); + this.allianceDuration = Math.max(1, this.config.allianceDuration()); + + this.basePlayerTemplate = this.createBasePlayerElement(); + + this.iconTemplate = document.createElement("img"); + + this.iconCenterTemplate = document.createElement("img"); + this.iconCenterTemplate.style.position = "absolute"; + this.iconCenterTemplate.style.top = "50%"; + this.iconCenterTemplate.style.transform = "translateY(-50%)"; + + this.emojiTemplate = document.createElement("div"); + this.emojiTemplate.style.position = "absolute"; + this.emojiTemplate.style.top = "50%"; + this.emojiTemplate.style.transform = "translateY(-50%)"; + this.eventBus.on(AlternateViewEvent, (e) => this.onAlternateViewChange(e)); } @@ -131,15 +152,15 @@ export class NameLayer implements Layer { : false; const maxZoomScale = 17; - if ( + const display = !this.isVisible || size < 7 || (this.transformHandler.scale > maxZoomScale && size > 100) || !isOnScreen - ) { - render.element.style.display = "none"; - } else { - render.element.style.display = "flex"; + ? "none" + : "flex"; + if (render.element.style.display !== display) { + render.element.style.display = display; } } @@ -155,16 +176,7 @@ export class NameLayer implements Layer { if (player.isAlive()) { if (!this.seenPlayers.has(player)) { this.seenPlayers.add(player); - this.renders.push( - new RenderInfo( - player, - 0, - null, - 0, - "", - this.createPlayerElement(player), - ), - ); + this.renders.push(this.createPlayerElement(player)); } } } @@ -187,19 +199,24 @@ export class NameLayer implements Layer { const now = Date.now(); if (now > this.lastChecked + this.renderCheckRate) { this.lastChecked = now; + + this.myPlayer ??= this.game.myPlayer(); + const transitiveTargets = this.myPlayer?.transitiveTargets() ?? []; + for (const render of this.renders) { - this.renderPlayerInfo(render); + this.renderPlayerInfo(render, transitiveTargets); } } } - private createPlayerElement(player: PlayerView): HTMLDivElement { + private createBasePlayerElement(): HTMLDivElement { const element = document.createElement("div"); element.style.position = "absolute"; - element.style.display = "flex"; element.style.flexDirection = "column"; element.style.alignItems = "center"; element.style.gap = "0px"; + // Start off invisible so it doesn't flash at 0,0 + element.style.display = "none"; const iconsDiv = document.createElement("div"); iconsDiv.classList.add(PLAYER_ICONS); @@ -212,23 +229,7 @@ export class NameLayer implements Layer { element.appendChild(iconsDiv); const nameDiv = document.createElement("div"); - const applyFlagStyles = (element: HTMLElement): void => { - element.classList.add(PLAYER_FLAG); - element.style.opacity = "0.8"; - element.style.zIndex = "1"; - element.style.objectFit = "contain"; - }; - - if (player.cosmetics.flag) { - const flag = assetUrl(player.cosmetics.flag); - const flagImg = document.createElement("img"); - flagImg.src = flag; - applyFlagStyles(flagImg); - nameDiv.appendChild(flagImg); - } nameDiv.classList.add(PLAYER_NAME); - nameDiv.style.color = this.theme.textColor(player); - nameDiv.style.fontFamily = this.theme.font(); nameDiv.style.whiteSpace = "nowrap"; nameDiv.style.textOverflow = "ellipsis"; nameDiv.style.zIndex = "3"; @@ -236,30 +237,75 @@ export class NameLayer implements Layer { nameDiv.style.justifyContent = "flex-end"; nameDiv.style.alignItems = "center"; + const flagImg = document.createElement("img"); + flagImg.classList.add(PLAYER_FLAG); + flagImg.style.opacity = "0.8"; + flagImg.style.zIndex = "1"; + flagImg.style.objectFit = "contain"; + flagImg.style.display = "none"; + nameDiv.appendChild(flagImg); + const nameSpan = document.createElement("span"); - nameSpan.className = PLAYER_NAME_SPAN; - nameSpan.textContent = player.displayName(); + nameSpan.classList.add(PLAYER_NAME_SPAN); nameDiv.appendChild(nameSpan); element.appendChild(nameDiv); const troopsDiv = document.createElement("div"); troopsDiv.classList.add(PLAYER_TROOPS); troopsDiv.setAttribute("translate", "no"); - troopsDiv.textContent = renderTroops(player.troops()); - troopsDiv.style.color = this.theme.textColor(player); - troopsDiv.style.fontFamily = this.theme.font(); troopsDiv.style.zIndex = "3"; troopsDiv.style.marginTop = "-5%"; element.appendChild(troopsDiv); - // Start off invisible so it doesn't flash at 0,0 - element.style.display = "none"; - - this.container.appendChild(element); return element; } - renderPlayerInfo(render: RenderInfo) { + private createPlayerElement(player: PlayerView): RenderInfo { + const element = this.basePlayerTemplate.cloneNode(true) as HTMLDivElement; + + // Queryselector expensive but this runs only once per player and better maintainable + const nameDiv = element.querySelector(`.${PLAYER_NAME}`) as HTMLDivElement; + const nameSpan = element.querySelector( + `.${PLAYER_NAME_SPAN}`, + ) as HTMLSpanElement; + const troopsDiv = element.querySelector( + `.${PLAYER_TROOPS}`, + ) as HTMLDivElement; + const flagImg = element.querySelector( + `.${PLAYER_FLAG}`, + ) as HTMLImageElement; + const iconsDiv = element.querySelector( + `.${PLAYER_ICONS}`, + ) as HTMLDivElement; + + const font = this.theme.font(); + nameDiv.style.fontFamily = font; + + const flag = player.cosmetics.flag; + if (flag) { + flagImg.src = assetUrl(flag); + flagImg.style.display = "block"; + } + + const renderInfo = new RenderInfo( + player, + 0, + null, + 0, + "", + element, + nameDiv, + nameSpan, + troopsDiv, + flagImg, + iconsDiv, + ); + + this.container.appendChild(element); + return renderInfo; + } + + renderPlayerInfo(render: RenderInfo, transitiveTargets: PlayerView[]) { if (!render.player.nameLocation()) { return; } @@ -296,22 +342,27 @@ export class NameLayer implements Layer { } render.lastRenderCalc = now + this.rand.nextInt(0, 100); - // Update text sizes + // Update text sizes, content and color render.fontSize = Math.max(4, Math.floor(baseSize * 0.4)); - render.fontColor = this.theme.textColor(render.player); - render.nameDiv.style.fontSize = `${render.fontSize}px`; render.nameDiv.style.lineHeight = `${render.fontSize}px`; - render.nameDiv.style.color = render.fontColor; - if (render.flagDiv) { - render.flagDiv.style.height = `${render.fontSize}px`; - } + render.flagImg.style.height = `${render.fontSize}px`; render.troopsDiv.style.fontSize = `${render.fontSize}px`; - render.troopsDiv.style.color = render.fontColor; + + render.nameSpan.textContent = render.player.displayName(); render.troopsDiv.textContent = renderTroops(render.player.troops()); + const fontColor = this.theme.textColor(render.player); + if (render.fontColor !== fontColor) { + render.fontColor = fontColor; + render.nameDiv.style.color = fontColor; + render.troopsDiv.style.color = fontColor; + } + // Handle icons const iconSize = Math.min(render.fontSize * 1.5, 48); + const darkMode = this.userSettings.darkMode(); + const darkModeStr = darkMode.toString(); // Compute which icons should be shown for this player using shared logic const icons = getPlayerIcons({ @@ -319,6 +370,9 @@ export class NameLayer implements Layer { player: render.player, includeAllianceIcon: true, firstPlace: this.firstPlace, + darkMode: darkMode, + alliancesDisabled: this.alliancesDisabled, + transitiveTargets: transitiveTargets, }); // Build a set of desired icon IDs @@ -327,163 +381,172 @@ export class NameLayer implements Layer { // 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); + if (id === ALLIANCE_ICON_ID) { + render.allianceIconRefs?.wrapper.remove(); + render.allianceIconRefs = null; + render.icons.delete(ALLIANCE_ICON_ID); + } else { + element.remove(); + render.icons.delete(id); + } } } // 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 (icon.kind === EMOJI_ICON_KIND && icon.text) { + this.handleEmojiIcon(render, icon, iconSize); + continue; + } else if (!(icon.kind === IMAGE_ICON_KIND && icon.src)) { + continue; + } + // Special handling for alliance icon with progress indicator + if (icon.id === ALLIANCE_ICON_ID) { + this.handleAllianceIcons(render, iconSize, darkModeStr); + continue; // Skip regular image handling + } - if (!emojiDiv) { - emojiDiv = document.createElement("div"); - emojiDiv.style.position = "absolute"; - emojiDiv.style.top = "50%"; - emojiDiv.style.transform = "translateY(-50%)"; - render.iconsDiv.appendChild(emojiDiv); - render.icons.set(icon.id, emojiDiv); - } + const imgElement = this.handleOtherIcons( + render, + icon, + iconSize, + darkModeStr, + ); - emojiDiv.textContent = icon.text; - emojiDiv.style.fontSize = `${iconSize}px`; - } else if (icon.kind === "image" && icon.src) { - // Special handling for alliance icon with progress indicator - if (icon.id === "alliance") { - let allianceWrapper = render.icons.get(icon.id) as - | HTMLDivElement - | undefined; - - const myPlayer = this.game.myPlayer(); - const allianceView = myPlayer - ?.alliances() - .find((a) => a.other === render.player.id()); - - let fraction = 0; - let hasExtensionRequest = false; - if (allianceView) { - const remaining = Math.max( - 0, - allianceView.expiresAt - this.game.ticks(), - ); - const duration = Math.max(1, this.game.config().allianceDuration()); - fraction = Math.max(0, Math.min(1, remaining / duration)); - hasExtensionRequest = allianceView.hasExtensionRequest; - } - - if (!allianceWrapper) { - allianceWrapper = createAllianceProgressIcon( - iconSize, - fraction, - hasExtensionRequest, - this.userSettings.darkMode(), - ); - render.iconsDiv.appendChild(allianceWrapper); - render.icons.set(icon.id, allianceWrapper); - } else { - // Update existing alliance icon - allianceWrapper.style.width = `${iconSize}px`; - allianceWrapper.style.height = `${iconSize}px`; - allianceWrapper.style.flexShrink = "0"; - - const overlay = allianceWrapper.querySelector( - ".alliance-progress-overlay", - ) as HTMLDivElement | null; - if (overlay) { - overlay.style.clipPath = computeAllianceClipPath(fraction); - } - - const questionMark = allianceWrapper.querySelector( - ".alliance-question-mark", - ) as HTMLImageElement | null; - if (questionMark) { - questionMark.style.display = hasExtensionRequest - ? "block" - : "none"; - } - - // Update inner image sizes - const imgs = allianceWrapper.getElementsByTagName("img"); - for (const img of imgs) { - img.style.width = `${iconSize}px`; - img.style.height = `${iconSize}px`; - } - } - continue; // Skip regular image handling - } - - let imgElement = render.icons.get(icon.id) as - | HTMLImageElement - | undefined; - - if (!imgElement) { - imgElement = this.createIconElement(icon.src, iconSize, icon.center); - render.iconsDiv.appendChild(imgElement); - render.icons.set(icon.id, imgElement); - } - - // Update src if it changed (e.g., nuke red/white or dark-mode icons) - if (imgElement.src !== icon.src) { - imgElement.src = icon.src; - } - - 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"; - } - } + // Traitor flashing - smooth speed increase starting at 15s + if (icon.id === TRAITOR_ICON_ID) { + this.handleTraitorIconFlashing(render.player, imgElement); } } // Position element with scale - // Even when positionChanged is false: Scale update otherwise sometimes only happens after seconds which looks buggy. + // Don't require nameLocation to be changed: 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. + // and the 500ms renderRefreshRate in here. const scale = Math.min(baseSize * 0.25, 3); - render.element.style.transform = `translate(${newX}px, ${newY}px) translate(-50%, -50%) scale(${scale})`; + const transform = `translate(${newX}px, ${newY}px) translate(-50%, -50%) scale(${scale})`; + if (render.lastTransform !== transform) { + render.element.style.transform = transform; + render.lastTransform = transform; + } } - private createIconElement( - src: string, + private handleEmojiIcon( + render: RenderInfo, + icon: PlayerIconDescriptor, size: number, - center: boolean = false, - ): HTMLImageElement { - const icon = document.createElement("img"); - icon.src = src; - icon.style.width = `${size}px`; - icon.style.height = `${size}px`; - icon.setAttribute("dark-mode", this.userSettings.darkMode().toString()); - if (center) { - icon.style.position = "absolute"; - icon.style.top = "50%"; - icon.style.transform = "translateY(-50%)"; + ) { + let emojiDiv = render.icons.get(icon.id) as HTMLDivElement | undefined; + + if (!emojiDiv) { + emojiDiv = this.emojiTemplate.cloneNode(true) as HTMLDivElement; + render.iconsDiv.appendChild(emojiDiv); + render.icons.set(icon.id, emojiDiv); + } + + emojiDiv.textContent = icon.text ?? ""; + emojiDiv.style.fontSize = `${size}px`; + } + + private handleAllianceIcons( + render: RenderInfo, + size: number, + darkMode: string, + ) { + this.myPlayer ??= this.game.myPlayer(); + const allianceView = this.myPlayer + ?.alliances() + .find((a) => a.other === render.player.id()); + + let fraction = 0; + let hasExtensionRequest = false; + if (allianceView) { + const remaining = Math.max(0, allianceView.expiresAt - this.game.ticks()); + fraction = Math.max(0, Math.min(1, remaining / this.allianceDuration)); + hasExtensionRequest = allianceView.hasExtensionRequest; + } + + if (!render.allianceIconRefs) { + render.allianceIconRefs = createAllianceProgressIconRefs( + size, + fraction, + hasExtensionRequest, + darkMode, + ); + + render.iconsDiv.appendChild(render.allianceIconRefs.wrapper); + render.icons.set(ALLIANCE_ICON_ID, render.allianceIconRefs.wrapper); + } else { + updateAllianceProgressIconRefs( + render.allianceIconRefs, + size, + fraction, + hasExtensionRequest, + darkMode, + ); + } + return; + } + + private handleOtherIcons( + render: RenderInfo, + icon: PlayerIconDescriptor, + size: number, + darkMode: string, + ): HTMLImageElement { + let imgElement = render.icons.get(icon.id) as HTMLImageElement | undefined; + + if (!imgElement) { + imgElement = icon.center + ? (this.iconCenterTemplate.cloneNode(true) as HTMLImageElement) + : (this.iconTemplate.cloneNode(true) as HTMLImageElement); + + imgElement.src = icon.src ?? ""; + imgElement.style.width = `${size}px`; + imgElement.style.height = `${size}px`; + imgElement.setAttribute("dark-mode", darkMode); + render.iconsDiv.appendChild(imgElement); + render.icons.set(icon.id, imgElement); + } else { + // Update src if it changed (e.g., nuke red/white or dark-mode icons) + if (imgElement.src !== icon.src) { + imgElement.src = icon.src ?? ""; + } + + imgElement.style.width = `${size}px`; + imgElement.style.height = `${size}px`; + imgElement.setAttribute("dark-mode", darkMode); + } + return imgElement; + } + + private handleTraitorIconFlashing( + player: PlayerView, + icon: HTMLImageElement, + ) { + const remainingTicks = 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`; + + icon.style.animation = `traitorFlash ${animationDuration} infinite`; + icon.style.animationTimingFunction = "ease-in-out"; + } else { + // Don't flash if more than 15 seconds remaining + icon.style.animation = "none"; } - return icon; } } diff --git a/src/client/graphics/layers/PlayerInfoOverlay.ts b/src/client/graphics/layers/PlayerInfoOverlay.ts index 46db9d883..af7165e2a 100644 --- a/src/client/graphics/layers/PlayerInfoOverlay.ts +++ b/src/client/graphics/layers/PlayerInfoOverlay.ts @@ -1,4 +1,4 @@ -import { LitElement, TemplateResult, html } from "lit"; +import { html, LitElement, TemplateResult } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { assetUrl } from "../../../core/AssetUrls"; import { EventBus } from "../../../core/EventBus"; @@ -24,7 +24,12 @@ import { renderTroops, translateText, } from "../../Utils"; -import { getFirstPlacePlayer, getPlayerIcons } from "../PlayerIcons"; +import { + EMOJI_ICON_KIND, + getFirstPlacePlayer, + getPlayerIcons, + IMAGE_ICON_KIND, +} from "../PlayerIcons"; import { TransformHandler } from "../TransformHandler"; import { ImmunityBarVisibleEvent } from "./ImmunityTimer"; import { Layer } from "./Layer"; @@ -258,6 +263,7 @@ export class PlayerInfoOverlay extends LitElement implements Layer { // 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, + alliancesDisabled: this.game.config().disableAlliances(), }); if (icons.length === 0) { @@ -266,11 +272,11 @@ export class PlayerInfoOverlay extends LitElement implements Layer { return html` ${icons.map((icon) => - icon.kind === "emoji" && icon.text + icon.kind === EMOJI_ICON_KIND && icon.text ? html`${icon.text}` - : icon.kind === "image" && icon.src + : icon.kind === IMAGE_ICON_KIND && icon.src ? html`` : html``, )} diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index c4c8a1ced..1bd4dd61c 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -575,7 +575,33 @@ export class PlayerView { } transitiveTargets(): PlayerView[] { - return [...this.targets(), ...this.allies().flatMap((p) => p.targets())]; + const result: PlayerView[] = []; + + // Add own targets + for (const id of this.data.targets) { + result.push(this.game.playerBySmallID(id) as PlayerView); + } + + // Add allies' targets + for (const allyID of this.data.allies) { + const ally = this.game.playerBySmallID(allyID) as PlayerView; + for (const targetId of ally.data.targets) { + result.push(this.game.playerBySmallID(targetId) as PlayerView); + } + } + + // Add teammates' targets + if (this.data.team !== undefined) { + for (const p of this.game.playerViews()) { + if (p !== this && p.data.team === this.data.team) { + for (const targetId of p.data.targets) { + result.push(this.game.playerBySmallID(targetId) as PlayerView); + } + } + } + } + + return result; } isTraitor(): boolean {