diff --git a/src/client/graphics/layers/MainRadialMenu.ts b/src/client/graphics/layers/MainRadialMenu.ts index 002b488b0..5bb7fa1f5 100644 --- a/src/client/graphics/layers/MainRadialMenu.ts +++ b/src/client/graphics/layers/MainRadialMenu.ts @@ -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); diff --git a/src/client/graphics/layers/PlayerActionHandler.ts b/src/client/graphics/layers/PlayerActionHandler.ts index 2ea2e8970..672cc2baf 100644 --- a/src/client/graphics/layers/PlayerActionHandler.ts +++ b/src/client/graphics/layers/PlayerActionHandler.ts @@ -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") { diff --git a/src/client/graphics/layers/PlayerPanel.ts b/src/client/graphics/layers/PlayerPanel.ts index 560c1fe22..483054666 100644 --- a/src/client/graphics/layers/PlayerPanel.ts +++ b/src/client/graphics/layers/PlayerPanel.ts @@ -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"; } diff --git a/src/client/graphics/layers/RadialMenu.ts b/src/client/graphics/layers/RadialMenu.ts index c868000aa..acacc6e15 100644 --- a/src/client/graphics/layers/RadialMenu.ts +++ b/src/client/graphics/layers/RadialMenu.ts @@ -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); } diff --git a/src/client/graphics/layers/RadialMenuElements.ts b/src/client/graphics/layers/RadialMenuElements.ts index 7eeb88f29..1b0f3cd21 100644 --- a/src/client/graphics/layers/RadialMenuElements.ts +++ b/src/client/graphics/layers/RadialMenuElements.ts @@ -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);