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


<img width="1132" height="631" alt="Screenshot 2025-11-18 205205"
src="https://github.com/user-attachments/assets/4af71ddc-f847-4460-9046-167275efc773"
/>
<img width="1387" height="792" alt="Screenshot 2025-11-18 205532"
src="https://github.com/user-attachments/assets/9dd0e018-323f-4de1-bae8-2633c09fe867"
/>

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

---------

Co-authored-by: Evan <evanpelle@gmail.com>
This commit is contained in:
Hauke12345
2025-11-19 22:32:01 +02:00
committed by GitHub
parent 9840306753
commit dcf5d1b103
7 changed files with 350 additions and 0 deletions
+66
View File
@@ -0,0 +1,66 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="834"
height="834"
shape-rendering="geometricPrecision"
image-rendering="optimizeQuality"
fill-rule="evenodd"
version="1.1"
id="svg6"
sodipodi:docname="AllianceIconFaded.svg"
inkscape:version="1.4 (86a8ad7, 2024-10-11)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs6" />
<sodipodi:namedview
id="namedview6"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="0.5"
inkscape:cx="417"
inkscape:cy="257"
inkscape:window-width="1920"
inkscape:window-height="1009"
inkscape:window-x="-8"
inkscape:window-y="-8"
inkscape:window-maximized="1"
inkscape:current-layer="svg6" />
<path
d="M-.5 397.5v-5l136-239.5c1.667-.667 3.333-.667 5 0 20.752 12.458 41.252 25.291 61.5 38.5.638 1.109 1.138 2.275 1.5 3.5l-135 240.5c-3.0617 2.497-6.0617 2.33-9-.5l-60-37.5zm834-5v5c-21.238 14.207-43.071 27.541-65.5 40-1.285-.45-2.452-1.117-3.5-2L629.5 195c.583-2.499 1.916-4.499 4-6l59-36c1.667-.667 3.333-.667 5 0l136 239.5z"
fill="#278f06"
id="path1"
style="fill:#88da6f;fill-opacity:1;stroke:#000000;stroke-opacity:1;stroke-width:5;stroke-dasharray:none" />
<path
d="M432.5 217.5c22.065-.582 44.065.251 66 2.5l85 17c5.667.667 11.333.667 17 0 9.847-1.549 19.514-3.549 29-6L734 417.5c-10.863 11.197-22.029 22.03-33.5 32.5-7.415 5.46-15.248 10.293-23.5 14.5-79.554-66.724-165.721-123.891-258.5-171.5-4.198-2.514-8.698-4.347-13.5-5.5-14.009 3.961-27.842 8.461-41.5 13.5-13.712 20.712-31.045 37.712-52 51-13.542 7.51-28.042 11.677-43.5 12.5-3.583-.558-7.083-1.391-10.5-2.5-2.082-2.409-3.082-5.243-3-8.5.561-6.244 2.061-12.244 4.5-18 21.319-34.477 44.486-67.644 69.5-99.5 11.022-5.673 22.688-9.34 35-11 23.037-3.569 46.037-6.069 69-7.5zm-231 15c10.445 5.724 21.112 11.224 32 16.5 18.501 4.528 37.168 8.195 56 11-17.45 23.055-33.283 47.222-47.5 72.5-11.16 34.914 1.34 50.747 37.5 47.5 17.364-2.453 33.364-8.453 48-18 18.377-12.71 33.71-28.376 46-47 10.749-4.255 21.749-7.255 33-9 66.843 32.897 129.843 71.564 189 116 24.877 18.276 49.21 37.276 73 57 17.316 16.374 17.316 32.708 0 49-10.221 5.794-20.888 6.794-32 3L502 451.5c-9.201 3.025-10.701 8.191-4.5 15.5L622 542.5c3.242 24.405-7.258 37.905-31.5 40.5-4.333.667-8.667.667-13 0l-109-63c-8.528-.156-11.695 4.011-9.5 12.5l107 63c1.107 12.35-2.726 22.85-11.5 31.5-3.448 1.927-7.115 3.261-11 4-8.72.839-17.387.505-26-1l-85-41c-9.548-.747-12.715 3.42-9.5 12.5l2.5 2.5 82 40c-16.901 24.862-40.068 33.362-69.5 25.5-18.921-3.688-37.421-8.855-55.5-15.5 16.937-19.691 18.437-40.524 4.5-62.5-10.469-13.324-23.969-19.657-40.5-19 1.841-25.828-8.825-44.328-32-55.5-5.764-2.214-11.597-3.047-17.5-2.5 2.293-30.025-10.873-49.525-39.5-58.5-15.974-2.677-29.141 2.157-39.5 14.5-2.973 4.474-6.14 8.807-9.5 13-13.651-30.999-36.318-40.832-68-29.5l-11 9c-10.856-14.036-22.19-27.702-34-41l106-189.5z"
fill="#278f04"
id="path2"
style="fill:#88da6f;fill-opacity:1;stroke:#000000;stroke-opacity:1;stroke-width:5;stroke-dasharray:none" />
<path
fill="#28900b"
d="M158.5 464.5c17.008.509 28.175 8.842 33.5 25 1.31 7.371.31 14.371-3 21-7.473 10.107-15.306 19.94-23.5 29.5-18.101 6.072-31.601.572-40.5-16.5-3.601-8.846-3.267-17.512 1-26 7.473-10.107 15.306-19.94 23.5-29.5 3.071-1.296 6.071-2.462 9-3.5z"
id="path3"
style="fill:#88da6f;fill-opacity:1;stroke:#000000;stroke-opacity:1;stroke-width:5;stroke-dasharray:none" />
<path
fill="#289009"
d="M246.5 471.5c24.731 2.84 35.564 16.507 32.5 41l-58 81c-10.712 9.718-22.212 10.552-34.5 2.5-12.299-9.422-16.465-21.588-12.5-36.5 19.728-28.396 40.228-56.229 61.5-83.5 3.553-2.121 7.22-3.621 11-4.5z"
id="path4"
style="fill:#88da6f;fill-opacity:1;stroke:#000000;stroke-opacity:1;stroke-width:5;stroke-dasharray:none" />
<path
fill="#28900b"
d="M293.5 530.5c18.901-.264 30.734 8.736 35.5 27 1.405 7.217.071 13.884-4 20L279.5 639c-19.61 8.459-34.11 2.959-43.5-16.5-3.226-7.013-3.226-14.013 0-21l47-65c3.101-2.852 6.601-4.852 10.5-6z"
id="path5"
style="fill:#88da6f;fill-opacity:1;stroke:#000000;stroke-opacity:1;stroke-width:5;stroke-dasharray:none" />
<path
fill="#28900c"
d="M344.5 588.5c23.569 1.069 35.402 13.402 35.5 37-.856 4.044-2.522 7.711-5 11-8.91 11.488-17.577 23.155-26 35-9.872 10.173-21.039 11.673-33.5 4.5-13.736-9.632-18.236-22.465-13.5-38.5 11.116-15.451 22.616-30.617 34.5-45.5 2.809-1.093 5.476-2.26 8-3.5z"
id="path6"
style="fill:#88da6f;fill-opacity:1;stroke:#000000;stroke-opacity:1;stroke-width:5;stroke-dasharray:none" />
</svg>

After

Width:  |  Height:  |  Size: 4.8 KiB

+68
View File
@@ -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)`;
}
+67
View File
@@ -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;
+1
View File
@@ -179,6 +179,7 @@ export interface AllianceView {
other: PlayerID;
createdAt: Tick;
expiresAt: Tick;
hasExtensionRequest: boolean;
}
export interface AllianceRequestUpdate {
+4
View File
@@ -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(),
+41
View File
@@ -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)
});
});
});
+103
View File
@@ -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>): 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();
});
});