Implement send resources modal (#2146)

## Description:
Fixes https://github.com/openfrontio/OpenFrontIO/issues/2015
Implemented a new interactive modal component for sending troops/gold
between players, replacing the previous automatic troop donation system.

Screenshots
<img width="388" height="569" alt="s1"
src="https://github.com/user-attachments/assets/b5b5cfce-972e-474c-848a-4ea0dc7dde8f"
/>

<img width="383" height="534" alt="s2"
src="https://github.com/user-attachments/assets/4c467fa2-7631-4e7b-ab17-898778b08ea6"
/>

<img width="377" height="548" alt="s3"
src="https://github.com/user-attachments/assets/9359ee06-18af-48ea-a47c-586198e26f57"
/>

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

abodcraft1

---------

Co-authored-by: evanpelle <evanpelle@gmail.com>
This commit is contained in:
Abdallah Bahrawi
2025-10-09 23:19:05 +03:00
committed by GitHub
parent d070c5810c
commit 0076996dce
4 changed files with 704 additions and 54 deletions
+77 -46
View File
@@ -28,8 +28,6 @@ import { CloseViewEvent, MouseUpEvent } from "../../InputHandler";
import {
SendAllianceRequestIntentEvent,
SendBreakAllianceIntentEvent,
SendDonateGoldIntentEvent,
SendDonateTroopsIntentEvent,
SendEmbargoIntentEvent,
SendEmojiIntentEvent,
SendTargetPlayerIntentEvent,
@@ -44,6 +42,7 @@ import { UIState } from "../UIState";
import { ChatModal } from "./ChatModal";
import { EmojiTable } from "./EmojiTable";
import { Layer } from "./Layer";
import "./SendResourceModal";
@customElement("player-panel")
export class PlayerPanel extends LitElement implements Layer {
@@ -51,21 +50,17 @@ export class PlayerPanel extends LitElement implements Layer {
public eventBus: EventBus;
public emojiTable: EmojiTable;
public uiState: UIState;
private actions: PlayerActions | null = null;
private tile: TileRef | null = null;
private _profileForPlayerId: number | null = null;
@state()
public isVisible: boolean = false;
@state()
private allianceExpiryText: string | null = null;
@state()
private allianceExpirySeconds: number | null = null;
@state()
private otherProfile: PlayerProfile | null = null;
@state() private sendTarget: PlayerView | null = null;
@state() private sendMode: "troops" | "gold" | "none" = "none";
@state() public isVisible: boolean = false;
@state() private allianceExpiryText: string | null = null;
@state() private allianceExpirySeconds: number | null = null;
@state() private otherProfile: PlayerProfile | null = null;
private ctModal: ChatModal;
@@ -138,6 +133,8 @@ export class PlayerPanel extends LitElement implements Layer {
public hide() {
this.isVisible = false;
this.sendMode = "none";
this.sendTarget = null;
this.requestUpdate();
}
@@ -166,19 +163,23 @@ export class PlayerPanel extends LitElement implements Layer {
this.hide();
}
private openSendTroops(target: PlayerView) {
this.sendTarget = target;
this.sendMode = "troops";
}
private openSendGold(target: PlayerView) {
this.sendTarget = target;
this.sendMode = "gold";
}
private handleDonateTroopClick(
e: Event,
myPlayer: PlayerView,
other: PlayerView,
) {
e.stopPropagation();
this.eventBus.emit(
new SendDonateTroopsIntentEvent(
other,
myPlayer.troops() * this.uiState.attackRatio,
),
);
this.hide();
this.openSendTroops(other);
}
private handleDonateGoldClick(
@@ -187,10 +188,20 @@ export class PlayerPanel extends LitElement implements Layer {
other: PlayerView,
) {
e.stopPropagation();
this.eventBus.emit(new SendDonateGoldIntentEvent(other, null));
this.hide();
this.openSendGold(other);
}
private closeSend = () => {
this.sendTarget = null;
};
private confirmSend = (
e: CustomEvent<{ amount: number; closePanel?: boolean }>,
) => {
this.closeSend();
if (e.detail?.closePanel) this.hide();
};
private handleEmbargoClick(
e: Event,
myPlayer: PlayerView,
@@ -312,10 +323,11 @@ export class PlayerPanel extends LitElement implements Layer {
}
private getExpiryColorClass(seconds: number | null): string {
if (seconds === null) return "text-white";
if (seconds <= 30) return "text-red-400";
if (seconds <= 60) return "text-yellow-400";
return "text-emerald-400";
if (seconds === null) return "text-white"; // Default color
if (seconds <= 30) return "text-red-400"; // Last 30 seconds: Red
if (seconds <= 60) return "text-yellow-400"; // Last 60 seconds: Yellow
return "text-emerald-400"; // More than 60 seconds: Green
}
private getTraitorRemainingSeconds(player: PlayerView): number | null {
@@ -433,31 +445,27 @@ export class PlayerPanel extends LitElement implements Layer {
return html`
<div class="mb-1 flex justify-between gap-2">
<div
class="inline-flex items-center gap-1.5 rounded-full bg-zinc-800 px-2.5 py-1
text-lg font-semibold text-zinc-100"
class="inline-flex items-center gap-1.5 rounded-full bg-white/[0.04] px-2.5 py-1
text-base font-semibold text-zinc-200"
>
<span class="mr-0.5">💰</span>
<span
translate="no"
class="inline-block w-[45px] text-right text-zinc-50"
>
<span translate="no" class="inline-block w-[45px] text-right">
${renderNumber(other.gold() || 0)}
</span>
<span class="opacity-95">${translateText("player_panel.gold")}</span>
<span class="opacity-95 whitespace-nowrap"
>${translateText("player_panel.gold")}</span
>
</div>
<div
class="inline-flex items-center gap-1.5 rounded-full bg-zinc-800 px-2.5 py-1
text-lg font-semibold text-zinc-100"
class="inline-flex items-center gap-1.5 rounded-full bg-white/[0.04] px-2.5 py-1
text-base font-semibold text-zinc-200"
>
<span class="mr-0.5">🛡️</span>
<span
translate="no"
class="inline-block w-[45px] text-right text-zinc-50"
>
<span translate="no" class="inline-block w-[45px] text-right">
${renderTroops(other.troops() || 0)}
</span>
<span class="opacity-95"
<span class="opacity-95 whitespace-nowrap"
>${translateText("player_panel.troops")}</span
>
</div>
@@ -554,7 +562,7 @@ export class PlayerPanel extends LitElement implements Layer {
})
: html`
<div class="py-2 text-zinc-300">
${translateText("player_panel.none")}
${translateText("common.none")}
</div>
`}
</div>
@@ -567,7 +575,7 @@ export class PlayerPanel extends LitElement implements Layer {
if (this.allianceExpiryText === null) return html``;
return html`
<div class="grid grid-cols-[auto,1fr] gap-x-6 gap-y-2 text-base">
<div class="font-semibold text-zinc-400">
<div class="font-semibold text-zinc-300">
${translateText("player_panel.alliance_time_remaining")}
</div>
<div class="text-right font-semibold">
@@ -713,6 +721,8 @@ export class PlayerPanel extends LitElement implements Layer {
return html``;
}
const other = owner as PlayerView;
const myGoldNum = my.gold();
const myTroopsNum = Number(my.troops());
return html`
<style>
@@ -744,12 +754,11 @@ export class PlayerPanel extends LitElement implements Layer {
<div
class="fixed inset-0 z-[1001] flex items-center justify-center overflow-auto
bg-black/40 backdrop-blur-sm backdrop-brightness-110 pointer-events-auto"
bg-black/15 backdrop-blur-sm backdrop-brightness-110 pointer-events-auto"
@contextmenu=${(e: MouseEvent) => e.preventDefault()}
@wheel=${(e: MouseEvent) => e.stopPropagation()}
@click=${() => this.hide()}
>
<!-- Stop clicks inside the panel from closing it -->
<div
class="pointer-events-auto max-h-[90vh] overflow-y-auto min-w-[240px] w-auto px-4 py-2"
@click=${(e: MouseEvent) => e.stopPropagation()}
@@ -763,8 +772,8 @@ export class PlayerPanel extends LitElement implements Layer {
@click=${this.handleClose}
class="absolute -top-3 -right-3 flex h-7 w-7 items-center justify-center
rounded-full bg-zinc-700 text-white shadow hover:bg-red-500 transition-colors"
aria-label=${translateText("player_panel.close") || "Close"}
title=${translateText("player_panel.close") || "Close"}
aria-label=${translateText("common.close") || "Close"}
title=${translateText("common.close") || "Close"}
>
</button>
@@ -775,6 +784,28 @@ export class PlayerPanel extends LitElement implements Layer {
<!-- Identity (flag, name, type, traitor, relation) -->
<div class="mb-1">${this.renderIdentityRow(other, my)}</div>
${this.sendTarget
? html`
<send-resource-modal
.open=${this.sendMode !== "none"}
.mode=${this.sendMode}
.total=${this.sendMode === "troops"
? myTroopsNum
: myGoldNum}
.uiState=${this.uiState}
.myPlayer=${my}
.target=${this.sendTarget}
.gameView=${this.g}
.eventBus=${this.eventBus}
.format=${this.sendMode === "troops"
? renderTroops
: renderNumber}
@confirm=${this.confirmSend}
@close=${this.closeSend}
></send-resource-modal>
`
: ""}
<ui-divider></ui-divider>
<!-- Resources -->