attack panel (#3114)

Relates #2260

## Description:

Move outgoing & incoming boat & land attacks to a new "AttacksDisplay"
layer that sits on top of the ControlPanel. The idea is to break up
EventsDisplay so it's easier to find information. It's also more mobile
friendly.

It still needs more styling, but this just a first pass.

<img width="356" height="199" alt="Screenshot 2026-02-09 at 4 44 38 PM"
src="https://github.com/user-attachments/assets/c8e32972-be3b-469b-b7c7-982197c1d572"
/>

<img width="750" height="436" alt="Screenshot 2026-02-09 at 4 44 18 PM"
src="https://github.com/user-attachments/assets/5359459b-015e-432f-81bf-1561cc64babe"
/>

<img width="537" height="537" alt="Screenshot 2026-02-09 at 4 43 33 PM"
src="https://github.com/user-attachments/assets/edc7a07e-3589-4107-b017-38e00768c5cf"
/>

<img width="487" height="283" alt="Screenshot 2026-02-09 at 4 44 05 PM"
src="https://github.com/user-attachments/assets/1a3886c7-57e3-4247-92c5-3a13876c2a71"
/>

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

evan
This commit is contained in:
Evan
2026-02-09 21:06:08 -08:00
committed by GitHub
parent 900cc89067
commit 79330af2b2
6 changed files with 470 additions and 296 deletions
+3 -292
View File
@@ -8,15 +8,12 @@ import {
getMessageCategory,
MessageCategory,
MessageType,
PlayerType,
Tick,
UnitType,
} from "../../../core/game/Game";
import {
AllianceExpiredUpdate,
AllianceRequestReplyUpdate,
AllianceRequestUpdate,
AttackUpdate,
BrokeAllianceUpdate,
DisplayChatMessageUpdate,
DisplayMessageUpdate,
@@ -26,22 +23,15 @@ import {
UnitIncomingUpdate,
} from "../../../core/game/GameUpdates";
import {
CancelAttackIntentEvent,
CancelBoatIntentEvent,
SendAllianceExtensionIntentEvent,
SendAllianceReplyIntentEvent,
SendAttackIntentEvent,
} from "../../Transport";
import { Layer } from "./Layer";
import { GameView, PlayerView, UnitView } from "../../../core/game/GameView";
import { onlyImages } from "../../../core/Util";
import { renderNumber, renderTroops } from "../../Utils";
import {
GoToPlayerEvent,
GoToPositionEvent,
GoToUnitEvent,
} from "./Leaderboard";
import { renderNumber } from "../../Utils";
import { GoToPlayerEvent, GoToUnitEvent } from "./Leaderboard";
import { getMessageTypeClasses, translateText } from "../../Utils";
import { UIState } from "../UIState";
@@ -84,10 +74,6 @@ export class EventsDisplay extends LitElement implements Layer {
// allianceID -> last checked at tick
private alliancesCheckedAt = new Map<number, Tick>();
@state() private incomingAttacks: AttackUpdate[] = [];
@state() private outgoingAttacks: AttackUpdate[] = [];
@state() private outgoingLandAttacks: AttackUpdate[] = [];
@state() private outgoingBoats: UnitView[] = [];
@state() private _hidden: boolean = false;
@state() private _isVisible: boolean = false;
@state() private newEvents: number = 0;
@@ -194,9 +180,6 @@ export class EventsDisplay extends LitElement implements Layer {
constructor() {
super();
this.events = [];
this.incomingAttacks = [];
this.outgoingAttacks = [];
this.outgoingBoats = [];
}
init() {}
@@ -254,24 +237,6 @@ export class EventsDisplay extends LitElement implements Layer {
this.requestUpdate();
}
// Update attacks
this.incomingAttacks = myPlayer.incomingAttacks().filter((a) => {
const t = (this.game.playerBySmallID(a.attackerID) as PlayerView).type();
return t !== PlayerType.Bot;
});
this.outgoingAttacks = myPlayer
.outgoingAttacks()
.filter((a) => a.targetID !== 0);
this.outgoingLandAttacks = myPlayer
.outgoingAttacks()
.filter((a) => a.targetID === 0);
this.outgoingBoats = myPlayer
.units()
.filter((u) => u.type() === UnitType.TransportShip);
this.requestUpdate();
}
@@ -664,28 +629,12 @@ export class EventsDisplay extends LitElement implements Layer {
});
}
emitCancelAttackIntent(id: string) {
const myPlayer = this.game.myPlayer();
if (!myPlayer) return;
this.eventBus.emit(new CancelAttackIntentEvent(id));
}
emitBoatCancelIntent(id: number) {
const myPlayer = this.game.myPlayer();
if (!myPlayer) return;
this.eventBus.emit(new CancelBoatIntentEvent(id));
}
emitGoToPlayerEvent(attackerID: number) {
const attacker = this.game.playerBySmallID(attackerID) as PlayerView;
if (!attacker) return;
this.eventBus.emit(new GoToPlayerEvent(attacker));
}
emitGoToPositionEvent(x: number, y: number) {
this.eventBus.emit(new GoToPositionEvent(x, y));
}
emitGoToUnitEvent(unit: UnitView) {
this.eventBus.emit(new GoToUnitEvent(unit));
}
@@ -753,196 +702,6 @@ export class EventsDisplay extends LitElement implements Layer {
: event.description;
}
private async attackWarningOnClick(attack: AttackUpdate) {
const playerView = this.game.playerBySmallID(attack.attackerID);
if (playerView !== undefined) {
if (playerView instanceof PlayerView) {
const averagePosition = await playerView.attackAveragePosition(
attack.attackerID,
attack.id,
);
if (averagePosition === null) {
this.emitGoToPlayerEvent(attack.attackerID);
} else {
this.emitGoToPositionEvent(averagePosition.x, averagePosition.y);
}
}
} else {
this.emitGoToPlayerEvent(attack.attackerID);
}
}
private handleRetaliate(attack: AttackUpdate) {
const attacker = this.game.playerBySmallID(attack.attackerID) as PlayerView;
if (!attacker) return;
const myPlayer = this.game.myPlayer();
if (!myPlayer) return;
const counterTroops = Math.min(
attack.troops,
this.uiState.attackRatio * myPlayer.troops(),
);
this.eventBus.emit(new SendAttackIntentEvent(attacker.id(), counterTroops));
}
private renderIncomingAttacks() {
return html`
${this.incomingAttacks.length > 0
? html`
<div class="flex flex-wrap gap-y-1 gap-x-2">
${this.incomingAttacks.map(
(attack) => html`
<div class="inline-flex items-center gap-1">
${this.renderButton({
content: html`
${renderTroops(attack.troops)}
${(
this.game.playerBySmallID(
attack.attackerID,
) as PlayerView
)?.name()}
${attack.retreating
? `(${translateText("events_display.retreating")}...)`
: ""}
`,
onClick: () => this.attackWarningOnClick(attack),
className: "text-left text-red-400",
translate: false,
})}
${!attack.retreating
? this.renderButton({
content: translateText("events_display.retaliate"),
onClick: () => this.handleRetaliate(attack),
className:
"inline-block px-3 py-1 text-white rounded-sm text-md md:text-sm cursor-pointer transition-colors duration-300 bg-red-600 hover:bg-red-700",
translate: true,
})
: ""}
</div>
`,
)}
</div>
`
: ""}
`;
}
private renderOutgoingAttacks() {
return html`
${this.outgoingAttacks.length > 0
? html`
<div class="flex flex-wrap gap-y-1 gap-x-2">
${this.outgoingAttacks.map(
(attack) => html`
<div class="inline-flex items-center gap-1">
${this.renderButton({
content: html`
${renderTroops(attack.troops)}
${(
this.game.playerBySmallID(
attack.targetID,
) as PlayerView
)?.name()}
`,
onClick: async () => this.attackWarningOnClick(attack),
className: "text-left text-blue-400",
translate: false,
})}
${!attack.retreating
? this.renderButton({
content: "❌",
onClick: () => this.emitCancelAttackIntent(attack.id),
className: "text-left shrink-0",
disabled: attack.retreating,
})
: html`<span class="shrink-0 text-blue-400"
>(${translateText(
"events_display.retreating",
)}...)</span
>`}
</div>
`,
)}
</div>
`
: ""}
`;
}
private renderOutgoingLandAttacks() {
return html`
${this.outgoingLandAttacks.length > 0
? html`
<div class="flex flex-wrap gap-y-1 gap-x-2">
${this.outgoingLandAttacks.map(
(landAttack) => html`
<div class="inline-flex items-center gap-1">
${this.renderButton({
content: html`${renderTroops(landAttack.troops)}
${translateText("help_modal.ui_wilderness")}`,
className: "text-left text-gray-400",
translate: false,
})}
${!landAttack.retreating
? this.renderButton({
content: "❌",
onClick: () =>
this.emitCancelAttackIntent(landAttack.id),
className: "text-left shrink-0",
disabled: landAttack.retreating,
})
: html`<span class="shrink-0 text-blue-400"
>(${translateText(
"events_display.retreating",
)}...)</span
>`}
</div>
`,
)}
</div>
`
: ""}
`;
}
private renderBoats() {
return html`
${this.outgoingBoats.length > 0
? html`
<div class="flex flex-wrap gap-y-1 gap-x-2">
${this.outgoingBoats.map(
(boat) => html`
<div class="inline-flex items-center gap-1">
${this.renderButton({
content: html`${translateText("events_display.boat")}:
${renderTroops(boat.troops())}`,
onClick: () => this.emitGoToUnitEvent(boat),
className: "text-left text-blue-400",
translate: false,
})}
${!boat.retreating()
? this.renderButton({
content: "❌",
onClick: () => this.emitBoatCancelIntent(boat.id()),
className: "text-left shrink-0",
disabled: boat.retreating(),
})
: html`<span class="shrink-0 text-blue-400"
>(${translateText(
"events_display.retreating",
)}...)</span
>`}
</div>
`,
)}
</div>
`
: ""}
`;
}
private renderBetrayalDebuffTimer() {
const myPlayer = this.game.myPlayer();
if (!myPlayer || !myPlayer.isTraitor()) {
@@ -1161,17 +920,6 @@ export class EventsDisplay extends LitElement implements Layer {
</tr>
`,
)}
<!--- Incoming attacks row -->
${this.incomingAttacks.length > 0
? html`
<tr class="lg:px-2 lg:py-1 p-1">
<td class="lg:px-2 lg:py-1 p-1 text-left">
${this.renderIncomingAttacks()}
</td>
</tr>
`
: ""}
<!--- Betrayal debuff timer row -->
${(() => {
const myPlayer = this.game.myPlayer();
@@ -1190,45 +938,8 @@ export class EventsDisplay extends LitElement implements Layer {
`
: ""}
<!--- Outgoing attacks row -->
${this.outgoingAttacks.length > 0
? html`
<tr class="lg:px-2 lg:py-1 p-1">
<td class="lg:px-2 lg:py-1 p-1 text-left">
${this.renderOutgoingAttacks()}
</td>
</tr>
`
: ""}
<!--- Outgoing land attacks row -->
${this.outgoingLandAttacks.length > 0
? html`
<tr class="lg:px-2 lg:py-1 p-1">
<td class="lg:px-2 lg:py-1 p-1 text-left">
${this.renderOutgoingLandAttacks()}
</td>
</tr>
`
: ""}
<!--- Boats row -->
${this.outgoingBoats.length > 0
? html`
<tr class="lg:px-2 lg:py-1 p-1">
<td class="lg:px-2 lg:py-1 p-1 text-left">
${this.renderBoats()}
</td>
</tr>
`
: ""}
<!--- Empty row when no events or attacks -->
<!--- Empty row when no events -->
${filteredEvents.length === 0 &&
this.incomingAttacks.length === 0 &&
this.outgoingAttacks.length === 0 &&
this.outgoingLandAttacks.length === 0 &&
this.outgoingBoats.length === 0 &&
!(() => {
const myPlayer = this.game.myPlayer();
return (