Added NameLayer-Icons to PlayerInfoOverlay (#2446)

Resolves #1686

## Description:

Above or behind the player names on the map, there are icons (images
and/or emojis).
This PR also adds these icons to the PlayerInfoOverlay (on the right
side of the player name).
To share the logic, a new file PlayerIcons.ts has been created.

<img width="215" height="355" alt="Screenshot 2025-11-14 024435"
src="https://github.com/user-attachments/assets/2e581ef9-0330-4c9d-9c52-5f943a58e64b"
/>
<img width="203" height="337" alt="Screenshot 2025-11-14 024731"
src="https://github.com/user-attachments/assets/0c2bf278-b8ca-43c2-b466-ea7a83577b25"
/>
<img width="193" height="288" alt="Screenshot 2025-11-14 024639"
src="https://github.com/user-attachments/assets/be114bc6-f3a8-4b8d-b267-025587c9eafe"
/>

The alliance icon is NOT shown because it's already on the left side of
the alliance timer.

### Why is this change needed?

Sometimes you can't quickly find the nametag of a player on the map.
Especially if a player's territory is scattered around the map, maybe
even on several small islands.
But you still want to know if the player is AFK, has a traitor debuff,
etc.
So it's very useful to get this information by just hovering over a
player instead of needing to search for the floating nametag.

## Please complete the following:

- [X] I have added screenshots for all UI updates
- [X] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [X] I have added relevant tests to the test directory
- [X] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

## Please put your Discord username so you can be contacted if a bug or
regression is found:

FloPinguin

---------

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