Feat: Quick donate troops via radial menu (#2708)

If this PR fixes an issue, link it below. If not, delete these two
lines.
Resolves #2705 

## Description:

Introduces a quick donation feature in games where the `canDonateTroops`
option is enabled. It works by converting the center button in the
radial menu from a disabled attack button to a troop donate button when
the player right clicked on is friendly (teammate or ally).

Also adds donate gold button to Attack slot on radial menu when right
clicking friendly troops.

### Video Example


https://github.com/user-attachments/assets/d9b2c3f7-b6c0-482a-9dbd-b3841676cbe5

### New Icon
<img width="1310" height="931" alt="image"
src="https://github.com/user-attachments/assets/85225858-6971-470d-92f6-db68a5d05bb2"
/>

### Donate Gold


https://github.com/user-attachments/assets/b116bc06-d53d-47c7-9504-871eada6a21e


## 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:

bijx
This commit is contained in:
bijx
2025-12-27 16:05:02 -05:00
committed by GitHub
parent 7339c968c9
commit 7284ded290
5 changed files with 147 additions and 18 deletions
@@ -20,6 +20,7 @@ import {
rootMenuElement,
} from "./RadialMenuElements";
import donateTroopIcon from "../../../../resources/images/DonateTroopIconWhite.svg";
import swordIcon from "../../../../resources/images/SwordIconWhite.svg";
import { ContextMenuEvent } from "../../InputHandler";
@@ -127,10 +128,22 @@ export class MainRadialMenu extends LitElement implements Layer {
playerActionHandler: this.playerActionHandler,
playerPanel: this.playerPanel,
chatIntegration: this.chatIntegration,
uiState: this.uiState,
closeMenu: () => this.closeMenu(),
eventBus: this.eventBus,
};
const isFriendlyTarget =
recipient !== null && recipient.isFriendly(myPlayer);
this.radialMenu.setCenterButtonAppearance(
isFriendlyTarget ? donateTroopIcon : swordIcon,
isFriendlyTarget ? "#34D399" : "#2c3e50",
isFriendlyTarget
? this.radialMenu.getDefaultCenterIconSize() * 0.75
: this.radialMenu.getDefaultCenterIconSize(),
);
this.radialMenu.setParams(params);
if (screenX !== null && screenY !== null) {
this.radialMenu.showRadialMenu(screenX, screenY);
@@ -84,8 +84,12 @@ export class PlayerActionHandler {
this.eventBus.emit(new SendDonateGoldIntentEvent(recipient, null));
}
handleDonateTroops(recipient: PlayerView) {
this.eventBus.emit(new SendDonateTroopsIntentEvent(recipient, null));
handleDonateTroops(recipient: PlayerView, troops?: number) {
const amount = troops ?? null;
if (amount !== null && amount <= 0) {
return;
}
this.eventBus.emit(new SendDonateTroopsIntentEvent(recipient, amount));
}
handleEmbargo(recipient: PlayerView, action: "start" | "stop") {
+24 -1
View File
@@ -62,6 +62,7 @@ export class PlayerPanel extends LitElement implements Layer {
@state() private allianceExpiryText: string | null = null;
@state() private allianceExpirySeconds: number | null = null;
@state() private otherProfile: PlayerProfile | null = null;
@state() private suppressNextHide: boolean = false;
private ctModal: ChatModal;
@@ -78,7 +79,13 @@ export class PlayerPanel extends LitElement implements Layer {
});
}
init() {
this.eventBus.on(MouseUpEvent, () => this.hide());
this.eventBus.on(MouseUpEvent, () => {
if (this.suppressNextHide) {
this.suppressNextHide = false;
return;
}
this.hide();
});
this.ctModal = document.querySelector("chat-modal") as ChatModal;
if (!this.ctModal) {
@@ -131,6 +138,20 @@ export class PlayerPanel extends LitElement implements Layer {
this.requestUpdate();
}
public openSendGoldModal(
actions: PlayerActions,
tile: TileRef,
target: PlayerView,
) {
this.suppressNextHide = true;
this.actions = actions;
this.tile = tile;
this.sendTarget = target;
this.sendMode = "gold";
this.isVisible = true;
this.requestUpdate();
}
public hide() {
this.isVisible = false;
this.sendMode = "none";
@@ -164,11 +185,13 @@ export class PlayerPanel extends LitElement implements Layer {
}
private openSendTroops(target: PlayerView) {
this.suppressNextHide = true;
this.sendTarget = target;
this.sendMode = "troops";
}
private openSendGold(target: PlayerView) {
this.suppressNextHide = true;
this.sendTarget = target;
this.sendMode = "gold";
}
+48 -10
View File
@@ -78,6 +78,9 @@ export class RadialMenu implements Layer {
private backButtonHoverTimeout: number | null = null;
private navigationInProgress: boolean = false;
private originalCenterButtonIcon: string = "";
private readonly defaultCenterButtonColor = "#2c3e50";
private centerButtonColor: string;
private centerButtonIconSize: number;
private params: MenuElementParams | null = null;
@@ -103,6 +106,8 @@ export class RadialMenu implements Layer {
};
this.originalCenterButtonIcon = this.config.centerButtonIcon;
this.backIconSize = this.config.centerIconSize * 0.8;
this.centerButtonColor = this.defaultCenterButtonColor;
this.centerButtonIconSize = this.config.centerIconSize;
}
init() {
@@ -193,17 +198,17 @@ export class RadialMenu implements Layer {
.append("circle")
.attr("class", "center-button-visible")
.attr("r", this.config.centerButtonSize)
.attr("fill", "#2c3e50")
.attr("fill", this.centerButtonColor)
.style("pointer-events", "none");
centerButton
.append("image")
.attr("class", "center-button-icon")
.attr("xlink:href", this.config.centerButtonIcon)
.attr("width", this.config.centerIconSize)
.attr("height", this.config.centerIconSize)
.attr("x", -this.config.centerIconSize / 2)
.attr("y", -this.config.centerIconSize / 2)
.attr("width", this.centerButtonIconSize)
.attr("height", this.centerButtonIconSize)
.attr("x", -this.centerButtonIconSize / 2)
.attr("y", -this.centerButtonIconSize / 2)
.style("pointer-events", "none");
}
@@ -888,10 +893,10 @@ export class RadialMenu implements Layer {
const iconImg = this.menuElement.select(".center-button-icon");
iconImg
.attr("xlink:href", this.originalCenterButtonIcon)
.attr("width", this.config.centerIconSize)
.attr("height", this.config.centerIconSize)
.attr("x", -this.config.centerIconSize / 2)
.attr("y", -this.config.centerIconSize / 2);
.attr("width", this.centerButtonIconSize)
.attr("height", this.centerButtonIconSize)
.attr("x", -this.centerButtonIconSize / 2)
.attr("y", -this.centerButtonIconSize / 2);
}
const centerButton = this.menuElement.select(".center-button");
@@ -904,7 +909,7 @@ export class RadialMenu implements Layer {
centerButton
.select(".center-button-visible")
.attr("fill", enabled ? "#2c3e50" : "#999999");
.attr("fill", enabled ? this.centerButtonColor : "#999999");
centerButton
.select(".center-button-icon")
@@ -953,6 +958,39 @@ export class RadialMenu implements Layer {
this.params = params;
}
public getDefaultCenterIconSize(): number {
return this.config.centerIconSize;
}
public setCenterButtonAppearance(
icon: string,
color?: string,
iconSize?: number,
) {
this.originalCenterButtonIcon = icon;
this.centerButtonColor = color ?? this.defaultCenterButtonColor;
this.centerButtonIconSize = iconSize ?? this.config.centerIconSize;
if (!this.menuElement) return;
const iconImg = this.menuElement.select(".center-button-icon");
iconImg
.attr("xlink:href", icon)
.attr("width", this.centerButtonIconSize)
.attr("height", this.centerButtonIconSize)
.attr("x", -this.centerButtonIconSize / 2)
.attr("y", -this.centerButtonIconSize / 2);
this.menuElement
.select(".center-button-visible")
.attr(
"fill",
this.isCenterButtonEnabled() ? this.centerButtonColor : "#999999",
);
this.updateCenterButtonState(this.centerButtonState);
}
private findMenuItem(id: string): MenuElement | undefined {
return this.currentMenuItems.find((item) => item.id === id);
}
@@ -4,6 +4,7 @@ import { TileRef } from "../../../core/game/GameMap";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { Emoji, flattenedEmojiTable } from "../../../core/Util";
import { renderNumber, translateText } from "../../Utils";
import { UIState } from "../UIState";
import { BuildItemDisplay, BuildMenu, flattenedBuildTable } from "./BuildMenu";
import { ChatIntegration } from "./ChatIntegration";
import { EmojiTable } from "./EmojiTable";
@@ -37,6 +38,7 @@ export interface MenuElementParams {
playerPanel: PlayerPanel;
chatIntegration: ChatIntegration;
eventBus: EventBus;
uiState?: UIState;
closeMenu: () => void;
}
@@ -107,6 +109,14 @@ export enum Slot {
Delete = "delete",
}
function isFriendlyTarget(params: MenuElementParams): boolean {
const selectedPlayer = params.selected;
if (selectedPlayer === null) return false;
const isFriendly = (selectedPlayer as PlayerView).isFriendly;
if (typeof isFriendly !== "function") return false;
return isFriendly.call(selectedPlayer, params.myPlayer);
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const infoChatElement: MenuElement = {
id: "info_chat",
@@ -423,6 +433,24 @@ export const attackMenuElement: MenuElement = {
},
};
const donateGoldRadialElement: MenuElement = {
id: Slot.Attack,
name: "radial_donate_gold",
disabled: (params: MenuElementParams) =>
params.game.inSpawnPhase() ||
!params.playerActions?.interaction?.canDonateGold,
icon: donateGoldIcon,
color: "#EAB308",
action: (params: MenuElementParams) => {
if (!params.selected) return;
params.playerPanel.openSendGoldModal(
params.playerActions,
params.tile,
params.selected,
);
},
};
export const deleteUnitElement: MenuElement = {
id: Slot.Delete,
name: "delete",
@@ -549,16 +577,33 @@ export const centerButtonElement: CenterButtonElement = {
}
return false;
}
if (isFriendlyTarget(params)) {
return !params.playerActions.interaction?.canDonateTroops;
}
return !params.playerActions.canAttack;
},
action: (params: MenuElementParams) => {
if (params.game.inSpawnPhase()) {
params.playerActionHandler.handleSpawn(params.tile);
} else {
params.playerActionHandler.handleAttack(
params.myPlayer,
params.selected?.id() ?? null,
);
if (isFriendlyTarget(params)) {
const selectedPlayer = params.selected as PlayerView;
const ratio = params.uiState?.attackRatio ?? 1;
const troopsToDonate = Math.floor(ratio * params.myPlayer.troops());
if (troopsToDonate > 0) {
params.playerActionHandler.handleDonateTroops(
selectedPlayer,
troopsToDonate,
);
}
} else {
params.playerActionHandler.handleAttack(
params.myPlayer,
params.selected?.id() ?? null,
);
}
}
params.closeMenu();
},
@@ -585,7 +630,13 @@ export const rootMenuElement: MenuElement = {
infoMenuElement,
...(isOwnTerritory
? [deleteUnitElement, ally, buildMenuElement]
: [boatMenuElement, ally, attackMenuElement]),
: [
boatMenuElement,
ally,
isFriendlyTarget(params)
? donateGoldRadialElement
: attackMenuElement,
]),
];
return menuItems.filter((item): item is MenuElement => item !== null);