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 Screenshot 2025-11-18 205205 Screenshot 2025-11-18 205532 ## 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(); + }); +});