From dcf5d1b1039d4e5133a0071047a66d99616d4fc8 Mon Sep 17 00:00:00 2001
From: Hauke12345 <98530365+Hauke12345@users.noreply.github.com>
Date: Wed, 19 Nov 2025 22:32:01 +0200
Subject: [PATCH] Fading handshake (#2474)
## Description:
Add dynamic alliance icon with time-based fill and extension request
indicator
- Implement bottom-up green fill on alliance icon proportional to
remaining time
- Use AllianceIconFaded.svg as base layer with green overlay clipped
from top
- Add 20-82.40% clip range to account for icon vertical offset
## 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:
hauke4707
---------
Co-authored-by: Evan
---
resources/images/AllianceIconFaded.svg | 66 +++++++++++++++
src/client/graphics/PlayerIcons.ts | 68 ++++++++++++++++
src/client/graphics/layers/NameLayer.ts | 67 +++++++++++++++
src/core/game/GameUpdates.ts | 1 +
src/core/game/PlayerImpl.ts | 4 +
tests/NameLayer.test.ts | 41 ++++++++++
tests/radialMenuElements.spec.ts | 103 ++++++++++++++++++++++++
7 files changed, 350 insertions(+)
create mode 100644 resources/images/AllianceIconFaded.svg
create mode 100644 tests/NameLayer.test.ts
create mode 100644 tests/radialMenuElements.spec.ts
diff --git a/resources/images/AllianceIconFaded.svg b/resources/images/AllianceIconFaded.svg
new file mode 100644
index 000000000..d218b9898
--- /dev/null
+++ b/resources/images/AllianceIconFaded.svg
@@ -0,0 +1,66 @@
+
+
diff --git a/src/client/graphics/PlayerIcons.ts b/src/client/graphics/PlayerIcons.ts
index 6f1602578..928e54351 100644
--- a/src/client/graphics/PlayerIcons.ts
+++ b/src/client/graphics/PlayerIcons.ts
@@ -1,4 +1,5 @@
import allianceIcon from "../../../resources/images/AllianceIcon.svg";
+import allianceIconFaded from "../../../resources/images/AllianceIconFaded.svg";
import allianceRequestBlackIcon from "../../../resources/images/AllianceRequestBlackIcon.svg";
import allianceRequestWhiteIcon from "../../../resources/images/AllianceRequestWhiteIcon.svg";
import crownIcon from "../../../resources/images/CrownIcon.svg";
@@ -7,6 +8,7 @@ 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 questionMarkIcon from "../../../resources/images/QuestionMarkIcon.svg";
import targetIcon from "../../../resources/images/TargetIcon.svg";
import traitorIcon from "../../../resources/images/TraitorIcon.svg";
import { AllPlayers, nukeTypes } from "../../core/game/Game";
@@ -152,3 +154,69 @@ export function getPlayerIcons(
return icons;
}
+
+export function createAllianceProgressIcon(
+ size: number,
+ fraction: number,
+ hasExtensionRequest: boolean,
+ darkMode: boolean,
+): HTMLDivElement {
+ // Wrapper
+ const wrapper = document.createElement("div");
+ wrapper.setAttribute("data-icon", "alliance");
+ wrapper.setAttribute("dark-mode", darkMode.toString());
+ wrapper.style.position = "relative";
+ wrapper.style.width = `${size}px`;
+ wrapper.style.height = `${size}px`;
+ wrapper.style.display = "inline-block";
+
+ // Base faded icon (full)
+ const base = document.createElement("img");
+ base.src = allianceIconFaded;
+ base.style.width = `${size}px`;
+ base.style.height = `${size}px`;
+ base.style.display = "block";
+ base.setAttribute("dark-mode", darkMode.toString());
+ wrapper.appendChild(base);
+
+ // 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%";
+ overlay.style.clipPath = computeAllianceClipPath(fraction);
+
+ const colored = document.createElement("img");
+ colored.src = allianceIcon; // green icon
+ 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);
+
+ // 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";
+ 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);
+
+ return wrapper;
+}
+
+export function computeAllianceClipPath(fraction: number): string {
+ const topCut = 20 + (1 - fraction) * 80 * 0.78; // min 20%, max 82.40%
+ return `inset(${topCut.toFixed(2)}% -2px 0 -2px)`;
+}
diff --git a/src/client/graphics/layers/NameLayer.ts b/src/client/graphics/layers/NameLayer.ts
index 649315cf6..f87ccd0a1 100644
--- a/src/client/graphics/layers/NameLayer.ts
+++ b/src/client/graphics/layers/NameLayer.ts
@@ -9,6 +9,8 @@ import { UserSettings } from "../../../core/game/UserSettings";
import { AlternateViewEvent } from "../../InputHandler";
import { createCanvas, renderNumber, renderTroops } from "../../Utils";
import {
+ computeAllianceClipPath,
+ createAllianceProgressIcon,
getFirstPlacePlayer,
getPlayerIcons,
PlayerIconId,
@@ -51,6 +53,8 @@ export class NameLayer implements Layer {
) {
this.shieldIconImage = new Image();
this.shieldIconImage.src = shieldIcon;
+ this.shieldIconImage = new Image();
+ this.shieldIconImage.src = shieldIcon;
}
resizeCanvas() {
@@ -399,6 +403,69 @@ export class NameLayer implements Layer {
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`;
+
+ 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;
diff --git a/src/core/game/GameUpdates.ts b/src/core/game/GameUpdates.ts
index 706be36d8..455ef1ac1 100644
--- a/src/core/game/GameUpdates.ts
+++ b/src/core/game/GameUpdates.ts
@@ -179,6 +179,7 @@ export interface AllianceView {
other: PlayerID;
createdAt: Tick;
expiresAt: Tick;
+ hasExtensionRequest: boolean;
}
export interface AllianceRequestUpdate {
diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts
index f85cc7aab..2dbe79784 100644
--- a/src/core/game/PlayerImpl.ts
+++ b/src/core/game/PlayerImpl.ts
@@ -171,6 +171,10 @@ export class PlayerImpl implements Player {
other: a.other(this).id(),
createdAt: a.createdAt(),
expiresAt: a.expiresAt(),
+ hasExtensionRequest:
+ a.expiresAt() <=
+ this.mg.ticks() +
+ this.mg.config().allianceExtensionPromptOffset(),
}) satisfies AllianceView,
),
hasSpawned: this.hasSpawned(),
diff --git a/tests/NameLayer.test.ts b/tests/NameLayer.test.ts
new file mode 100644
index 000000000..2337e78a1
--- /dev/null
+++ b/tests/NameLayer.test.ts
@@ -0,0 +1,41 @@
+import { computeAllianceClipPath } from "../src/client/graphics/PlayerIcons";
+
+describe("PlayerIcons", () => {
+ describe("computeAllianceClipPath", () => {
+ test("returns full visibility (20% top cut) when alliance time is at 100%", () => {
+ const result = computeAllianceClipPath(1.0);
+ // topCut = 20 + (1 - 1.0) * 80 * 0.78 = 20 + 0 = 20.00
+ expect(result).toBe("inset(20.00% -2px 0 -2px)");
+ });
+
+ test("returns maximum cut (82.40% top cut) when alliance time is at 0%", () => {
+ const result = computeAllianceClipPath(0.0);
+ // topCut = 20 + (1 - 0.0) * 80 * 0.78 = 20 + 62.4 = 82.40
+ expect(result).toBe("inset(82.40% -2px 0 -2px)");
+ });
+
+ test("returns 51.20% top cut when alliance time is at 50%", () => {
+ const result = computeAllianceClipPath(0.5);
+ // topCut = 20 + (1 - 0.5) * 80 * 0.78 = 20 + 31.2 = 51.20
+ expect(result).toBe("inset(51.20% -2px 0 -2px)");
+ });
+
+ test("returns 27.80% top cut when alliance time is at 87.5%", () => {
+ const result = computeAllianceClipPath(0.875);
+ // topCut = 20 + (1 - 0.875) * 80 * 0.78 = 20 + 7.8 = 27.80
+ expect(result).toBe("inset(27.80% -2px 0 -2px)");
+ });
+
+ test("returns 74.60% top cut when alliance time is at 12.5%", () => {
+ const result = computeAllianceClipPath(0.125);
+ // topCut = 20 + (1 - 0.125) * 80 * 0.78 = 20 + 54.6 = 74.60
+ expect(result).toBe("inset(74.60% -2px 0 -2px)");
+ });
+
+ test("includes -2px horizontal overscan to prevent subpixel gaps", () => {
+ const result = computeAllianceClipPath(0.5);
+ expect(result).toContain("-2px");
+ expect(result.match(/-2px/g)).toHaveLength(2); // Should appear twice (left and right)
+ });
+ });
+});
diff --git a/tests/radialMenuElements.spec.ts b/tests/radialMenuElements.spec.ts
new file mode 100644
index 000000000..a14437195
--- /dev/null
+++ b/tests/radialMenuElements.spec.ts
@@ -0,0 +1,103 @@
+// Mock BuildMenu to avoid importing lit and other ESM-heavy deps in this unit test
+jest.mock(
+ "../src/client/graphics/layers/BuildMenu",
+ () => ({
+ BuildMenu: class {},
+ flattenedBuildTable: [],
+ }),
+ { virtual: true },
+);
+
+// Mock Utils to avoid touching DOM (document) during tests
+jest.mock("../src/client/Utils", () => ({
+ translateText: (k: string) => k,
+ getSvgAspectRatio: async () => 1,
+}));
+
+import {
+ COLORS,
+ rootMenuElement,
+ type MenuElementParams,
+} from "../src/client/graphics/layers/RadialMenuElements";
+
+// Minimal stubs to satisfy types used in rootMenuElement.subMenu and allyBreak actions
+const makePlayer = (id: string) =>
+ ({
+ id: () => id,
+ isAlliedWith: (other: any) =>
+ other && typeof other.id === "function" && other.id() !== id
+ ? true
+ : true,
+ }) as unknown as import("../src/core/game/GameView").PlayerView;
+
+const makeParams = (opts?: Partial): MenuElementParams => {
+ const myPlayer = (opts?.myPlayer as any) ?? makePlayer("p1");
+ const selected = (opts?.selected as any) ?? makePlayer("p2");
+ return {
+ myPlayer,
+ selected,
+ tile: {} as any,
+ playerActions: {
+ canAttack: true,
+ interaction: {
+ canBreakAlliance: true,
+ canSendAllianceRequest: false,
+ canEmbargo: false,
+ },
+ } as any,
+ game: {
+ inSpawnPhase: () => false,
+ owner: () => ({ isPlayer: () => false }),
+ } as any,
+ buildMenu: {
+ canBuildOrUpgrade: () => false,
+ cost: () => 0,
+ count: () => 0,
+ sendBuildOrUpgrade: () => {},
+ } as any,
+ emojiTable: {} as any,
+ playerActionHandler: {
+ handleBreakAlliance: jest.fn(),
+ handleEmbargo: jest.fn(),
+ handleDonateGold: jest.fn(),
+ handleDonateTroops: jest.fn(),
+ handleTargetPlayer: jest.fn(),
+ } as any,
+ playerPanel: {
+ show: jest.fn(),
+ } as any,
+ chatIntegration: {
+ createQuickChatMenu: jest.fn(() => []),
+ } as any,
+ eventBus: {} as any,
+ closeMenu: jest.fn(),
+ };
+};
+
+const findAllyBreak = (items: any[]) =>
+ items.find((i) => i && i.id === "ally_break");
+
+describe("RadialMenuElements ally break", () => {
+ test("shows break option with correct color when allied", () => {
+ const params = makeParams();
+ const items = rootMenuElement.subMenu!(params);
+ const ally = findAllyBreak(items)!;
+ expect(ally).toBeTruthy();
+ expect(ally.name).toBe("break");
+ expect(ally.color).toBe(COLORS.breakAlly);
+ });
+
+ test("action calls handleBreakAlliance and closes menu", () => {
+ const params = makeParams();
+ const items = rootMenuElement.subMenu!(params);
+ const ally = findAllyBreak(items)!;
+
+ ally.action!(params);
+
+ expect(params.playerActionHandler.handleBreakAlliance).toHaveBeenCalledWith(
+ params.myPlayer,
+ params.selected,
+ );
+ expect(params.closeMenu).toHaveBeenCalled();
+ });
+});