From 818477129caf1cd4696d19ff6c52d672c649f332 Mon Sep 17 00:00:00 2001
From: scamiv <6170744+scamiv@users.noreply.github.com>
Date: Fri, 6 Feb 2026 00:25:45 +0100
Subject: [PATCH] perf, rework(NameLayer): render player names + status icons
directly to main canvas
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Replace the DOM-based NameLayer overlay (per-player elements + icon
s)
with direct CanvasRenderingContext2D drawing in renderLayer(). This removes
layout/style churn and reduces per-frame overhead when many players are visible.
Key changes:
- Draw name, flag, troops, and status icons on the provided main canvas context
(no private canvas, no fixed-position container div).
- Add shared state caching (200ms): first-place player id, my transitive targets,
nuking players, nukes targeting me (for red nuke), dark-mode + emoji settings.
- Add per-player render caching for formatted troops + measured text widths,
and invalidate metrics on font changes to avoid zoom jitter.
- Re-implement icon rendering on-canvas:
- crown (first place)
- traitor icon with time-based alpha flashing ramping in the last 15s
- disconnected icon
- alliance progress icon via clip-fill (plus “?” overlay if extension requested)
- alliance request / embargo icons selecting black/white variants in dark mode
- nuke icon (red if targeting local player, else white)
- outgoing emoji (to AllPlayers / me) rendered as text
- target reticle overlay for transitive targets
- Add image caching for icons/flags and custom-flag mask SVGs to avoid recreating Image objects.
- Restore custom flag support for NameLayer: rasterize `!…` flags via mask compositing into an
offscreen canvas and cache per flag+size (animated colors re-render at a capped rate).
- CustomFlag: allow rendering without cosmetics metadata; align rainbow durations with CSS.
---
src/client/graphics/layers/NameLayer.ts | 1188 ++++++++++++++---------
src/core/CustomFlag.ts | 9 +-
2 files changed, 714 insertions(+), 483 deletions(-)
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);