import { EventBus } from "../../../core/EventBus"; import { Theme } from "../../../core/configuration/Config"; import { AllPlayers, Cell, nukeTypes, PlayerID } from "../../../core/game/Game"; import { GameView, PlayerView } from "../../../core/game/GameView"; import { AlternateViewEvent } from "../../InputHandler"; import { renderTroops } from "../../Utils"; import { TransformHandler } from "../TransformHandler"; import { Layer } from "./Layer"; 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"; type CachedImage = { img: HTMLImageElement; src: string; }; 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 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 isVisible: boolean = true; private readonly sharedStateRefreshMs = 200; private readonly playerCacheRefreshMs = 200; private readonly customFlagRefreshMs = 120; constructor( private game: GameView, private transformHandler: TransformHandler, private eventBus: EventBus, ) {} shouldTransform(): boolean { return false; } redraw() { this.theme = this.game.config().theme(); this.sharedState = null; } public init() { this.eventBus.on(AlternateViewEvent, (e) => this.onAlternateViewChange(e)); } private onAlternateViewChange(event: AlternateViewEvent) { this.isVisible = !event.alternateView; } public renderLayer(mainContex: CanvasRenderingContext2D) { if (!this.isVisible) { return; } 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; } } 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()); if (myPlayer) { const detonationDst = unit.targetTile(); if (detonationDst) { const targetId = this.game.owner(detonationDst).id(); if (targetId === myPlayer.id()) { nukesTargetingMe.add(owner.id()); } } } } const transitiveTargets = myPlayer !== null ? new Set(myPlayer.transitiveTargets()) : null; this.sharedState = { firstPlaceId: firstPlace?.id() ?? null, transitiveTargets, nukingPlayers, nukesTargetingMe, isDarkMode, emojisEnabled, }; return this.sharedState; } 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; } this.drawImage(ctx, `/flags/${flag}.svg`, x, y, w, h); } 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}`; const existing = this.customFlagCache.get(key); if (existing) return existing; const layers = this.parseCustomFlag(flag); if (layers === null || layers.length === 0) return null; let isAnimated = false; for (const layer of layers) { if (this.isSpecialFlagColor(layer.colorKey)) { isAnimated = true; break; } } 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") { 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; } 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 drawAllianceProgressIcon( ctx: CanvasRenderingContext2D, x: number, y: number, size: number, 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); } } 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; } }