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
+29 -6
View File
@@ -6,7 +6,18 @@
"lang_code": "en"
},
"common": {
"close": "Close"
"close": "Close",
"available": "Available",
"preset_max": "Max",
"summary_send": "Send",
"summary_keep": "Keep",
"cancel": "Cancel",
"send": "Send",
"cap_label": "Cap",
"cap_tooltip": "Recipients remaining capacity",
"target_dead": "Target eliminated",
"target_dead_note": "You can't send resources to an eliminated player.",
"none": "None"
},
"main": {
"title": "OpenFront (ALPHA)",
@@ -590,8 +601,6 @@
"troops": "Troops",
"betrayals": "Betrayals",
"traitor": "Traitor",
"stable": "Stable",
"trust": "Trust",
"trading": "Trading",
"active": "Active",
"stopped": "Stopped",
@@ -600,9 +609,6 @@
"nuke": "Nukes sent by them to you",
"start_trade": "Start Trading",
"stop_trade": "Stop Trading",
"yes": "Yes",
"no": "No",
"none": "None",
"alliances": "Alliances",
"flag": "Flag",
"chat": "Chat",
@@ -615,6 +621,23 @@
"send_gold": "Send Gold",
"emotes": "Emojis"
},
"send_troops_modal": {
"title_with_name": "Send Troops to {name}",
"available_tooltip": "Your current available troops",
"min_keep": "Min keep",
"min_keep_pct": "(30%)",
"slider_tooltip": "{{percent}}% • {{amount}}",
"toggle_attack_bar_mode": "Use attack bar to send troops",
"warning_attackbar": "Once enabled, you can't open this modal directly. You'll only send troops via the attack bar.",
"aria_slider": "Troops slider",
"capacity_note": "Receiver can accept only {{amount}} right now."
},
"send_gold_modal": {
"title_with_name": "Send Gold to {name}",
"available_tooltip": "Your current available gold",
"aria_slider": "Amount slider",
"slider_tooltip": "{{percent}}% • {{amount}}"
},
"replay_panel": {
"replay_speed": "Replay speed",
"game_speed": "Game speed",
+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 -->
@@ -0,0 +1,588 @@
import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { EventBus } from "../../../core/EventBus";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { within } from "../../../core/Util";
import {
SendDonateGoldIntentEvent,
SendDonateTroopsIntentEvent,
} from "../../Transport";
import { renderTroops, translateText } from "../../Utils";
import { UIState } from "../UIState";
@customElement("send-resource-modal")
export class SendResourceModal extends LitElement {
@property({ attribute: false }) eventBus: EventBus | null = null;
@property({ type: Boolean }) open: boolean = false;
@property({ type: String }) mode: "troops" | "gold" = "troops";
@property({ type: Object }) total: number | bigint = 0;
@property({ type: Object }) uiState: UIState | null = null; // to seed initial %
@property({ attribute: false }) format: (n: number) => string = renderTroops;
@property({ attribute: false }) myPlayer: PlayerView | null = null;
@property({ attribute: false }) target: PlayerView | null = null;
@property({ attribute: false }) gameView: GameView | null = null;
@property({ type: String }) heading: string | null = null;
@state() private sendAmount: number = 0;
@state() private selectedPercent: number | null = null;
private PRESETS = [10, 25, 50, 75, 100] as const;
createRenderRoot() {
return this;
}
connectedCallback() {
super.connectedCallback();
const initPct =
this.uiState && typeof this.uiState.attackRatio === "number"
? Math.round(this.uiState.attackRatio * 100)
: 100;
this.selectedPercent = this.sanitizePercent(initPct);
const basis = this.getPercentBasis();
this.sendAmount = this.clampSend(
Math.floor((basis * this.selectedPercent) / 100),
);
}
updated(changed: Map<string, unknown>) {
if (changed.has("open") && this.open) {
// If either side is dead, just close and do nothing
if (!this.isSenderAlive() || !this.isTargetAlive()) {
this.closeModal();
return;
}
queueMicrotask(() =>
(this.querySelector('[role="dialog"]') as HTMLElement | null)?.focus(),
);
}
if (
changed.has("total") ||
changed.has("mode") ||
changed.has("target") ||
changed.has("gameView")
) {
const basis = this.getPercentBasis();
if (this.selectedPercent !== null) {
const pct = this.sanitizePercent(this.selectedPercent);
const raw = Math.floor((basis * pct) / 100);
this.sendAmount = this.clampSend(raw);
} else {
this.sendAmount = this.clampSend(this.sendAmount);
}
}
}
private closeModal() {
this.dispatchEvent(new CustomEvent("close"));
}
private confirm() {
if (!this.isSenderAlive() || !this.isTargetAlive() || !this.eventBus) {
return;
}
const myPlayer = this.myPlayer;
const target = this.target;
const amount = this.limitAmount(this.sendAmount);
if (!myPlayer || !target || amount <= 0) return;
if (this.mode === "troops") {
const myTroops = Number(myPlayer.troops());
if (amount > myTroops) return;
this.eventBus.emit(new SendDonateTroopsIntentEvent(target, amount));
} else {
const myGold = Number(myPlayer.gold());
if (amount > myGold) return;
this.eventBus.emit(new SendDonateGoldIntentEvent(target, BigInt(amount)));
}
this.dispatchEvent(
new CustomEvent("confirm", {
detail: { amount, closePanel: true, success: true },
}),
);
this.closeModal();
}
private handleKeydown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
this.closeModal();
}
if (e.key === "Enter") {
e.preventDefault();
this.confirm();
}
};
private toNum(x: unknown): number {
if (typeof x === "bigint") return Number(x);
return Number(x ?? 0);
}
private getTotalNumber(): number {
const base = this.toNum(this.total);
return this.isSenderAlive() ? base : 0;
}
private sanitizePercent(p: number) {
return within(p, 0, 100);
}
/** Internal capacity only for troops; gold is unlimited. */
private getCapacityLeft(): number | null {
if (!this.isTargetAlive()) return 0;
if (this.mode !== "troops") return null;
if (!this.gameView || !this.target) return null;
const current = this.toNum(this.target.troops());
const max = this.toNum(this.gameView.config().maxTroops(this.target));
return Math.max(0, max - current);
}
private getPercentBasis(): number {
return this.getTotalNumber();
}
private limitAmount(proposed: number): number {
const cap = this.getCapacityLeft();
const total = this.getTotalNumber();
const hardMax = cap === null ? total : Math.min(total, cap);
return within(proposed, 0, hardMax);
}
private clampSend(n: number) {
const total = this.getTotalNumber();
const byTotal = within(n, 0, total);
return this.limitAmount(byTotal);
}
private percentOfBasis(n: number): number {
const basis = this.getPercentBasis();
return basis ? Math.round((n / basis) * 100) : 0;
}
private keepAfter(allowed: number): number {
const total = this.getTotalNumber();
return Math.max(0, total - allowed);
}
private getFillColor(): string {
return this.mode === "troops"
? "rgb(168 85 247)" /* purple */
: "rgb(234 179 8)" /* amber */;
}
private getMinKeepRatio(): number {
return this.mode === "troops" ? 0.3 : 0;
}
private isTargetAlive(): boolean {
return this.target?.isAlive() ?? false;
}
private isSenderAlive(): boolean {
return this.myPlayer?.isAlive() ?? false;
}
private i18n = {
title: (name: string) =>
this.mode === "troops"
? translateText("send_troops_modal.title_with_name", { name })
: translateText("send_gold_modal.title_with_name", { name }),
availableChip: () => translateText("common.available"),
availableTooltip: () =>
this.mode === "troops"
? translateText("send_troops_modal.available_tooltip")
: translateText("send_gold_modal.available_tooltip"),
max: () => translateText("common.preset_max"),
ariaSlider: () =>
this.mode === "troops"
? translateText("send_troops_modal.aria_slider")
: translateText("send_gold_modal.aria_slider"),
summarySend: () => translateText("common.summary_send"),
summaryKeep: () => translateText("common.summary_keep"),
closeLabel: () => translateText("common.close"),
cancel: () => translateText("common.cancel"),
send: () => translateText("common.send"),
cap: () => translateText("common.cap_label"),
capTooltip: () => translateText("common.cap_tooltip"),
sliderTooltip: (percent: number, amountStr: string) =>
this.mode === "troops"
? translateText("send_troops_modal.slider_tooltip", {
percent,
amount: amountStr,
})
: translateText("send_gold_modal.slider_tooltip", {
percent,
amount: amountStr,
}),
capacityNote: (amountStr: string) =>
translateText("send_troops_modal.capacity_note", { amount: amountStr }),
targetDeadTitle: () => translateText("common.target_dead"),
targetDeadNote: () => translateText("common.target_dead_note"),
};
private renderHeader() {
const name = this.target?.name?.() ?? "";
return html`
<div class="mb-3 flex items-center justify-between">
<h2
id="send-title"
class="text-lg font-semibold tracking-tight text-zinc-100"
>
${this.heading ?? this.i18n.title(name)}
</h2>
<button
class="rounded-md px-2 text-2xl leading-none text-zinc-300 hover:text-zinc-100 focus:outline-none focus:ring-2 focus:ring-white/30"
@click=${() => this.closeModal()}
aria-label=${this.i18n.closeLabel()}
>
×
</button>
</div>
`;
}
private renderAvailable() {
const total = this.getTotalNumber();
const cap = this.getCapacityLeft();
return html`
<div class="mb-4 pb-3 border-b border-zinc-800">
<div class="flex items-center gap-2 text-[13px]">
<!-- Available -->
<span
class="inline-flex items-center gap-1 rounded-full bg-indigo-600/15 px-2 py-0.5 ring-1 ring-indigo-400/40 text-indigo-100"
title=${this.i18n.availableTooltip()}
>
<span class="opacity-90">${this.i18n.availableChip()}</span>
<span class="font-mono tabular-nums">${this.format(total)}</span>
</span>
${cap !== null
? html`
<!-- Cap -->
<span
class="inline-flex items-center gap-1 rounded-full bg-amber-500/10 px-2 py-0.5 ring-1 ring-amber-400/40 text-amber-200"
title=${this.i18n.capTooltip()}
>
<span class="opacity-90">${this.i18n.cap()}</span>
<span class="font-mono tabular-nums"
>${this.format(cap)}</span
>
</span>
`
: html``}
</div>
</div>
`;
}
private renderPresets(percentNow: number) {
const basis = this.getTotalNumber();
const dead = !this.isSenderAlive() || !this.isTargetAlive();
return html`
<div class="mb-8 grid grid-cols-5 gap-2">
${this.PRESETS.map((p) => {
const pct = this.sanitizePercent(p);
const active = (this.selectedPercent ?? percentNow) === pct;
const label = pct === 100 ? this.i18n.max() : `${pct}%`;
return html`
<button
?disabled=${dead}
class="rounded-lg px-3 py-2 text-sm ring-1 transition
${dead
? "bg-zinc-800/70 text-zinc-400 ring-zinc-700 cursor-not-allowed"
: active
? "bg-indigo-600 text-white ring-indigo-300/60"
: "bg-zinc-800 text-zinc-200 ring-zinc-700 hover:bg-zinc-700 hover:text-zinc-50"}"
@click=${() => {
if (dead) return;
this.selectedPercent = pct;
const raw = Math.floor((basis * pct) / 100);
this.sendAmount = this.clampSend(raw);
}}
?aria-pressed=${active}
title="${pct}%"
>
${label}
</button>
`;
})}
</div>
`;
}
private renderSlider(percentNow: number) {
const basis = this.getTotalNumber();
const cap = this.getCapacityLeft();
const hardMax = cap === null ? basis : Math.min(basis, cap);
const dead = !this.isSenderAlive() || !this.isTargetAlive();
// Where to draw the cap marker (as % of Available)
const capPercent =
cap === null
? null
: Math.max(
0,
Math.min(
100,
Math.round((Math.min(cap, basis) / (basis || 1)) * 100),
),
);
const fill = this.getFillColor();
const disabled = basis <= 0 || dead;
const sliderOuterMb = capPercent !== null ? "mb-8" : "mb-2";
return html`
<div class="${sliderOuterMb}">
<div
class="relative px-1 rounded-lg overflow-visible focus-within:ring-2 focus-within:ring-indigo-500/30"
>
<input
type="range"
min="0"
.max=${basis}
.value=${this.sendAmount}
?disabled=${disabled}
@input=${(e: Event) => {
if (dead) return;
const raw = Number((e.target as HTMLInputElement).value);
const pctRaw = basis ? Math.round((raw / basis) * 100) : 0;
this.selectedPercent = this.sanitizePercent(pctRaw);
const clamped = Math.min(raw, hardMax);
this.sendAmount = this.clampSend(clamped);
}}
class="w-full appearance-none bg-transparent range-x focus:outline-none"
aria-label=${this.i18n.ariaSlider()}
aria-valuemin="0"
aria-valuemax=${hardMax}
aria-valuetext=${this.i18n.sliderTooltip(
percentNow,
this.format(this.sendAmount),
)}
style="--percent:${percentNow}%; --fill:${fill}; --track: rgba(255,255,255,.28); --thumb-ring: rgb(24 24 27);"
/>
<!-- Tooltip -->
<div
class="pointer-events-none absolute -top-6 -translate-x-1/2 select-none"
style="left:${percentNow}%"
>
<div
class="rounded bg-[#0f1116] ring-1 ring-zinc-700 text-zinc-100 px-1.5 py-0.5 text-[12px] shadow whitespace-nowrap w-max z-50"
>
${percentNow}% • ${this.format(this.sendAmount)}
</div>
</div>
<!-- Cap marker -->
${capPercent !== null
? html`
<div
class="pointer-events-none absolute top-1/2 -translate-y-1/2 h-3 w-[2px] bg-amber-400/80 shadow"
style="left:${capPercent}%;"
title=${this.i18n.capTooltip()}
></div>
<div
class="pointer-events-none absolute top-full mt-1.5 -translate-x-1/2 select-none"
style="left:${capPercent}%"
>
<div
class="rounded bg-[#0f1116] ring-1 ring-amber-400/40 text-amber-200 px-1 py-0.5 text-[11px] shadow whitespace-nowrap"
>
${this.i18n.cap()}
</div>
</div>
`
: html``}
</div>
</div>
`;
}
private renderCapacityNote(allowed: number) {
const capped = allowed !== this.sendAmount;
if (!capped) return html``;
return html`<p class="mt-1 text-xs text-amber-300">
${this.i18n.capacityNote(this.format(allowed))}
</p>`;
}
private renderSummary(allowed: number) {
const total = this.getTotalNumber();
const keep = this.keepAfter(allowed);
const belowMinKeep =
this.getMinKeepRatio() > 0 &&
keep < Math.floor(total * this.getMinKeepRatio());
return html`
<div class="mt-3 text-center text-sm text-zinc-200">
${this.i18n.summarySend()}
<span class="font-semibold text-indigo-400 font-mono"
>${this.format(allowed)}</span
>
· ${this.i18n.summaryKeep()}
<span
class="font-semibold font-mono ${belowMinKeep
? "text-amber-400"
: "text-emerald-400"}"
>
${this.format(keep)}
</span>
</div>
`;
}
private renderActions() {
const total = this.getTotalNumber();
const dead = !this.isSenderAlive() || !this.isTargetAlive();
const disabled = total <= 0 || this.clampSend(this.sendAmount) <= 0 || dead;
return html`
<div class="mt-5 flex justify-end gap-2">
<button
class="h-10 min-w-24 rounded-lg px-3 text-sm font-semibold
text-zinc-100 bg-zinc-800 ring-1 ring-zinc-700
hover:bg-zinc-700 focus:outline-none
focus-visible:ring-2 focus-visible:ring-white/20"
@click=${() => this.closeModal()}
>
${this.i18n.cancel()}
</button>
<button
class="h-10 min-w-24 rounded-lg px-3 text-sm font-semibold text-white
bg-indigo-600 enabled:hover:bg-indigo-500
focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400/50
disabled:cursor-not-allowed disabled:opacity-50"
?disabled=${disabled}
@click=${() => this.confirm()}
>
${this.i18n.send()}
</button>
</div>
`;
}
private renderDeadNote() {
return html`
<div
class="mb-2 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-amber-200 text-sm"
>
<div class="font-semibold">${this.i18n.targetDeadTitle()}</div>
<div>${this.i18n.targetDeadNote()}</div>
</div>
`;
}
private renderSliderStyles() {
return html`
<style>
.range-x {
-webkit-appearance: none;
appearance: none;
height: 8px;
outline: none;
background: transparent;
}
.range-x::-webkit-slider-runnable-track {
height: 8px;
border-radius: 9999px;
background: linear-gradient(
90deg,
var(--fill) 0,
var(--fill) var(--percent),
/* allowed (clamped) fill */ rgba(255, 255, 255, 0.22)
var(--percent),
rgba(255, 255, 255, 0.22) 100%
);
}
.range-x::-webkit-slider-thumb {
-webkit-appearance: none;
height: 18px;
width: 18px;
border-radius: 9999px;
background: var(--fill);
border: 3px solid var(--thumb-ring);
margin-top: -5px;
}
.range-x::-moz-range-track {
height: 8px;
border-radius: 9999px;
background: rgba(255, 255, 255, 0.22);
}
.range-x::-moz-range-progress {
height: 8px;
border-radius: 9999px;
background: var(--fill);
}
.range-x::-moz-range-thumb {
height: 18px;
width: 18px;
border-radius: 9999px;
background: var(--fill);
border: 3px solid var(--thumb-ring);
}
</style>
`;
}
render() {
if (!this.open) return html``;
const percent = this.percentOfBasis(this.sendAmount);
const allowed = this.limitAmount(this.sendAmount);
return html`
<div class="fixed inset-0 z-[1100] flex items-center justify-center p-4">
<div
class="absolute inset-0 bg-black/60 backdrop-blur-sm rounded-2xl"
@click=${() => this.closeModal()}
></div>
<div
role="dialog"
aria-modal="true"
aria-labelledby="send-title"
class="relative z-10 w-full max-w-[540px] focus:outline-none"
tabindex="0"
@keydown=${this.handleKeydown}
>
<div
class="rounded-2xl bg-zinc-900 p-5 shadow-2xl ring-1 ring-zinc-800 max-h-[90vh] text-zinc-200"
@click=${(e: MouseEvent) => e.stopPropagation()}
>
${this.renderHeader()} ${this.renderAvailable()}
${!this.isTargetAlive() ? this.renderDeadNote() : html``}
${this.renderPresets(percent)} ${this.renderSlider(percent)}
${this.mode === "troops"
? this.renderCapacityNote(allowed)
: html``}
${this.renderSummary(allowed)} ${this.renderActions()}
${this.renderSliderStyles()}
</div>
</div>
</div>
`;
}
}
+10 -2
View File
@@ -576,7 +576,11 @@ export class PlayerImpl implements Player {
}
canDonateGold(recipient: Player): boolean {
if (!this.isFriendly(recipient)) {
if (
!this.isAlive() ||
!recipient.isAlive() ||
!this.isFriendly(recipient)
) {
return false;
}
if (
@@ -599,7 +603,11 @@ export class PlayerImpl implements Player {
}
canDonateTroops(recipient: Player): boolean {
if (!this.isFriendly(recipient)) {
if (
!this.isAlive() ||
!recipient.isAlive() ||
!this.isFriendly(recipient)
) {
return false;
}
if (