mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 09:40:44 +00:00
Perf/refactor/fix(NameLayer): about 10% extra improvement (#3540)
## Description: NameLayer perf part 2 after https://github.com/openfrontio/OpenFrontIO/pull/3475 with thanks to @scamiv. Shaves off another 10% or thereabouts, even doing something extra for a fix (see below). Also refactor/fixes around NameLayer and PlayerIcons, which is used by both NameLayer and PlayerInfoOverlay, and underlying function in GameView. This would go well with other PR https://github.com/openfrontio/OpenFrontIO/pull/3481, since this layer reads multiple settings. Reasoning to not use events and instead rely on fast caching is explained in that PR. ### Contents - Fixes: -- Fixes bug on .dev introduced by wrong assumption by me in previous PR https://github.com/openfrontio/OpenFrontIO/pull/3475. displayName CAN change during game, when Hidden Names is toggled from settings, so needs to be put back in renderPlayerInfo. -- Fixes longer existing bug: it was assumed Dark Mode didn't change after creation of icon element. Now it also sets Dark Mode attribute when updating icons elements. -- Fixes target mark icons not being shown to team members, while the icons were shown to normal allies. And EventsDisplay displayed message "XX requests you attack XX" to both team members and allies already. So why is the icon not shown to both if the message already is. While we improve performance of GameView > PlayerView > transitiveTargets (which is only used by NameLayer/PlayerIcons so only in this context). We can add team members' targets to it in one go. So previously transitiveTargets returned: your own targets and allies' targets. Now transitiveTargets is faster and returns: your own targets and allies' targets and team members' targets. - NameLayer: -- renderLayer: for target icons, getPlayerIcons used to fetch myPlayer.transitiveTargets each time. While that doesn't change per player we're rendering for. So now, we fetch myPlayer.transitiveTargets once per call to renderPlayerInfo, which passes it on to getPlayerIcons. So now we check it 1x each 100ms (renderCheckRate) inside of renderLayer. Instead of up to 100s of times each 500ms (renderRefreshRate) inside of getPlayerIcons inside of renderPlayerInfo loop. -- createBasePlayerElement and renderPlayerInfo: use cloneNode where possible with templates -- createPlayerElement: only find the elements and set font and flag. Leave the rest to renderLayer > renderPlayerInfo which fills displayName and troops and font color very soon after anyway. I haven't noticed a difference in testing. -- cache game.config() and others -- renderPlayerInfo: remove check if render.flagDiv exists, we know it exists. Check if fontColor changed before assigning it (it never changes, currently, be it dark mode or light mode). Don't check if troops or size changes, that happens so often that the overhead for checking would be smaller than the win, probably. -- We don't require nameLocation to be changed to change scale (see previous Namelayer perf PR for the reason). But it seems good to check if the transform changed before 'overwriting' it, so do that now instead. -- Remove Alliance icon DOM traversals. Only do it once, for each time an alliance icon is displayed. To this end, also made NameLayer more agnostic on Alliance icon stuff. By moving more code to PlayerIcons. See below. -- Use cached allianceDuration instead of fetching this static value every time -- Re-use from PlayerIcons: ALLIANCE_ICON_ID, TRAITOR_ICON_ID etc -- create more sub-functions to make the icons loop in renderLayer more readable: handleEmojiIcon, handleAllianceIcons, createOrUpdateIconElement (createIconElement already existed, now combined), handleTraitorIcon. For Alliance icons, this was already done in PlayerIcons.ts through createAllianceProgressIcon (now createAllianceProgressIconRefs), and more now to skip some DOM traversals. But most of this belongs in NameLayer itself when it comes to seperation of concern. - cache dark mode (as boolean and as string) - use dark mode to update (alliance) icons too, not only on create, since the setting can change after icon element creation and before it is removed -- for getPlayerIcons, add this.alliancesDisabled. If disabled, getPlayerIcons won't fetch Alliance icon and Alliance Request icon. - PlayerIcons: -- use cloneNode where possible -- added check for alliances disabled: then skip alliance (request) icon checks -- See point under NameLayer about the move of Alliance icon code to PlayerIcons. To make NameLayer even more agnostic on it and keep it in one place. -- getPlayerIcons: skip creating a new Set from myPlayer.transitiveTargets() each time getPlayerIcons is called. One allocation less. Just do .includes on the returned array. Probably just as fast in this case, also because not many Targets are present many times anyway. -- getPlayerIcons: on outgoingEmojis(), use .find() instead of .filter() since we only use the first result anyway and it saves us another allocation. -- getPlayerIcons: for nukes, only fetch the ones from the player we're rendering for, not all game nukes. Also don't use .filter() and just a normal loop to skip an allocation. Logic outcome is the same. -- getPlayerIcons: for target icons, it used to fetch myPlayer.transitiveTargets each time. While that doesn't change per player we're rendering for. So now, NameLayer fetches myPlayer.transitiveTargets once per call to renderPlayerInfo, which passes it on to getPlayerIcons. -- Remove the need for querySelector and getElementsByTagName("img") alltogether. Since this would be done for every time an alliance was (re-)created, here in createAllianceProgressIconRefs in PlayerIcons it makes more sense to not do DOM traversal than in createPlayerElement in NameLayer where we only do it once per player per game anyway. We assume updateAllianceProgressIconRefs just knows of all image names in createAllianceProgressIconRefs. This is a bit less dynamic and maintainable maybe, but i think worth the win. And the functions are all one-purpose and not meant to be used dynamically by another caller anyway. -- So instead of updateAllianceProgressIconRefs looping through refs.images, now just update the different images each. See point above. - PlayerInfoOverlay: also re-use the new exported consts from PlayerIcons. Since we put those in PlayerIcons anyway, need to be consistent. Even though PlayerInfoOverlay is outside of the scope of this PR otherwise. -- for getPlayerIcons, add this.alliancesDisabled here too. If disabled, getPlayerIcons won't fetch Alliance icon and Alliance Request icon. We also send includeAllianceIcon = false, which means Alliance icon will already be excluded but Alliance Request icon is normally still fetched and shown. - GameView > PlayerView: for transitiveTargets (only used in NameLayer/PlayerIcons so only in this context), improve performance. It did several allocations. Now it loops directly over the arrays we need. Also (as mentioned under Fixes) previously transitiveTargets returned: your own targets and allies' targets. Now transitiveTargets is faster and returns: your own targets and allies' targets and team members' targets. **BEFORE**  **AFTER** (including now getting team members' targets for myPlayer.transitiveTargets)  ## 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: tryout33
This commit is contained in:
@@ -19,18 +19,43 @@ const questionMarkIcon = assetUrl("images/QuestionMarkIcon.svg");
|
||||
const targetIcon = assetUrl("images/TargetIcon.svg");
|
||||
const traitorIcon = assetUrl("images/TraitorIcon.svg");
|
||||
|
||||
export type PlayerIconId =
|
||||
| "crown"
|
||||
| "traitor"
|
||||
| "disconnected"
|
||||
| "alliance"
|
||||
| "alliance-request"
|
||||
| "target"
|
||||
| "emoji"
|
||||
| "embargo"
|
||||
| "nuke";
|
||||
let allianceIconTemplate: HTMLDivElement | undefined;
|
||||
|
||||
export type PlayerIconKind = "image" | "emoji";
|
||||
export const ALLIANCE_ICON_ID = "alliance" as const;
|
||||
const ALLIANCE_PROGRESS_OVERLAY_CLASS = "alliance-progress-overlay";
|
||||
const ALLIANCE_QUESTION_MARK_CLASS = "alliance-question-mark";
|
||||
export const TRAITOR_ICON_ID = "traitor" as const;
|
||||
const CROWN_ICON_ID = "crown" as const;
|
||||
const DISCONNECTED_ICON_ID = "disconnected" as const;
|
||||
const ALLIANCE_REQUEST_ICON_ID = "alliance-request" as const;
|
||||
const TARGET_ICON_ID = "target" as const;
|
||||
const EMOJI_ICON_ID = "emoji" as const;
|
||||
const EMBARGO_ICON_ID = "embargo" as const;
|
||||
const NUKE_ICON_ID = "nuke" as const;
|
||||
|
||||
export const IMAGE_ICON_KIND = "image" as const;
|
||||
export const EMOJI_ICON_KIND = "emoji" as const;
|
||||
|
||||
export type PlayerIconId =
|
||||
| typeof CROWN_ICON_ID
|
||||
| typeof TRAITOR_ICON_ID
|
||||
| typeof DISCONNECTED_ICON_ID
|
||||
| typeof ALLIANCE_ICON_ID
|
||||
| typeof ALLIANCE_REQUEST_ICON_ID
|
||||
| typeof TARGET_ICON_ID
|
||||
| typeof EMOJI_ICON_ID
|
||||
| typeof EMBARGO_ICON_ID
|
||||
| typeof NUKE_ICON_ID;
|
||||
|
||||
export type PlayerIconKind = typeof IMAGE_ICON_KIND | typeof EMOJI_ICON_KIND;
|
||||
|
||||
export type AllianceProgressIconRefs = {
|
||||
wrapper: HTMLDivElement;
|
||||
base: HTMLImageElement;
|
||||
overlay: HTMLDivElement;
|
||||
colored: HTMLImageElement;
|
||||
questionMark: HTMLImageElement;
|
||||
};
|
||||
|
||||
export interface PlayerIconDescriptor {
|
||||
id: PlayerIconId;
|
||||
@@ -50,6 +75,9 @@ export interface PlayerIconParams {
|
||||
includeAllianceIcon: boolean;
|
||||
/** Player currently in first place, used for the crown icon */
|
||||
firstPlace: PlayerView | null;
|
||||
alliancesDisabled: boolean;
|
||||
darkMode?: boolean;
|
||||
transitiveTargets?: PlayerView[];
|
||||
}
|
||||
|
||||
export function getFirstPlacePlayer(game: GameView): PlayerView | null {
|
||||
@@ -63,71 +91,99 @@ export function getFirstPlacePlayer(game: GameView): PlayerView | null {
|
||||
export function getPlayerIcons(
|
||||
params: PlayerIconParams,
|
||||
): PlayerIconDescriptor[] {
|
||||
const { game, player, includeAllianceIcon, firstPlace } = params;
|
||||
const {
|
||||
game,
|
||||
player,
|
||||
includeAllianceIcon,
|
||||
firstPlace,
|
||||
alliancesDisabled,
|
||||
darkMode,
|
||||
transitiveTargets,
|
||||
} = params;
|
||||
|
||||
const myPlayer = game.myPlayer();
|
||||
const userSettings = game.config().userSettings();
|
||||
const isDarkMode = userSettings?.darkMode() ?? false;
|
||||
const isDarkMode = darkMode ?? userSettings?.darkMode() ?? false;
|
||||
const emojisEnabled = userSettings?.emojis() ?? false;
|
||||
const alliancesOff = alliancesDisabled ?? game.config().disableAlliances();
|
||||
|
||||
const icons: PlayerIconDescriptor[] = [];
|
||||
|
||||
// Crown icon for first place
|
||||
if (player === firstPlace) {
|
||||
icons.push({ id: "crown", kind: "image", src: crownIcon });
|
||||
icons.push({ id: CROWN_ICON_ID, kind: IMAGE_ICON_KIND, src: crownIcon });
|
||||
}
|
||||
|
||||
// Traitor icon
|
||||
if (player.isTraitor()) {
|
||||
icons.push({ id: "traitor", kind: "image", src: traitorIcon });
|
||||
icons.push({
|
||||
id: TRAITOR_ICON_ID,
|
||||
kind: IMAGE_ICON_KIND,
|
||||
src: traitorIcon,
|
||||
});
|
||||
}
|
||||
|
||||
// Disconnected icon
|
||||
if (player.isDisconnected()) {
|
||||
icons.push({ id: "disconnected", kind: "image", src: disconnectedIcon });
|
||||
}
|
||||
|
||||
// Alliance icon
|
||||
if (
|
||||
includeAllianceIcon &&
|
||||
myPlayer !== null &&
|
||||
myPlayer.isAlliedWith(player)
|
||||
) {
|
||||
icons.push({ id: "alliance", kind: "image", src: allianceIcon });
|
||||
}
|
||||
|
||||
// Alliance request icon (theme dependent)
|
||||
if (myPlayer !== null && player.isRequestingAllianceWith(myPlayer)) {
|
||||
const allianceRequestIcon = isDarkMode
|
||||
? allianceRequestWhiteIcon
|
||||
: allianceRequestBlackIcon;
|
||||
icons.push({
|
||||
id: "alliance-request",
|
||||
kind: "image",
|
||||
src: allianceRequestIcon,
|
||||
id: DISCONNECTED_ICON_ID,
|
||||
kind: IMAGE_ICON_KIND,
|
||||
src: disconnectedIcon,
|
||||
});
|
||||
}
|
||||
|
||||
if (!alliancesOff) {
|
||||
// Alliance icon
|
||||
if (
|
||||
includeAllianceIcon &&
|
||||
myPlayer !== null &&
|
||||
myPlayer.isAlliedWith(player)
|
||||
) {
|
||||
icons.push({
|
||||
id: ALLIANCE_ICON_ID,
|
||||
kind: IMAGE_ICON_KIND,
|
||||
src: allianceIcon,
|
||||
});
|
||||
}
|
||||
|
||||
// Alliance request icon (theme dependent)
|
||||
if (myPlayer !== null && player.isRequestingAllianceWith(myPlayer)) {
|
||||
const allianceRequestIcon = isDarkMode
|
||||
? allianceRequestWhiteIcon
|
||||
: allianceRequestBlackIcon;
|
||||
icons.push({
|
||||
id: ALLIANCE_REQUEST_ICON_ID,
|
||||
kind: IMAGE_ICON_KIND,
|
||||
src: allianceRequestIcon,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Target icon (centered on the map, but regular in overlays)
|
||||
if (myPlayer !== null && new Set(myPlayer.transitiveTargets()).has(player)) {
|
||||
icons.push({ id: "target", kind: "image", src: targetIcon, center: true });
|
||||
const targets = transitiveTargets ?? myPlayer?.transitiveTargets() ?? [];
|
||||
if (targets.includes(player)) {
|
||||
icons.push({
|
||||
id: TARGET_ICON_ID,
|
||||
kind: IMAGE_ICON_KIND,
|
||||
src: targetIcon,
|
||||
center: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Emoji handling
|
||||
if (emojisEnabled) {
|
||||
const emojis = player
|
||||
const emoji = player
|
||||
.outgoingEmojis()
|
||||
.filter(
|
||||
(emoji) =>
|
||||
emoji.recipientID === AllPlayers ||
|
||||
emoji.recipientID === myPlayer?.smallID(),
|
||||
.find(
|
||||
(e) =>
|
||||
e.recipientID === AllPlayers || e.recipientID === myPlayer?.smallID(),
|
||||
);
|
||||
|
||||
if (emojis.length > 0) {
|
||||
if (emoji) {
|
||||
icons.push({
|
||||
id: "emoji",
|
||||
kind: "emoji",
|
||||
text: emojis[0].message,
|
||||
id: EMOJI_ICON_ID,
|
||||
kind: EMOJI_ICON_KIND,
|
||||
text: emoji.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -135,91 +191,153 @@ export function getPlayerIcons(
|
||||
// Embargo icon (theme dependent)
|
||||
if (myPlayer?.hasEmbargo(player)) {
|
||||
const embargoIcon = isDarkMode ? embargoWhiteIcon : embargoBlackIcon;
|
||||
icons.push({ id: "embargo", kind: "image", src: embargoIcon });
|
||||
icons.push({
|
||||
id: EMBARGO_ICON_ID,
|
||||
kind: IMAGE_ICON_KIND,
|
||||
src: embargoIcon,
|
||||
});
|
||||
}
|
||||
|
||||
// Nuke icon (different color depending on whether the local player is the target)
|
||||
const nukesSentByOtherPlayer = game.units(...Nukes.types).filter((unit) => {
|
||||
const isSendingNuke = player.id() === unit.owner().id();
|
||||
const notMyPlayer = !myPlayer || unit.owner().id() !== myPlayer.id();
|
||||
return isSendingNuke && notMyPlayer && unit.isActive();
|
||||
});
|
||||
if (!myPlayer || player.id() !== myPlayer.id()) {
|
||||
let hasActiveNukes = false;
|
||||
let isMyPlayerTarget = false;
|
||||
const playerNukes = player.units(...Nukes.types);
|
||||
|
||||
const isMyPlayerTarget = nukesSentByOtherPlayer.some((unit) => {
|
||||
const detonationDst = unit.targetTile();
|
||||
if (!detonationDst || !myPlayer) return false;
|
||||
const targetId = game.owner(detonationDst).id();
|
||||
return targetId === myPlayer.id();
|
||||
});
|
||||
for (const nuke of playerNukes) {
|
||||
if (nuke.isActive()) {
|
||||
hasActiveNukes = true;
|
||||
|
||||
if (nukesSentByOtherPlayer.length > 0) {
|
||||
const icon = isMyPlayerTarget ? nukeRedIcon : nukeWhiteIcon;
|
||||
icons.push({ id: "nuke", kind: "image", src: icon });
|
||||
const detonationDst = nuke.targetTile();
|
||||
if (
|
||||
myPlayer &&
|
||||
detonationDst &&
|
||||
game.owner(detonationDst).id() === myPlayer.id()
|
||||
) {
|
||||
isMyPlayerTarget = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasActiveNukes) {
|
||||
const icon = isMyPlayerTarget ? nukeRedIcon : nukeWhiteIcon;
|
||||
icons.push({ id: NUKE_ICON_ID, kind: IMAGE_ICON_KIND, src: icon });
|
||||
}
|
||||
}
|
||||
|
||||
return icons;
|
||||
}
|
||||
|
||||
export function createAllianceProgressIcon(
|
||||
export function createAllianceProgressIconRefs(
|
||||
size: number,
|
||||
fraction: number,
|
||||
hasExtensionRequest: boolean,
|
||||
darkMode: boolean,
|
||||
): HTMLDivElement {
|
||||
darkMode: string,
|
||||
): AllianceProgressIconRefs {
|
||||
if (!allianceIconTemplate) {
|
||||
allianceIconTemplate = document.createElement("div");
|
||||
allianceIconTemplate.setAttribute("data-icon", ALLIANCE_ICON_ID);
|
||||
allianceIconTemplate.style.position = "relative";
|
||||
allianceIconTemplate.style.display = "inline-block";
|
||||
allianceIconTemplate.style.flexShrink = "0";
|
||||
|
||||
const base = document.createElement("img");
|
||||
base.src = allianceIconFaded;
|
||||
base.style.display = "block";
|
||||
allianceIconTemplate.appendChild(base);
|
||||
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = ALLIANCE_PROGRESS_OVERLAY_CLASS;
|
||||
overlay.style.position = "absolute";
|
||||
overlay.style.left = "0";
|
||||
overlay.style.top = "0";
|
||||
overlay.style.width = "100%";
|
||||
overlay.style.height = "100%";
|
||||
|
||||
const colored = document.createElement("img");
|
||||
colored.src = allianceIcon; // green icon
|
||||
colored.style.display = "block";
|
||||
overlay.appendChild(colored);
|
||||
|
||||
allianceIconTemplate.appendChild(overlay);
|
||||
|
||||
const questionMark = document.createElement("img");
|
||||
questionMark.className = ALLIANCE_QUESTION_MARK_CLASS;
|
||||
questionMark.src = questionMarkIcon;
|
||||
questionMark.style.position = "absolute";
|
||||
questionMark.style.left = "0";
|
||||
questionMark.style.top = "0";
|
||||
questionMark.style.pointerEvents = "none";
|
||||
allianceIconTemplate.appendChild(questionMark);
|
||||
}
|
||||
|
||||
// Wrapper
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.setAttribute("data-icon", "alliance");
|
||||
wrapper.setAttribute("dark-mode", darkMode.toString());
|
||||
wrapper.style.position = "relative";
|
||||
const wrapper = allianceIconTemplate.cloneNode(true) as HTMLDivElement;
|
||||
wrapper.setAttribute("dark-mode", darkMode);
|
||||
wrapper.style.width = `${size}px`;
|
||||
wrapper.style.height = `${size}px`;
|
||||
wrapper.style.display = "inline-block";
|
||||
wrapper.style.flexShrink = "0";
|
||||
|
||||
// Base faded icon (full)
|
||||
const base = document.createElement("img");
|
||||
base.src = allianceIconFaded;
|
||||
// No QuerySelector here since we know the structure and it avoids overhead each call
|
||||
const base = wrapper.childNodes[0] as HTMLImageElement;
|
||||
base.style.width = `${size}px`;
|
||||
base.style.height = `${size}px`;
|
||||
base.style.display = "block";
|
||||
base.setAttribute("dark-mode", darkMode.toString());
|
||||
wrapper.appendChild(base);
|
||||
base.setAttribute("dark-mode", darkMode);
|
||||
|
||||
// Overlay container for green portion, clipped from the top via clip-path
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "alliance-progress-overlay";
|
||||
overlay.style.position = "absolute";
|
||||
overlay.style.left = "0";
|
||||
overlay.style.top = "0";
|
||||
overlay.style.width = "100%";
|
||||
overlay.style.height = "100%";
|
||||
const overlay = wrapper.childNodes[1] as HTMLDivElement;
|
||||
overlay.style.clipPath = computeAllianceClipPath(fraction);
|
||||
|
||||
const colored = document.createElement("img");
|
||||
colored.src = allianceIcon; // green icon
|
||||
const colored = overlay.childNodes[0] as HTMLImageElement;
|
||||
colored.style.width = `${size}px`;
|
||||
colored.style.height = `${size}px`;
|
||||
colored.style.display = "block";
|
||||
colored.setAttribute("dark-mode", darkMode.toString());
|
||||
overlay.appendChild(colored);
|
||||
|
||||
wrapper.appendChild(overlay);
|
||||
colored.setAttribute("dark-mode", darkMode);
|
||||
|
||||
// Question mark overlay (shown when there's a pending extension request)
|
||||
const questionMark = document.createElement("img");
|
||||
questionMark.className = "alliance-question-mark";
|
||||
questionMark.src = questionMarkIcon;
|
||||
questionMark.style.position = "absolute";
|
||||
questionMark.style.left = "0";
|
||||
questionMark.style.top = "0";
|
||||
const questionMark = wrapper.childNodes[2] as HTMLImageElement;
|
||||
questionMark.style.width = `${size}px`;
|
||||
questionMark.style.height = `${size}px`;
|
||||
questionMark.style.display = hasExtensionRequest ? "block" : "none";
|
||||
questionMark.style.pointerEvents = "none";
|
||||
questionMark.setAttribute("dark-mode", darkMode.toString());
|
||||
wrapper.appendChild(questionMark);
|
||||
questionMark.setAttribute("dark-mode", darkMode);
|
||||
|
||||
return wrapper;
|
||||
return {
|
||||
wrapper,
|
||||
base,
|
||||
overlay,
|
||||
colored,
|
||||
questionMark,
|
||||
};
|
||||
}
|
||||
|
||||
export function updateAllianceProgressIconRefs(
|
||||
refs: AllianceProgressIconRefs,
|
||||
size: number,
|
||||
fraction: number,
|
||||
hasExtensionRequest: boolean,
|
||||
darkMode: string,
|
||||
): void {
|
||||
refs.wrapper.style.width = `${size}px`;
|
||||
refs.wrapper.style.height = `${size}px`;
|
||||
refs.wrapper.style.flexShrink = "0";
|
||||
|
||||
refs.base.style.width = `${size}px`;
|
||||
refs.base.style.height = `${size}px`;
|
||||
refs.base.setAttribute("dark-mode", darkMode);
|
||||
|
||||
refs.colored.style.width = `${size}px`;
|
||||
refs.colored.style.height = `${size}px`;
|
||||
refs.colored.setAttribute("dark-mode", darkMode);
|
||||
refs.overlay.style.clipPath = computeAllianceClipPath(fraction);
|
||||
|
||||
if (!hasExtensionRequest) {
|
||||
refs.questionMark.style.display = "none";
|
||||
} else {
|
||||
refs.questionMark.style.width = `${size}px`;
|
||||
refs.questionMark.style.height = `${size}px`;
|
||||
refs.questionMark.style.display = "block";
|
||||
refs.questionMark.setAttribute("dark-mode", darkMode);
|
||||
}
|
||||
}
|
||||
|
||||
export function computeAllianceClipPath(fraction: number): string {
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
import { assetUrl } from "src/core/AssetUrls";
|
||||
import { EventBus } from "../../../core/EventBus";
|
||||
import { PseudoRandom } from "../../../core/PseudoRandom";
|
||||
import { Theme } from "../../../core/configuration/Config";
|
||||
import { Config, Theme } from "../../../core/configuration/Config";
|
||||
import { Cell } from "../../../core/game/Game";
|
||||
import { GameView, PlayerView } from "../../../core/game/GameView";
|
||||
import { UserSettings } from "../../../core/game/UserSettings";
|
||||
import { AlternateViewEvent } from "../../InputHandler";
|
||||
import { renderTroops } from "../../Utils";
|
||||
import {
|
||||
computeAllianceClipPath,
|
||||
createAllianceProgressIcon,
|
||||
ALLIANCE_ICON_ID,
|
||||
AllianceProgressIconRefs,
|
||||
createAllianceProgressIconRefs,
|
||||
EMOJI_ICON_KIND,
|
||||
getFirstPlacePlayer,
|
||||
getPlayerIcons,
|
||||
IMAGE_ICON_KIND,
|
||||
PlayerIconDescriptor,
|
||||
PlayerIconId,
|
||||
TRAITOR_ICON_ID,
|
||||
updateAllianceProgressIconRefs,
|
||||
} from "../PlayerIcons";
|
||||
import { TransformHandler } from "../TransformHandler";
|
||||
import { Layer } from "./Layer";
|
||||
@@ -24,13 +30,8 @@ const PLAYER_ICONS = "player-icons";
|
||||
const PLAYER_FLAG = "player-flag";
|
||||
|
||||
class RenderInfo {
|
||||
public icons: Map<PlayerIconId, HTMLElement> = new Map(); // Track icon elements
|
||||
|
||||
public nameDiv: HTMLDivElement;
|
||||
public nameSpan: HTMLSpanElement | null;
|
||||
public troopsDiv: HTMLDivElement;
|
||||
public flagDiv: HTMLDivElement | null;
|
||||
public iconsDiv: HTMLDivElement;
|
||||
public icons: Map<PlayerIconId, HTMLElement> = new Map();
|
||||
public allianceIconRefs: AllianceProgressIconRefs | null = null;
|
||||
|
||||
constructor(
|
||||
public player: PlayerView,
|
||||
@@ -39,23 +40,17 @@ class RenderInfo {
|
||||
public fontSize: number,
|
||||
public fontColor: string,
|
||||
public element: HTMLElement,
|
||||
) {
|
||||
// Traverse the DOM once, upon creation
|
||||
this.nameDiv = element.querySelector(`.${PLAYER_NAME}`) as HTMLDivElement;
|
||||
this.nameSpan = element.querySelector(
|
||||
`.${PLAYER_NAME_SPAN}`,
|
||||
) as HTMLSpanElement | null;
|
||||
this.troopsDiv = element.querySelector(
|
||||
`.${PLAYER_TROOPS}`,
|
||||
) as HTMLDivElement;
|
||||
this.flagDiv = element.querySelector(
|
||||
`.${PLAYER_FLAG}`,
|
||||
) as HTMLDivElement | null;
|
||||
this.iconsDiv = element.querySelector(`.${PLAYER_ICONS}`) as HTMLDivElement;
|
||||
}
|
||||
public nameDiv: HTMLDivElement,
|
||||
public nameSpan: HTMLSpanElement,
|
||||
public troopsDiv: HTMLDivElement,
|
||||
public flagImg: HTMLImageElement,
|
||||
public iconsDiv: HTMLDivElement,
|
||||
public lastTransform: string = "",
|
||||
) {}
|
||||
}
|
||||
|
||||
export class NameLayer implements Layer {
|
||||
private config: Config;
|
||||
private lastChecked = 0;
|
||||
private renderCheckRate = 100;
|
||||
private renderRefreshRate = 500;
|
||||
@@ -63,11 +58,18 @@ export class NameLayer implements Layer {
|
||||
private renders: RenderInfo[] = [];
|
||||
private seenPlayers: Set<PlayerView> = new Set();
|
||||
private container: HTMLDivElement;
|
||||
private theme: Theme = this.game.config().theme();
|
||||
private theme: Theme;
|
||||
private userSettings: UserSettings = new UserSettings();
|
||||
private isVisible: boolean = true;
|
||||
private firstPlace: PlayerView | null = null;
|
||||
private allianceDuration: number;
|
||||
private alliancesDisabled: boolean = false;
|
||||
private myPlayer: PlayerView | null = null;
|
||||
private lastContainerTransform: string = "";
|
||||
private basePlayerTemplate: HTMLDivElement;
|
||||
private iconTemplate: HTMLImageElement;
|
||||
private iconCenterTemplate: HTMLImageElement;
|
||||
private emojiTemplate: HTMLDivElement;
|
||||
|
||||
constructor(
|
||||
private game: GameView,
|
||||
@@ -79,9 +81,7 @@ export class NameLayer implements Layer {
|
||||
return false;
|
||||
}
|
||||
|
||||
redraw() {
|
||||
this.theme = this.game.config().theme();
|
||||
}
|
||||
redraw() {} // not affected by Canvas/WebGL context loss as this layer is DOM-based
|
||||
|
||||
public init() {
|
||||
this.container = document.createElement("div");
|
||||
@@ -107,6 +107,27 @@ export class NameLayer implements Layer {
|
||||
`;
|
||||
this.container.appendChild(style);
|
||||
|
||||
this.myPlayer = this.game.myPlayer();
|
||||
this.config = this.game.config();
|
||||
this.theme = this.config.theme();
|
||||
|
||||
this.alliancesDisabled = this.config.disableAlliances();
|
||||
this.allianceDuration = Math.max(1, this.config.allianceDuration());
|
||||
|
||||
this.basePlayerTemplate = this.createBasePlayerElement();
|
||||
|
||||
this.iconTemplate = document.createElement("img");
|
||||
|
||||
this.iconCenterTemplate = document.createElement("img");
|
||||
this.iconCenterTemplate.style.position = "absolute";
|
||||
this.iconCenterTemplate.style.top = "50%";
|
||||
this.iconCenterTemplate.style.transform = "translateY(-50%)";
|
||||
|
||||
this.emojiTemplate = document.createElement("div");
|
||||
this.emojiTemplate.style.position = "absolute";
|
||||
this.emojiTemplate.style.top = "50%";
|
||||
this.emojiTemplate.style.transform = "translateY(-50%)";
|
||||
|
||||
this.eventBus.on(AlternateViewEvent, (e) => this.onAlternateViewChange(e));
|
||||
}
|
||||
|
||||
@@ -131,15 +152,15 @@ export class NameLayer implements Layer {
|
||||
: false;
|
||||
const maxZoomScale = 17;
|
||||
|
||||
if (
|
||||
const display =
|
||||
!this.isVisible ||
|
||||
size < 7 ||
|
||||
(this.transformHandler.scale > maxZoomScale && size > 100) ||
|
||||
!isOnScreen
|
||||
) {
|
||||
render.element.style.display = "none";
|
||||
} else {
|
||||
render.element.style.display = "flex";
|
||||
? "none"
|
||||
: "flex";
|
||||
if (render.element.style.display !== display) {
|
||||
render.element.style.display = display;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,16 +176,7 @@ export class NameLayer implements Layer {
|
||||
if (player.isAlive()) {
|
||||
if (!this.seenPlayers.has(player)) {
|
||||
this.seenPlayers.add(player);
|
||||
this.renders.push(
|
||||
new RenderInfo(
|
||||
player,
|
||||
0,
|
||||
null,
|
||||
0,
|
||||
"",
|
||||
this.createPlayerElement(player),
|
||||
),
|
||||
);
|
||||
this.renders.push(this.createPlayerElement(player));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -187,19 +199,24 @@ export class NameLayer implements Layer {
|
||||
const now = Date.now();
|
||||
if (now > this.lastChecked + this.renderCheckRate) {
|
||||
this.lastChecked = now;
|
||||
|
||||
this.myPlayer ??= this.game.myPlayer();
|
||||
const transitiveTargets = this.myPlayer?.transitiveTargets() ?? [];
|
||||
|
||||
for (const render of this.renders) {
|
||||
this.renderPlayerInfo(render);
|
||||
this.renderPlayerInfo(render, transitiveTargets);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private createPlayerElement(player: PlayerView): HTMLDivElement {
|
||||
private createBasePlayerElement(): HTMLDivElement {
|
||||
const element = document.createElement("div");
|
||||
element.style.position = "absolute";
|
||||
element.style.display = "flex";
|
||||
element.style.flexDirection = "column";
|
||||
element.style.alignItems = "center";
|
||||
element.style.gap = "0px";
|
||||
// Start off invisible so it doesn't flash at 0,0
|
||||
element.style.display = "none";
|
||||
|
||||
const iconsDiv = document.createElement("div");
|
||||
iconsDiv.classList.add(PLAYER_ICONS);
|
||||
@@ -212,23 +229,7 @@ export class NameLayer implements Layer {
|
||||
element.appendChild(iconsDiv);
|
||||
|
||||
const nameDiv = document.createElement("div");
|
||||
const applyFlagStyles = (element: HTMLElement): void => {
|
||||
element.classList.add(PLAYER_FLAG);
|
||||
element.style.opacity = "0.8";
|
||||
element.style.zIndex = "1";
|
||||
element.style.objectFit = "contain";
|
||||
};
|
||||
|
||||
if (player.cosmetics.flag) {
|
||||
const flag = assetUrl(player.cosmetics.flag);
|
||||
const flagImg = document.createElement("img");
|
||||
flagImg.src = flag;
|
||||
applyFlagStyles(flagImg);
|
||||
nameDiv.appendChild(flagImg);
|
||||
}
|
||||
nameDiv.classList.add(PLAYER_NAME);
|
||||
nameDiv.style.color = this.theme.textColor(player);
|
||||
nameDiv.style.fontFamily = this.theme.font();
|
||||
nameDiv.style.whiteSpace = "nowrap";
|
||||
nameDiv.style.textOverflow = "ellipsis";
|
||||
nameDiv.style.zIndex = "3";
|
||||
@@ -236,30 +237,75 @@ export class NameLayer implements Layer {
|
||||
nameDiv.style.justifyContent = "flex-end";
|
||||
nameDiv.style.alignItems = "center";
|
||||
|
||||
const flagImg = document.createElement("img");
|
||||
flagImg.classList.add(PLAYER_FLAG);
|
||||
flagImg.style.opacity = "0.8";
|
||||
flagImg.style.zIndex = "1";
|
||||
flagImg.style.objectFit = "contain";
|
||||
flagImg.style.display = "none";
|
||||
nameDiv.appendChild(flagImg);
|
||||
|
||||
const nameSpan = document.createElement("span");
|
||||
nameSpan.className = PLAYER_NAME_SPAN;
|
||||
nameSpan.textContent = player.displayName();
|
||||
nameSpan.classList.add(PLAYER_NAME_SPAN);
|
||||
nameDiv.appendChild(nameSpan);
|
||||
element.appendChild(nameDiv);
|
||||
|
||||
const troopsDiv = document.createElement("div");
|
||||
troopsDiv.classList.add(PLAYER_TROOPS);
|
||||
troopsDiv.setAttribute("translate", "no");
|
||||
troopsDiv.textContent = renderTroops(player.troops());
|
||||
troopsDiv.style.color = this.theme.textColor(player);
|
||||
troopsDiv.style.fontFamily = this.theme.font();
|
||||
troopsDiv.style.zIndex = "3";
|
||||
troopsDiv.style.marginTop = "-5%";
|
||||
element.appendChild(troopsDiv);
|
||||
|
||||
// Start off invisible so it doesn't flash at 0,0
|
||||
element.style.display = "none";
|
||||
|
||||
this.container.appendChild(element);
|
||||
return element;
|
||||
}
|
||||
|
||||
renderPlayerInfo(render: RenderInfo) {
|
||||
private createPlayerElement(player: PlayerView): RenderInfo {
|
||||
const element = this.basePlayerTemplate.cloneNode(true) as HTMLDivElement;
|
||||
|
||||
// Queryselector expensive but this runs only once per player and better maintainable
|
||||
const nameDiv = element.querySelector(`.${PLAYER_NAME}`) as HTMLDivElement;
|
||||
const nameSpan = element.querySelector(
|
||||
`.${PLAYER_NAME_SPAN}`,
|
||||
) as HTMLSpanElement;
|
||||
const troopsDiv = element.querySelector(
|
||||
`.${PLAYER_TROOPS}`,
|
||||
) as HTMLDivElement;
|
||||
const flagImg = element.querySelector(
|
||||
`.${PLAYER_FLAG}`,
|
||||
) as HTMLImageElement;
|
||||
const iconsDiv = element.querySelector(
|
||||
`.${PLAYER_ICONS}`,
|
||||
) as HTMLDivElement;
|
||||
|
||||
const font = this.theme.font();
|
||||
nameDiv.style.fontFamily = font;
|
||||
|
||||
const flag = player.cosmetics.flag;
|
||||
if (flag) {
|
||||
flagImg.src = assetUrl(flag);
|
||||
flagImg.style.display = "block";
|
||||
}
|
||||
|
||||
const renderInfo = new RenderInfo(
|
||||
player,
|
||||
0,
|
||||
null,
|
||||
0,
|
||||
"",
|
||||
element,
|
||||
nameDiv,
|
||||
nameSpan,
|
||||
troopsDiv,
|
||||
flagImg,
|
||||
iconsDiv,
|
||||
);
|
||||
|
||||
this.container.appendChild(element);
|
||||
return renderInfo;
|
||||
}
|
||||
|
||||
renderPlayerInfo(render: RenderInfo, transitiveTargets: PlayerView[]) {
|
||||
if (!render.player.nameLocation()) {
|
||||
return;
|
||||
}
|
||||
@@ -296,22 +342,27 @@ export class NameLayer implements Layer {
|
||||
}
|
||||
render.lastRenderCalc = now + this.rand.nextInt(0, 100);
|
||||
|
||||
// Update text sizes
|
||||
// Update text sizes, content and color
|
||||
render.fontSize = Math.max(4, Math.floor(baseSize * 0.4));
|
||||
render.fontColor = this.theme.textColor(render.player);
|
||||
|
||||
render.nameDiv.style.fontSize = `${render.fontSize}px`;
|
||||
render.nameDiv.style.lineHeight = `${render.fontSize}px`;
|
||||
render.nameDiv.style.color = render.fontColor;
|
||||
if (render.flagDiv) {
|
||||
render.flagDiv.style.height = `${render.fontSize}px`;
|
||||
}
|
||||
render.flagImg.style.height = `${render.fontSize}px`;
|
||||
render.troopsDiv.style.fontSize = `${render.fontSize}px`;
|
||||
render.troopsDiv.style.color = render.fontColor;
|
||||
|
||||
render.nameSpan.textContent = render.player.displayName();
|
||||
render.troopsDiv.textContent = renderTroops(render.player.troops());
|
||||
|
||||
const fontColor = this.theme.textColor(render.player);
|
||||
if (render.fontColor !== fontColor) {
|
||||
render.fontColor = fontColor;
|
||||
render.nameDiv.style.color = fontColor;
|
||||
render.troopsDiv.style.color = fontColor;
|
||||
}
|
||||
|
||||
// Handle icons
|
||||
const iconSize = Math.min(render.fontSize * 1.5, 48);
|
||||
const darkMode = this.userSettings.darkMode();
|
||||
const darkModeStr = darkMode.toString();
|
||||
|
||||
// Compute which icons should be shown for this player using shared logic
|
||||
const icons = getPlayerIcons({
|
||||
@@ -319,6 +370,9 @@ export class NameLayer implements Layer {
|
||||
player: render.player,
|
||||
includeAllianceIcon: true,
|
||||
firstPlace: this.firstPlace,
|
||||
darkMode: darkMode,
|
||||
alliancesDisabled: this.alliancesDisabled,
|
||||
transitiveTargets: transitiveTargets,
|
||||
});
|
||||
|
||||
// Build a set of desired icon IDs
|
||||
@@ -327,163 +381,172 @@ export class NameLayer implements Layer {
|
||||
// Remove any icons that are no longer needed
|
||||
for (const [id, element] of render.icons) {
|
||||
if (!desiredIconIds.has(id)) {
|
||||
element.remove();
|
||||
render.icons.delete(id);
|
||||
if (id === ALLIANCE_ICON_ID) {
|
||||
render.allianceIconRefs?.wrapper.remove();
|
||||
render.allianceIconRefs = null;
|
||||
render.icons.delete(ALLIANCE_ICON_ID);
|
||||
} else {
|
||||
element.remove();
|
||||
render.icons.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add or update icons that should be shown
|
||||
for (const icon of icons) {
|
||||
if (icon.kind === "emoji" && icon.text) {
|
||||
let emojiDiv = render.icons.get(icon.id) as HTMLDivElement | undefined;
|
||||
if (icon.kind === EMOJI_ICON_KIND && icon.text) {
|
||||
this.handleEmojiIcon(render, icon, iconSize);
|
||||
continue;
|
||||
} else if (!(icon.kind === IMAGE_ICON_KIND && icon.src)) {
|
||||
continue;
|
||||
}
|
||||
// Special handling for alliance icon with progress indicator
|
||||
if (icon.id === ALLIANCE_ICON_ID) {
|
||||
this.handleAllianceIcons(render, iconSize, darkModeStr);
|
||||
continue; // Skip regular image handling
|
||||
}
|
||||
|
||||
if (!emojiDiv) {
|
||||
emojiDiv = document.createElement("div");
|
||||
emojiDiv.style.position = "absolute";
|
||||
emojiDiv.style.top = "50%";
|
||||
emojiDiv.style.transform = "translateY(-50%)";
|
||||
render.iconsDiv.appendChild(emojiDiv);
|
||||
render.icons.set(icon.id, emojiDiv);
|
||||
}
|
||||
const imgElement = this.handleOtherIcons(
|
||||
render,
|
||||
icon,
|
||||
iconSize,
|
||||
darkModeStr,
|
||||
);
|
||||
|
||||
emojiDiv.textContent = icon.text;
|
||||
emojiDiv.style.fontSize = `${iconSize}px`;
|
||||
} else if (icon.kind === "image" && icon.src) {
|
||||
// Special handling for alliance icon with progress indicator
|
||||
if (icon.id === "alliance") {
|
||||
let allianceWrapper = render.icons.get(icon.id) as
|
||||
| HTMLDivElement
|
||||
| undefined;
|
||||
|
||||
const myPlayer = this.game.myPlayer();
|
||||
const allianceView = myPlayer
|
||||
?.alliances()
|
||||
.find((a) => a.other === render.player.id());
|
||||
|
||||
let fraction = 0;
|
||||
let hasExtensionRequest = false;
|
||||
if (allianceView) {
|
||||
const remaining = Math.max(
|
||||
0,
|
||||
allianceView.expiresAt - this.game.ticks(),
|
||||
);
|
||||
const duration = Math.max(1, this.game.config().allianceDuration());
|
||||
fraction = Math.max(0, Math.min(1, remaining / duration));
|
||||
hasExtensionRequest = allianceView.hasExtensionRequest;
|
||||
}
|
||||
|
||||
if (!allianceWrapper) {
|
||||
allianceWrapper = createAllianceProgressIcon(
|
||||
iconSize,
|
||||
fraction,
|
||||
hasExtensionRequest,
|
||||
this.userSettings.darkMode(),
|
||||
);
|
||||
render.iconsDiv.appendChild(allianceWrapper);
|
||||
render.icons.set(icon.id, allianceWrapper);
|
||||
} else {
|
||||
// Update existing alliance icon
|
||||
allianceWrapper.style.width = `${iconSize}px`;
|
||||
allianceWrapper.style.height = `${iconSize}px`;
|
||||
allianceWrapper.style.flexShrink = "0";
|
||||
|
||||
const overlay = allianceWrapper.querySelector(
|
||||
".alliance-progress-overlay",
|
||||
) as HTMLDivElement | null;
|
||||
if (overlay) {
|
||||
overlay.style.clipPath = computeAllianceClipPath(fraction);
|
||||
}
|
||||
|
||||
const questionMark = allianceWrapper.querySelector(
|
||||
".alliance-question-mark",
|
||||
) as HTMLImageElement | null;
|
||||
if (questionMark) {
|
||||
questionMark.style.display = hasExtensionRequest
|
||||
? "block"
|
||||
: "none";
|
||||
}
|
||||
|
||||
// Update inner image sizes
|
||||
const imgs = allianceWrapper.getElementsByTagName("img");
|
||||
for (const img of imgs) {
|
||||
img.style.width = `${iconSize}px`;
|
||||
img.style.height = `${iconSize}px`;
|
||||
}
|
||||
}
|
||||
continue; // Skip regular image handling
|
||||
}
|
||||
|
||||
let imgElement = render.icons.get(icon.id) as
|
||||
| HTMLImageElement
|
||||
| undefined;
|
||||
|
||||
if (!imgElement) {
|
||||
imgElement = this.createIconElement(icon.src, iconSize, icon.center);
|
||||
render.iconsDiv.appendChild(imgElement);
|
||||
render.icons.set(icon.id, imgElement);
|
||||
}
|
||||
|
||||
// Update src if it changed (e.g., nuke red/white or dark-mode icons)
|
||||
if (imgElement.src !== icon.src) {
|
||||
imgElement.src = icon.src;
|
||||
}
|
||||
|
||||
imgElement.style.width = `${iconSize}px`;
|
||||
imgElement.style.height = `${iconSize}px`;
|
||||
|
||||
// Traitor flashing - smooth speed increase starting at 15s
|
||||
if (icon.id === "traitor") {
|
||||
const remainingTicks = render.player.getTraitorRemainingTicks();
|
||||
// Use precise seconds (not rounded) for smoother transitions, rounded to 0.5s intervals
|
||||
const remainingSeconds = Math.round((remainingTicks / 10) * 2) / 2;
|
||||
|
||||
if (remainingSeconds <= 15) {
|
||||
// Smooth transition: starts at 1s at 15 seconds, decreases to 0.2s at 0 seconds
|
||||
// Using cubic ease-out for slower, more gradual acceleration
|
||||
const clampedSeconds = Math.max(0, Math.min(15, remainingSeconds));
|
||||
const normalizedTime = clampedSeconds / 15; // 0 to 1 (1 = 15s remaining, 0 = 0s remaining)
|
||||
|
||||
// Cubic ease-out: slower acceleration, smoother transition
|
||||
const easedProgress = 1 - Math.pow(1 - normalizedTime, 3);
|
||||
const maxDuration = 1.0; // Slow flash at 15 seconds
|
||||
const minDuration = 0.2; // Fast flash at 0 seconds
|
||||
const duration =
|
||||
minDuration + (maxDuration - minDuration) * easedProgress;
|
||||
const animationDuration = `${duration.toFixed(2)}s`;
|
||||
|
||||
imgElement.style.animation = `traitorFlash ${animationDuration} infinite`;
|
||||
imgElement.style.animationTimingFunction = "ease-in-out";
|
||||
} else {
|
||||
// Don't flash if more than 15 seconds remaining
|
||||
imgElement.style.animation = "none";
|
||||
}
|
||||
}
|
||||
// Traitor flashing - smooth speed increase starting at 15s
|
||||
if (icon.id === TRAITOR_ICON_ID) {
|
||||
this.handleTraitorIconFlashing(render.player, imgElement);
|
||||
}
|
||||
}
|
||||
|
||||
// Position element with scale
|
||||
// Even when positionChanged is false: Scale update otherwise sometimes only happens after seconds which looks buggy.
|
||||
// Don't require nameLocation to be changed: Scale update otherwise sometimes only happens after seconds which looks buggy.
|
||||
// Because of sometimes overlapping delays of 20 ticks for nameLocation() (largestClusterBoundingBox in PlayerExecution)
|
||||
// and the 500ms renderRefreshRate in NameLayer.
|
||||
// and the 500ms renderRefreshRate in here.
|
||||
const scale = Math.min(baseSize * 0.25, 3);
|
||||
render.element.style.transform = `translate(${newX}px, ${newY}px) translate(-50%, -50%) scale(${scale})`;
|
||||
const transform = `translate(${newX}px, ${newY}px) translate(-50%, -50%) scale(${scale})`;
|
||||
if (render.lastTransform !== transform) {
|
||||
render.element.style.transform = transform;
|
||||
render.lastTransform = transform;
|
||||
}
|
||||
}
|
||||
|
||||
private createIconElement(
|
||||
src: string,
|
||||
private handleEmojiIcon(
|
||||
render: RenderInfo,
|
||||
icon: PlayerIconDescriptor,
|
||||
size: number,
|
||||
center: boolean = false,
|
||||
): HTMLImageElement {
|
||||
const icon = document.createElement("img");
|
||||
icon.src = src;
|
||||
icon.style.width = `${size}px`;
|
||||
icon.style.height = `${size}px`;
|
||||
icon.setAttribute("dark-mode", this.userSettings.darkMode().toString());
|
||||
if (center) {
|
||||
icon.style.position = "absolute";
|
||||
icon.style.top = "50%";
|
||||
icon.style.transform = "translateY(-50%)";
|
||||
) {
|
||||
let emojiDiv = render.icons.get(icon.id) as HTMLDivElement | undefined;
|
||||
|
||||
if (!emojiDiv) {
|
||||
emojiDiv = this.emojiTemplate.cloneNode(true) as HTMLDivElement;
|
||||
render.iconsDiv.appendChild(emojiDiv);
|
||||
render.icons.set(icon.id, emojiDiv);
|
||||
}
|
||||
|
||||
emojiDiv.textContent = icon.text ?? "";
|
||||
emojiDiv.style.fontSize = `${size}px`;
|
||||
}
|
||||
|
||||
private handleAllianceIcons(
|
||||
render: RenderInfo,
|
||||
size: number,
|
||||
darkMode: string,
|
||||
) {
|
||||
this.myPlayer ??= this.game.myPlayer();
|
||||
const allianceView = this.myPlayer
|
||||
?.alliances()
|
||||
.find((a) => a.other === render.player.id());
|
||||
|
||||
let fraction = 0;
|
||||
let hasExtensionRequest = false;
|
||||
if (allianceView) {
|
||||
const remaining = Math.max(0, allianceView.expiresAt - this.game.ticks());
|
||||
fraction = Math.max(0, Math.min(1, remaining / this.allianceDuration));
|
||||
hasExtensionRequest = allianceView.hasExtensionRequest;
|
||||
}
|
||||
|
||||
if (!render.allianceIconRefs) {
|
||||
render.allianceIconRefs = createAllianceProgressIconRefs(
|
||||
size,
|
||||
fraction,
|
||||
hasExtensionRequest,
|
||||
darkMode,
|
||||
);
|
||||
|
||||
render.iconsDiv.appendChild(render.allianceIconRefs.wrapper);
|
||||
render.icons.set(ALLIANCE_ICON_ID, render.allianceIconRefs.wrapper);
|
||||
} else {
|
||||
updateAllianceProgressIconRefs(
|
||||
render.allianceIconRefs,
|
||||
size,
|
||||
fraction,
|
||||
hasExtensionRequest,
|
||||
darkMode,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
private handleOtherIcons(
|
||||
render: RenderInfo,
|
||||
icon: PlayerIconDescriptor,
|
||||
size: number,
|
||||
darkMode: string,
|
||||
): HTMLImageElement {
|
||||
let imgElement = render.icons.get(icon.id) as HTMLImageElement | undefined;
|
||||
|
||||
if (!imgElement) {
|
||||
imgElement = icon.center
|
||||
? (this.iconCenterTemplate.cloneNode(true) as HTMLImageElement)
|
||||
: (this.iconTemplate.cloneNode(true) as HTMLImageElement);
|
||||
|
||||
imgElement.src = icon.src ?? "";
|
||||
imgElement.style.width = `${size}px`;
|
||||
imgElement.style.height = `${size}px`;
|
||||
imgElement.setAttribute("dark-mode", darkMode);
|
||||
render.iconsDiv.appendChild(imgElement);
|
||||
render.icons.set(icon.id, imgElement);
|
||||
} else {
|
||||
// Update src if it changed (e.g., nuke red/white or dark-mode icons)
|
||||
if (imgElement.src !== icon.src) {
|
||||
imgElement.src = icon.src ?? "";
|
||||
}
|
||||
|
||||
imgElement.style.width = `${size}px`;
|
||||
imgElement.style.height = `${size}px`;
|
||||
imgElement.setAttribute("dark-mode", darkMode);
|
||||
}
|
||||
return imgElement;
|
||||
}
|
||||
|
||||
private handleTraitorIconFlashing(
|
||||
player: PlayerView,
|
||||
icon: HTMLImageElement,
|
||||
) {
|
||||
const remainingTicks = player.getTraitorRemainingTicks();
|
||||
// Use precise seconds (not rounded) for smoother transitions, rounded to 0.5s intervals
|
||||
const remainingSeconds = Math.round((remainingTicks / 10) * 2) / 2;
|
||||
|
||||
if (remainingSeconds <= 15) {
|
||||
// Smooth transition: starts at 1s at 15 seconds, decreases to 0.2s at 0 seconds
|
||||
// Using cubic ease-out for slower, more gradual acceleration
|
||||
const clampedSeconds = Math.max(0, Math.min(15, remainingSeconds));
|
||||
const normalizedTime = clampedSeconds / 15; // 0 to 1 (1 = 15s remaining, 0 = 0s remaining)
|
||||
|
||||
// Cubic ease-out: slower acceleration, smoother transition
|
||||
const easedProgress = 1 - Math.pow(1 - normalizedTime, 3);
|
||||
const maxDuration = 1.0; // Slow flash at 15 seconds
|
||||
const minDuration = 0.2; // Fast flash at 0 seconds
|
||||
const duration =
|
||||
minDuration + (maxDuration - minDuration) * easedProgress;
|
||||
const animationDuration = `${duration.toFixed(2)}s`;
|
||||
|
||||
icon.style.animation = `traitorFlash ${animationDuration} infinite`;
|
||||
icon.style.animationTimingFunction = "ease-in-out";
|
||||
} else {
|
||||
// Don't flash if more than 15 seconds remaining
|
||||
icon.style.animation = "none";
|
||||
}
|
||||
return icon;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { LitElement, TemplateResult, html } from "lit";
|
||||
import { html, LitElement, TemplateResult } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { assetUrl } from "../../../core/AssetUrls";
|
||||
import { EventBus } from "../../../core/EventBus";
|
||||
@@ -24,7 +24,12 @@ import {
|
||||
renderTroops,
|
||||
translateText,
|
||||
} from "../../Utils";
|
||||
import { getFirstPlacePlayer, getPlayerIcons } from "../PlayerIcons";
|
||||
import {
|
||||
EMOJI_ICON_KIND,
|
||||
getFirstPlacePlayer,
|
||||
getPlayerIcons,
|
||||
IMAGE_ICON_KIND,
|
||||
} from "../PlayerIcons";
|
||||
import { TransformHandler } from "../TransformHandler";
|
||||
import { ImmunityBarVisibleEvent } from "./ImmunityTimer";
|
||||
import { Layer } from "./Layer";
|
||||
@@ -258,6 +263,7 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
|
||||
// Because we already show the alliance icon next to the alliance expiration timer, we don't need to show it a second time in this render
|
||||
includeAllianceIcon: false,
|
||||
firstPlace,
|
||||
alliancesDisabled: this.game.config().disableAlliances(),
|
||||
});
|
||||
|
||||
if (icons.length === 0) {
|
||||
@@ -266,11 +272,11 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
|
||||
|
||||
return html`<span class="flex items-center gap-1 ml-1 shrink-0">
|
||||
${icons.map((icon) =>
|
||||
icon.kind === "emoji" && icon.text
|
||||
icon.kind === EMOJI_ICON_KIND && icon.text
|
||||
? html`<span class="text-sm shrink-0" translate="no"
|
||||
>${icon.text}</span
|
||||
>`
|
||||
: icon.kind === "image" && icon.src
|
||||
: icon.kind === IMAGE_ICON_KIND && icon.src
|
||||
? html`<img src=${icon.src} alt="" class="w-4 h-4 shrink-0" />`
|
||||
: html``,
|
||||
)}
|
||||
|
||||
@@ -575,7 +575,33 @@ export class PlayerView {
|
||||
}
|
||||
|
||||
transitiveTargets(): PlayerView[] {
|
||||
return [...this.targets(), ...this.allies().flatMap((p) => p.targets())];
|
||||
const result: PlayerView[] = [];
|
||||
|
||||
// Add own targets
|
||||
for (const id of this.data.targets) {
|
||||
result.push(this.game.playerBySmallID(id) as PlayerView);
|
||||
}
|
||||
|
||||
// Add allies' targets
|
||||
for (const allyID of this.data.allies) {
|
||||
const ally = this.game.playerBySmallID(allyID) as PlayerView;
|
||||
for (const targetId of ally.data.targets) {
|
||||
result.push(this.game.playerBySmallID(targetId) as PlayerView);
|
||||
}
|
||||
}
|
||||
|
||||
// Add teammates' targets
|
||||
if (this.data.team !== undefined) {
|
||||
for (const p of this.game.playerViews()) {
|
||||
if (p !== this && p.data.team === this.data.team) {
|
||||
for (const targetId of p.data.targets) {
|
||||
result.push(this.game.playerBySmallID(targetId) as PlayerView);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
isTraitor(): boolean {
|
||||
|
||||
Reference in New Issue
Block a user