diff --git a/src/client/graphics/layers/NameLayer.ts b/src/client/graphics/layers/NameLayer.ts index e23d4d609..6ba91e781 100644 --- a/src/client/graphics/layers/NameLayer.ts +++ b/src/client/graphics/layers/NameLayer.ts @@ -1,66 +1,83 @@ -import { renderPlayerFlag } from "../../../core/CustomFlag"; import { EventBus } from "../../../core/EventBus"; -import { PseudoRandom } from "../../../core/PseudoRandom"; import { Theme } from "../../../core/configuration/Config"; -import { Cell } from "../../../core/game/Game"; +import { AllPlayers, Cell, nukeTypes, PlayerID } 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 { - computeAllianceClipPath, - createAllianceProgressIcon, - getFirstPlacePlayer, - getPlayerIcons, - PlayerIconId, -} from "../PlayerIcons"; +import { renderTroops } from "../../Utils"; import { TransformHandler } from "../TransformHandler"; import { Layer } from "./Layer"; -import shieldIcon from "/images/ShieldIconBlack.svg?url"; +import allianceIcon from "/images/AllianceIcon.svg?url"; +import allianceIconFaded from "/images/AllianceIconFaded.svg?url"; +import allianceRequestBlackIcon from "/images/AllianceRequestBlackIcon.svg?url"; +import allianceRequestWhiteIcon from "/images/AllianceRequestWhiteIcon.svg?url"; +import crownIcon from "/images/CrownIcon.svg?url"; +import disconnectedIcon from "/images/DisconnectedIcon.svg?url"; +import embargoBlackIcon from "/images/EmbargoBlackIcon.svg?url"; +import embargoWhiteIcon from "/images/EmbargoWhiteIcon.svg?url"; +import nukeRedIcon from "/images/NukeIconRed.svg?url"; +import nukeWhiteIcon from "/images/NukeIconWhite.svg?url"; +import questionMarkIcon from "/images/QuestionMarkIcon.svg?url"; +import targetIcon from "/images/TargetIcon.svg?url"; +import traitorIcon from "/images/TraitorIcon.svg?url"; -class RenderInfo { - public icons: Map = new Map(); // Track icon elements +type CachedImage = { + img: HTMLImageElement; + src: string; +}; - constructor( - public player: PlayerView, - public lastRenderCalc: number, - public location: Cell | null, - public fontSize: number, - public fontColor: string, - public element: HTMLElement, - ) {} -} +type CustomFlagLayer = { + maskSrc: string; + colorKey: string; +}; + +type CustomFlagRenderCache = { + w: number; + h: number; + canvas: HTMLCanvasElement; + ctx: CanvasRenderingContext2D; + scratch: HTMLCanvasElement; + scratchCtx: CanvasRenderingContext2D; + layers: CustomFlagLayer[]; + isAnimated: boolean; + lastRenderedAtMs: number; +}; + +type PlayerIconsSharedState = { + firstPlaceId: PlayerID | null; + transitiveTargets: ReadonlySet | null; + nukingPlayers: ReadonlySet; + nukesTargetingMe: ReadonlySet; + isDarkMode: boolean; + emojisEnabled: boolean; +}; + +type PlayerRenderCache = { + lastUpdatedAtMs: number; + lastFont: string; + lastName: string; + lastTroops: bigint | number; + troopsText: string; + nameTextWidth: number; + troopsTextWidth: number; +}; 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 lastSharedStateUpdatedAtMs = 0; + private sharedState: PlayerIconsSharedState | null = null; + private imageCache = new Map(); + private customFlagCache = new Map(); + private playerCache = new Map(); private theme: Theme = this.game.config().theme(); - private userSettings: UserSettings = new UserSettings(); private isVisible: boolean = true; - private firstPlace: PlayerView | null = null; + private readonly sharedStateRefreshMs = 200; + private readonly playerCacheRefreshMs = 200; + private readonly customFlagRefreshMs = 120; 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; @@ -68,475 +85,694 @@ export class NameLayer implements Layer { redraw() { this.theme = this.game.config().theme(); + this.sharedState = null; } 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%"; - this.container.style.top = "50%"; - this.container.style.pointerEvents = "none"; - this.container.style.zIndex = "2"; - document.body.appendChild(this.container); - - // Add CSS keyframes for traitor icon flashing animation - // Append to container instead of document.head to keep styles scoped to this component - const style = document.createElement("style"); - style.textContent = ` - @keyframes traitorFlash { - 0%, 100% { - opacity: 1; - } - 50% { - opacity: 0.3; - } - } - `; - this.container.appendChild(style); - this.eventBus.on(AlternateViewEvent, (e) => this.onAlternateViewChange(e)); } private onAlternateViewChange(event: AlternateViewEvent) { this.isVisible = !event.alternateView; - // Update visibility of all name elements immediately - for (const render of this.renders) { - this.updateElementVisibility(render); - } - } - - private updateElementVisibility(render: RenderInfo) { - if (!render.player.nameLocation() || !render.player.isAlive()) { - return; - } - - const 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) - : false; - const maxZoomScale = 17; - - if ( - !this.isVisible || - size < 7 || - (this.transformHandler.scale > maxZoomScale && size > 100) || - !isOnScreen - ) { - render.element.style.display = "none"; - } else { - render.element.style.display = "flex"; - } - } - - getTickIntervalMs() { - return 1000; - } - - public tick() { - // Precompute the first-place player for performance - this.firstPlace = getFirstPlacePlayer(this.game); - - for (const player of this.game.playerViews()) { - if (player.isAlive()) { - if (!this.seenPlayers.has(player)) { - this.seenPlayers.add(player); - this.renders.push( - new RenderInfo( - player, - 0, - null, - 0, - "", - this.createPlayerElement(player), - ), - ); - } - } - } } public renderLayer(mainContex: CanvasRenderingContext2D) { - const screenPosOld = this.transformHandler.worldToScreenCoordinates( - new Cell(0, 0), - ); - const screenPos = new Cell( - 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})`; + if (!this.isVisible) { + return; + } - const now = Date.now(); - if (now > this.lastChecked + this.renderCheckRate) { - this.lastChecked = now; - for (const render of this.renders) { - this.renderPlayerInfo(render); + const nowMs = performance.now(); + const sharedState = this.getSharedState(nowMs); + this.renderPlayers(mainContex, sharedState, nowMs); + } + + private getSharedState(nowMs: number): PlayerIconsSharedState { + if ( + this.sharedState !== null && + nowMs - this.lastSharedStateUpdatedAtMs < this.sharedStateRefreshMs + ) { + return this.sharedState; + } + + this.lastSharedStateUpdatedAtMs = nowMs; + + const myPlayer = this.game.myPlayer(); + const userSettings = this.game.config().userSettings(); + const isDarkMode = userSettings?.darkMode() ?? false; + const emojisEnabled = userSettings?.emojis() ?? false; + + let firstPlace: PlayerView | null = null; + let firstTiles = -Infinity; + for (const player of this.game.playerViews()) { + if (!player.isAlive()) continue; + const tiles = player.numTilesOwned(); + if (tiles > firstTiles) { + firstTiles = tiles; + firstPlace = player; } } - mainContex.drawImage( - this.canvas, - 0, - 0, - mainContex.canvas.width, - mainContex.canvas.height, - ); - } + const nukingPlayers = new Set(); + const nukesTargetingMe = new Set(); + for (const unit of this.game.units(...nukeTypes)) { + if (!unit.isActive()) continue; + const owner = unit.owner(); + if (myPlayer && owner.id() === myPlayer.id()) continue; + nukingPlayers.add(owner.id()); - private createPlayerElement(player: PlayerView): 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"; + if (myPlayer) { + const detonationDst = unit.targetTile(); + if (detonationDst) { + const targetId = this.game.owner(detonationDst).id(); + if (targetId === myPlayer.id()) { + nukesTargetingMe.add(owner.id()); + } + } + } + } - const iconsDiv = document.createElement("div"); - iconsDiv.classList.add("player-icons"); - iconsDiv.style.display = "flex"; - iconsDiv.style.gap = "4px"; - iconsDiv.style.justifyContent = "center"; - iconsDiv.style.alignItems = "center"; - iconsDiv.style.zIndex = "2"; - iconsDiv.style.opacity = "0.8"; - element.appendChild(iconsDiv); + const transitiveTargets = + myPlayer !== null ? new Set(myPlayer.transitiveTargets()) : null; - 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.aspectRatio = "3/4"; + this.sharedState = { + firstPlaceId: firstPlace?.id() ?? null, + transitiveTargets, + nukingPlayers, + nukesTargetingMe, + isDarkMode, + emojisEnabled, }; - if (player.cosmetics.flag) { - const flag = player.cosmetics.flag; - if (flag !== undefined && flag !== null && flag.startsWith("!")) { - const flagWrapper = document.createElement("div"); - applyFlagStyles(flagWrapper); - renderPlayerFlag(flag, flagWrapper); - nameDiv.appendChild(flagWrapper); - } else if (flag !== undefined && flag !== null) { - const flagImg = document.createElement("img"); - applyFlagStyles(flagImg); - flagImg.src = "/flags/" + flag + ".svg"; - 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"; - nameDiv.style.display = "flex"; - nameDiv.style.justifyContent = "flex-end"; - nameDiv.style.alignItems = "center"; - - const nameSpan = document.createElement("span"); - nameSpan.className = "player-name-span"; - nameSpan.innerHTML = player.name(); - 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); - - // 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"; - - this.container.appendChild(element); - return element; + return this.sharedState; } - renderPlayerInfo(render: RenderInfo) { - if (!render.player.nameLocation() || !render.player.isAlive()) { - this.renders = this.renders.filter((r) => r !== render); - render.element.remove(); + private renderPlayers( + ctx: CanvasRenderingContext2D, + sharedState: PlayerIconsSharedState, + nowMs: number, + ): void { + const fontFamily = this.theme.font(); + const scale = this.transformHandler.scale; + + for (const player of this.game.playerViews()) { + if (!player.isAlive()) { + this.playerCache.delete(player.id()); + continue; + } + + const nameLocation = player.nameLocation(); + if (!nameLocation) { + this.playerCache.delete(player.id()); + continue; + } + + const baseSize = Math.max(1, Math.floor(nameLocation.size)); + const size = scale * baseSize; + const maxZoomScale = 17; + if (size < 7 || (scale > maxZoomScale && size > 100)) { + continue; + } + + const worldCell = new Cell(nameLocation.x, nameLocation.y); + if (!this.transformHandler.isOnScreen(worldCell)) { + continue; + } + + const screenPos = + this.transformHandler.worldToScreenCoordinates(worldCell); + const x = Math.round(screenPos.x); + const y = Math.round(screenPos.y); + + const elementScale = Math.min(baseSize * 0.25, 3); + const visualScale = scale * elementScale; + + const fontBase = Math.max(4, Math.floor(baseSize * 0.4)); + const fontPx = Math.max(4, Math.round(fontBase * visualScale)); + + const iconBasePx = Math.min(fontBase * 1.5, 48); + const iconPx = Math.max(8, Math.round(iconBasePx * visualScale)); + + ctx.save(); + ctx.font = `${fontPx}px ${fontFamily}`; + ctx.fillStyle = this.theme.textColor(player); + ctx.textBaseline = "middle"; + ctx.textAlign = "left"; + + const cache = this.getPlayerCache(player, ctx, nowMs); + + const iconsY = Math.round(y - fontPx * 1.1 - iconPx * 0.6); + this.renderPlayerIcons( + ctx, + player, + sharedState, + x, + iconsY, + iconPx, + fontFamily, + ); + + const flag = player.cosmetics.flag ?? null; + const hasFlag = flag !== null && flag !== ""; + const flagW = hasFlag ? Math.round((fontPx * 3) / 4) : 0; + const flagH = hasFlag ? fontPx : 0; + const gapPx = hasFlag ? Math.max(2, Math.round(fontPx * 0.18)) : 0; + + const totalNameW = flagW + gapPx + cache.nameTextWidth; + const nameLeftX = x - totalNameW / 2; + + if (hasFlag) { + this.drawPlayerFlag( + ctx, + flag, + nameLeftX, + y - flagH / 2, + flagW, + flagH, + nowMs, + ); + } + + ctx.fillText(cache.lastName, nameLeftX + flagW + gapPx, y); + + ctx.textAlign = "center"; + ctx.fillText(cache.troopsText, x, Math.round(y + fontPx * 1.05)); + + if (sharedState.transitiveTargets?.has(player) ?? false) { + const targetSize = Math.round(iconPx * 1.1); + this.drawImage( + ctx, + targetIcon, + x - targetSize / 2, + y - targetSize / 2, + targetSize, + targetSize, + ); + } + + ctx.restore(); + } + } + + private getPlayerCache( + player: PlayerView, + ctx: CanvasRenderingContext2D, + nowMs: number, + ): PlayerRenderCache { + const id = player.id(); + const name = player.name(); + const troops = player.troops(); + const font = ctx.font; + + const existing = this.playerCache.get(id); + if ( + existing && + nowMs - existing.lastUpdatedAtMs < this.playerCacheRefreshMs && + existing.lastFont === font && + existing.lastName === name && + existing.lastTroops === troops + ) { + return existing; + } + + const troopsText = renderTroops(troops); + const next: PlayerRenderCache = { + lastUpdatedAtMs: nowMs, + lastFont: font, + lastName: name, + lastTroops: troops, + troopsText, + nameTextWidth: ctx.measureText(name).width, + troopsTextWidth: ctx.measureText(troopsText).width, + }; + this.playerCache.set(id, next); + return next; + } + + private drawPlayerFlag( + ctx: CanvasRenderingContext2D, + flag: string, + x: number, + y: number, + w: number, + h: number, + nowMs: number, + ): void { + if (flag.startsWith("!")) { + const custom = this.getCustomFlagCanvas(flag, w, h); + if (!custom) return; + this.renderCustomFlag(custom, nowMs); + ctx.drawImage(custom.canvas, x, y, w, h); return; } - const oldLocation = render.location; - render.location = new Cell( - render.player.nameLocation().x, - render.player.nameLocation().y, - ); + this.drawImage(ctx, `/flags/${flag}.svg`, x, y, w, h); + } - // 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); + private getCustomFlagCanvas( + flag: string, + w: number, + h: number, + ): CustomFlagRenderCache | null { + const bucketW = Math.max(2, Math.round(w / 4) * 4); + const bucketH = Math.max(2, Math.round(h / 4) * 4); + const key = `${flag}@${bucketW}x${bucketH}`; - // Update element visibility (handles Ctrl key, size, and screen position) - this.updateElementVisibility(render); + const existing = this.customFlagCache.get(key); + if (existing) return existing; - // If element is hidden, don't continue with rendering - if (render.element.style.display === "none") { - return; - } + const layers = this.parseCustomFlag(flag); + if (layers === null || layers.length === 0) return null; - // Throttle 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; - nameDiv.style.fontSize = `${render.fontSize}px`; - nameDiv.style.lineHeight = `${render.fontSize}px`; - nameDiv.style.color = render.fontColor; - const span = nameDiv.querySelector(".player-name-span"); - if (span) { - span.innerHTML = render.player.name(); - } - if (flagDiv) { - flagDiv.style.height = `${render.fontSize}px`; - } - troopsDiv.style.fontSize = `${render.fontSize}px`; - 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`; - } - if (shieldNumber) { - shieldNumber.style.fontSize = `${render.fontSize * 0.6}px`; - shieldNumber.style.marginTop = `${-render.fontSize * 0.1}px`; - shieldNumber.textContent = density; - } - - // 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 - 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); + let isAnimated = false; + for (const layer of layers) { + if (this.isSpecialFlagColor(layer.colorKey)) { + isAnimated = true; + break; } } - // Add or update icons that should be shown + const canvas = document.createElement("canvas"); + canvas.width = bucketW; + canvas.height = bucketH; + const canvasCtx = canvas.getContext("2d"); + if (!canvasCtx) return null; + + const scratch = document.createElement("canvas"); + scratch.width = bucketW; + scratch.height = bucketH; + const scratchCtx = scratch.getContext("2d"); + if (!scratchCtx) return null; + + const next: CustomFlagRenderCache = { + w: bucketW, + h: bucketH, + canvas, + ctx: canvasCtx, + scratch, + scratchCtx, + layers, + isAnimated, + lastRenderedAtMs: -Infinity, + }; + this.customFlagCache.set(key, next); + return next; + } + + private parseCustomFlag(flag: string): CustomFlagLayer[] | null { + if (!flag.startsWith("!")) return null; + const code = flag.slice(1); + if (code.length === 0) return null; + + const layers: CustomFlagLayer[] = []; + for (const segment of code.split("_")) { + const [layerKey, colorKey] = segment.split("-"); + if (!layerKey || !colorKey) continue; + if (!/^[a-zA-Z0-9_-]+$/.test(layerKey)) continue; + if (!/^[a-zA-Z0-9#_-]+$/.test(colorKey)) continue; + layers.push({ + maskSrc: `/flags/custom/${layerKey}.svg`, + colorKey, + }); + } + return layers.length > 0 ? layers : null; + } + + private renderCustomFlag(cache: CustomFlagRenderCache, nowMs: number): void { + if (!cache.isAnimated && cache.lastRenderedAtMs !== -Infinity) return; + if ( + cache.isAnimated && + nowMs - cache.lastRenderedAtMs < this.customFlagRefreshMs + ) { + return; + } + + for (const layer of cache.layers) { + const mask = this.getImage(layer.maskSrc); + if (!mask.complete || mask.naturalWidth === 0) { + return; + } + } + + cache.lastRenderedAtMs = nowMs; + cache.ctx.clearRect(0, 0, cache.w, cache.h); + + for (const layer of cache.layers) { + const mask = this.getImage(layer.maskSrc); + cache.scratchCtx.clearRect(0, 0, cache.w, cache.h); + cache.scratchCtx.globalCompositeOperation = "source-over"; + cache.scratchCtx.drawImage(mask, 0, 0, cache.w, cache.h); + cache.scratchCtx.globalCompositeOperation = "source-in"; + + cache.scratchCtx.fillStyle = this.resolveFlagColor(layer.colorKey, nowMs); + cache.scratchCtx.fillRect(0, 0, cache.w, cache.h); + cache.scratchCtx.globalCompositeOperation = "source-over"; + + cache.ctx.drawImage(cache.scratch, 0, 0); + } + } + + private isSpecialFlagColor(colorKey: string): boolean { + if (colorKey.startsWith("#")) return false; + return !/^([0-9a-fA-F]{6}|[0-9a-fA-F]{3})$/.test(colorKey); + } + + private resolveFlagColor(colorKey: string, nowMs: number): string { + if (!this.isSpecialFlagColor(colorKey)) { + if (colorKey.startsWith("#")) return colorKey; + if (/^([0-9a-fA-F]{6}|[0-9a-fA-F]{3})$/.test(colorKey)) { + return `#${colorKey}`; + } + return colorKey; + } + + switch (colorKey) { + case "rainbow": + return this.sampleKeyframedColor(nowMs, 7000, [ + [0, "#990033"], + [0.16, "#996600"], + [0.32, "#336600"], + [0.48, "#008080"], + [0.64, "#1c3f99"], + [0.8, "#5e0099"], + [1, "#990033"], + ]); + case "bright-rainbow": + return this.sampleKeyframedColor(nowMs, 7000, [ + [0, "#ff0000"], + [0.16, "#ffa500"], + [0.32, "#ffff00"], + [0.48, "#00ff00"], + [0.64, "#00ffff"], + [0.8, "#0000ff"], + [1, "#ff0000"], + ]); + case "copper-glow": + return this.sampleKeyframedColor(nowMs, 3000, [ + [0, "#b87333"], + [0.5, "#cd7f32"], + [1, "#b87333"], + ]); + case "silver-glow": + return this.sampleKeyframedColor(nowMs, 3000, [ + [0, "#c0c0c0"], + [0.5, "#e0e0e0"], + [1, "#c0c0c0"], + ]); + case "gold-glow": + return this.sampleKeyframedColor(nowMs, 3000, [ + [0, "#ffd700"], + [0.5, "#fff8dc"], + [1, "#ffd700"], + ]); + case "neon": + return this.sampleKeyframedColor(nowMs, 3000, [ + [0, "#39ff14"], + [0.25, "#2aff60"], + [0.5, "#00ff88"], + [0.75, "#2aff60"], + [1, "#39ff14"], + ]); + case "water": + return this.sampleKeyframedColor(nowMs, 6200, [ + [0, "#00bfff"], + [0.12, "#1e90ff"], + [0.27, "#87cefa"], + [0.45, "#4682b4"], + [0.63, "#87cefa"], + [0.8, "#1e90ff"], + [1, "#00bfff"], + ]); + case "lava": + return this.sampleKeyframedColor(nowMs, 6000, [ + [0, "#ff4500"], + [0.2, "#ff6347"], + [0.4, "#ff8c00"], + [0.6, "#ff4500"], + [0.8, "#ff0000"], + [1, "#ff4500"], + ]); + default: + return "#ffffff"; + } + } + + private sampleKeyframedColor( + nowMs: number, + durationMs: number, + stops: Array<[t: number, hex: string]>, + ): string { + const t = ((nowMs % durationMs) / durationMs) % 1; + let a = stops[0]; + let b = stops[stops.length - 1]; + + for (let i = 0; i < stops.length - 1; i++) { + const s0 = stops[i]; + const s1 = stops[i + 1]; + if (t >= s0[0] && t <= s1[0]) { + a = s0; + b = s1; + break; + } + } + + const span = Math.max(1e-6, b[0] - a[0]); + const u = Math.max(0, Math.min(1, (t - a[0]) / span)); + return this.lerpHex(a[1], b[1], u); + } + + private lerpHex(a: string, b: string, t: number): string { + const ar = this.hexToRgb(a); + const br = this.hexToRgb(b); + const r = Math.round(ar.r + (br.r - ar.r) * t); + const g = Math.round(ar.g + (br.g - ar.g) * t); + const bl = Math.round(ar.b + (br.b - ar.b) * t); + return `rgb(${r}, ${g}, ${bl})`; + } + + private hexToRgb(hex: string): { r: number; g: number; b: number } { + const h = hex.replace("#", ""); + if (h.length === 3) { + const r = parseInt(h[0] + h[0], 16); + const g = parseInt(h[1] + h[1], 16); + const b = parseInt(h[2] + h[2], 16); + return { r, g, b }; + } + const r = parseInt(h.slice(0, 2), 16); + const g = parseInt(h.slice(2, 4), 16); + const b = parseInt(h.slice(4, 6), 16); + return { r, g, b }; + } + + private renderPlayerIcons( + ctx: CanvasRenderingContext2D, + player: PlayerView, + shared: PlayerIconsSharedState, + centerX: number, + centerY: number, + iconPx: number, + fontFamily: string, + ): void { + const myPlayer = this.game.myPlayer(); + + const icons: Array< + | { kind: "image"; src: string; alpha?: number } + | { + kind: "alliance-progress"; + fraction: number; + hasExtensionRequest: boolean; + } + | { kind: "emoji"; text: string } + > = []; + + if (shared.firstPlaceId !== null && player.id() === shared.firstPlaceId) { + icons.push({ kind: "image", src: crownIcon }); + } + + if (player.isTraitor()) { + const remainingTicks = player.getTraitorRemainingTicks(); + const remainingSeconds = Math.round((remainingTicks / 10) * 2) / 2; + icons.push({ + kind: "image", + src: traitorIcon, + alpha: this.getTraitorIconAlpha(remainingSeconds), + }); + } + + if (player.isDisconnected()) { + icons.push({ kind: "image", src: disconnectedIcon }); + } + + if (myPlayer !== null && myPlayer.isAlliedWith(player)) { + const allianceView = myPlayer + .alliances() + .find((a) => a.other === 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; + } + + icons.push({ + kind: "alliance-progress", + fraction, + hasExtensionRequest, + }); + } + + if (myPlayer !== null && player.isRequestingAllianceWith(myPlayer)) { + icons.push({ + kind: "image", + src: shared.isDarkMode + ? allianceRequestWhiteIcon + : allianceRequestBlackIcon, + }); + } + + if (shared.emojisEnabled) { + const emojis = player + .outgoingEmojis() + .filter( + (emoji) => + emoji.recipientID === AllPlayers || + emoji.recipientID === myPlayer?.smallID(), + ); + if (emojis.length > 0) { + icons.push({ kind: "emoji", text: emojis[0].message }); + } + } + + if (myPlayer?.hasEmbargo(player)) { + icons.push({ + kind: "image", + src: shared.isDarkMode ? embargoWhiteIcon : embargoBlackIcon, + }); + } + + if (shared.nukingPlayers.has(player.id())) { + const isTargetingMe = shared.nukesTargetingMe.has(player.id()); + icons.push({ + kind: "image", + src: isTargetingMe ? nukeRedIcon : nukeWhiteIcon, + }); + } + + if (icons.length === 0) { + return; + } + + const gap = Math.max(2, Math.round(iconPx * 0.18)); + const totalW = icons.length * iconPx + (icons.length - 1) * gap; + let x = centerX - totalW / 2; + for (const icon of icons) { - if (icon.kind === "emoji" && icon.text) { - let emojiDiv = render.icons.get(icon.id) as HTMLDivElement | undefined; - - 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); - } - - 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(), - ); - 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); - 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"; - } - } + if (icon.kind === "emoji") { + ctx.save(); + ctx.font = `${iconPx}px ${fontFamily}`; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(icon.text, x + iconPx / 2, centerY); + ctx.restore(); + x += iconPx + gap; + continue; } - } - // 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})`; + if (icon.kind === "alliance-progress") { + this.drawAllianceProgressIcon( + ctx, + x, + centerY - iconPx / 2, + iconPx, + icon.fraction, + icon.hasExtensionRequest, + ); + x += iconPx + gap; + continue; + } + + if (icon.alpha !== undefined) { + ctx.save(); + ctx.globalAlpha *= icon.alpha; + } + + this.drawImage(ctx, icon.src, x, centerY - iconPx / 2, iconPx, iconPx); + + if (icon.alpha !== undefined) { + ctx.restore(); + } + + x += iconPx + gap; } } - private createIconElement( - src: string, + private drawAllianceProgressIcon( + ctx: CanvasRenderingContext2D, + x: number, + y: number, 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%)"; + fraction: number, + hasExtensionRequest: boolean, + ): void { + this.drawImage(ctx, allianceIconFaded, x, y, size, size); + + const topCutPct = 20 + (1 - fraction) * 80 * 0.78; + const topCutPx = (Math.max(0, Math.min(100, topCutPct)) / 100) * size; + + ctx.save(); + ctx.beginPath(); + ctx.rect(x, y + topCutPx, size, size - topCutPx); + ctx.clip(); + this.drawImage(ctx, allianceIcon, x, y, size, size); + ctx.restore(); + + if (hasExtensionRequest) { + this.drawImage(ctx, questionMarkIcon, x, y, size, size); } - return icon; + } + + private getTraitorIconAlpha(remainingSeconds: number): number { + if (remainingSeconds > 15) return 1; + + const clampedSeconds = Math.max(0, Math.min(15, remainingSeconds)); + const normalizedTime = clampedSeconds / 15; + const easedProgress = 1 - Math.pow(1 - normalizedTime, 3); + const maxDuration = 1.0; + const minDuration = 0.2; + const duration = minDuration + (maxDuration - minDuration) * easedProgress; + + const t = performance.now() / 1000; + const phase = (t % duration) / duration; + const triangle = phase < 0.5 ? phase * 2 : 2 - phase * 2; + return 0.3 + 0.7 * triangle; + } + + private drawImage( + ctx: CanvasRenderingContext2D, + src: string, + x: number, + y: number, + w: number, + h: number, + ): void { + const img = this.getImage(src); + if (!img.complete || img.naturalWidth === 0) return; + ctx.drawImage(img, x, y, w, h); + } + + private getImage(src: string): HTMLImageElement { + const cached = this.imageCache.get(src); + if (cached) return cached.img; + + const img = new Image(); + img.decoding = "async"; + img.src = src; + this.imageCache.set(src, { img, src }); + return img; } } diff --git a/src/core/CustomFlag.ts b/src/core/CustomFlag.ts index 3347e5e8f..e2892d7c3 100644 --- a/src/core/CustomFlag.ts +++ b/src/core/CustomFlag.ts @@ -1,8 +1,8 @@ import { Cosmetics } from "./CosmeticSchemas"; const ANIMATION_DURATIONS: Record = { - rainbow: 4000, - "bright-rainbow": 4000, + rainbow: 7000, + "bright-rainbow": 7000, "copper-glow": 3000, "silver-glow": 3000, "gold-glow": 3000, @@ -18,11 +18,6 @@ export function renderPlayerFlag( target: HTMLElement, cosmetics: Cosmetics | undefined = undefined, ) { - if (cosmetics === undefined) { - console.warn("No cosmetics provided for flag", flag); - return; - } - if (!flag.startsWith("!")) return; const code = flag.slice("!".length);