From 79330af2b2cb8231abcdacbb22ab4745414ef927 Mon Sep 17 00:00:00 2001 From: Evan Date: Mon, 9 Feb 2026 21:06:08 -0800 Subject: [PATCH] attack panel (#3114) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. Screenshot 2026-02-09 at 4 44 38 PM Screenshot 2026-02-09 at 4 44 18 PM Screenshot 2026-02-09 at 4 43 33 PM Screenshot 2026-02-09 at 4 44 05 PM ## 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 --- index.html | 5 +- resources/lang/en.json | 2 - src/client/graphics/GameRenderer.ts | 12 + src/client/graphics/layers/AttacksDisplay.ts | 450 +++++++++++++++++++ src/client/graphics/layers/ControlPanel.ts | 2 +- src/client/graphics/layers/EventsDisplay.ts | 295 +----------- 6 files changed, 470 insertions(+), 296 deletions(-) create mode 100644 src/client/graphics/layers/AttacksDisplay.ts diff --git a/index.html b/index.html index 7c7854bae..e9a96fd50 100644 --- a/index.html +++ b/index.html @@ -242,7 +242,10 @@
-
+
+
= new Set(); + private spriteDataURLCache: Map = new Map(); + @state() private _isVisible: boolean = false; + @state() private incomingAttacks: AttackUpdate[] = []; + @state() private outgoingAttacks: AttackUpdate[] = []; + @state() private outgoingLandAttacks: AttackUpdate[] = []; + @state() private outgoingBoats: UnitView[] = []; + @state() private incomingBoats: UnitView[] = []; + + createRenderRoot() { + return this; + } + + init() {} + + tick() { + this.active = true; + + if (!this._isVisible && !this.game.inSpawnPhase()) { + this._isVisible = true; + } + + const myPlayer = this.game.myPlayer(); + if (!myPlayer || !myPlayer.isAlive()) { + if (this._isVisible) { + this._isVisible = false; + } + return; + } + + // Track incoming boat unit IDs from UnitIncoming events + const updates = this.game.updatesSinceLastTick(); + if (updates) { + for (const event of updates[ + GameUpdateType.UnitIncoming + ] as UnitIncomingUpdate[]) { + if ( + event.playerID === myPlayer.smallID() && + event.messageType === MessageType.NAVAL_INVASION_INBOUND + ) { + this.incomingBoatIDs.add(event.unitID); + } + } + } + + // Resolve incoming boats from tracked IDs, remove inactive ones + const resolvedIncomingBoats: UnitView[] = []; + for (const unitID of this.incomingBoatIDs) { + const unit = this.game.unit(unitID); + if (unit && unit.isActive() && unit.type() === UnitType.TransportShip) { + resolvedIncomingBoats.push(unit); + } else { + this.incomingBoatIDs.delete(unitID); + } + } + this.incomingBoats = resolvedIncomingBoats; + + 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(); + } + + shouldTransform(): boolean { + return false; + } + + renderLayer(): void {} + + private renderButton(options: { + content: any; + onClick?: () => void; + className?: string; + disabled?: boolean; + translate?: boolean; + hidden?: boolean; + }) { + const { + content, + onClick, + className = "", + disabled = false, + translate = true, + hidden = false, + } = options; + + if (hidden) { + return html``; + } + + return html` + + `; + } + + private emitCancelAttackIntent(id: string) { + const myPlayer = this.game.myPlayer(); + if (!myPlayer) return; + this.eventBus.emit(new CancelAttackIntentEvent(id)); + } + + private emitBoatCancelIntent(id: number) { + const myPlayer = this.game.myPlayer(); + if (!myPlayer) return; + this.eventBus.emit(new CancelBoatIntentEvent(id)); + } + + private emitGoToPlayerEvent(attackerID: number) { + const attacker = this.game.playerBySmallID(attackerID) as PlayerView; + this.eventBus.emit(new GoToPlayerEvent(attacker)); + } + + private getBoatSpriteDataURL(unit: UnitView): string { + const owner = unit.owner(); + const key = `boat-${owner.id()}`; + const cached = this.spriteDataURLCache.get(key); + if (cached) return cached; + try { + const canvas = getColoredSprite(unit, this.game.config().theme()); + const dataURL = canvas.toDataURL(); + this.spriteDataURLCache.set(key, dataURL); + return dataURL; + } catch { + return ""; + } + } + + 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.eventBus.emit( + new GoToPositionEvent(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() { + if (this.incomingAttacks.length === 0) return html``; + + return this.incomingAttacks.map( + (attack) => html` +
+ ${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 inline-flex items-center gap-0.5 lg:gap-1 min-w-0", + translate: false, + })} + ${!attack.retreating + ? this.renderButton({ + content: html``, + onClick: () => this.handleRetaliate(attack), + className: + "ml-auto inline-flex items-center justify-center cursor-pointer bg-red-900/50 hover:bg-red-800/70 rounded px-1.5 py-1 border border-red-700/50", + translate: false, + }) + : ""} +
+ `, + ); + } + + private renderOutgoingAttacks() { + if (this.outgoingAttacks.length === 0) return html``; + + return this.outgoingAttacks.map( + (attack) => html` +
+ ${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 inline-flex items-center gap-0.5 lg:gap-1 min-w-0", + translate: false, + })} + ${!attack.retreating + ? this.renderButton({ + content: "❌", + onClick: () => this.emitCancelAttackIntent(attack.id), + className: "ml-auto text-left shrink-0", + disabled: attack.retreating, + }) + : html`(${translateText("events_display.retreating")}...)`} +
+ `, + ); + } + + private renderOutgoingLandAttacks() { + if (this.outgoingLandAttacks.length === 0) return html``; + + return this.outgoingLandAttacks.map( + (landAttack) => html` +
+ ${this.renderButton({ + content: html` + ${renderTroops(landAttack.troops)} + ${translateText("help_modal.ui_wilderness")}`, + className: + "text-left text-gray-400 inline-flex items-center gap-0.5 lg:gap-1 min-w-0", + translate: false, + })} + ${!landAttack.retreating + ? this.renderButton({ + content: "❌", + onClick: () => this.emitCancelAttackIntent(landAttack.id), + className: "ml-auto text-left shrink-0", + disabled: landAttack.retreating, + }) + : html`(${translateText("events_display.retreating")}...)`} +
+ `, + ); + } + + private getBoatTargetName(boat: UnitView): string { + const target = boat.targetTile(); + if (target === undefined) return ""; + const ownerID = this.game.ownerID(target); + if (ownerID === 0) return ""; + const player = this.game.playerBySmallID(ownerID) as PlayerView; + return player?.name() ?? ""; + } + + private renderBoatIcon(boat: UnitView) { + const dataURL = this.getBoatSpriteDataURL(boat); + if (!dataURL) return html``; + return html``; + } + + private renderBoats() { + if (this.outgoingBoats.length === 0) return html``; + + return this.outgoingBoats.map( + (boat) => html` +
+ ${this.renderButton({ + content: html`${this.renderBoatIcon(boat)} + ${renderTroops(boat.troops())} + ${this.getBoatTargetName(boat)}`, + onClick: () => this.eventBus.emit(new GoToUnitEvent(boat)), + className: + "text-left text-blue-400 inline-flex items-center gap-0.5 lg:gap-1 min-w-0", + translate: false, + })} + ${!boat.retreating() + ? this.renderButton({ + content: "❌", + onClick: () => this.emitBoatCancelIntent(boat.id()), + className: "ml-auto text-left shrink-0", + disabled: boat.retreating(), + }) + : html`(${translateText("events_display.retreating")}...)`} +
+ `, + ); + } + + private renderIncomingBoats() { + if (this.incomingBoats.length === 0) return html``; + + return this.incomingBoats.map( + (boat) => html` +
+ ${this.renderButton({ + content: html`${this.renderBoatIcon(boat)} + ${renderTroops(boat.troops())} + ${boat.owner()?.name()}`, + onClick: () => this.eventBus.emit(new GoToUnitEvent(boat)), + className: + "text-left text-red-400 inline-flex items-center gap-0.5 lg:gap-1 min-w-0", + translate: false, + })} +
+ `, + ); + } + + render() { + if (!this.active || !this._isVisible) { + return html``; + } + + const hasAnything = + this.outgoingAttacks.length > 0 || + this.outgoingLandAttacks.length > 0 || + this.outgoingBoats.length > 0 || + this.incomingAttacks.length > 0 || + this.incomingBoats.length > 0; + + if (!hasAnything) { + return html``; + } + + return html` +
+ ${this.renderOutgoingAttacks()} ${this.renderOutgoingLandAttacks()} + ${this.renderBoats()} ${this.renderIncomingAttacks()} + ${this.renderIncomingBoats()} +
+ `; + } +} diff --git a/src/client/graphics/layers/ControlPanel.ts b/src/client/graphics/layers/ControlPanel.ts index c45a31b00..c5f6c4a41 100644 --- a/src/client/graphics/layers/ControlPanel.ts +++ b/src/client/graphics/layers/ControlPanel.ts @@ -261,7 +261,7 @@ export class ControlPanel extends LitElement implements Layer { return html`
e.preventDefault()} > diff --git a/src/client/graphics/layers/EventsDisplay.ts b/src/client/graphics/layers/EventsDisplay.ts index 3d0bad609..b39d3f8de 100644 --- a/src/client/graphics/layers/EventsDisplay.ts +++ b/src/client/graphics/layers/EventsDisplay.ts @@ -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(); - @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` -
- ${this.incomingAttacks.map( - (attack) => html` -
- ${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, - }) - : ""} -
- `, - )} -
- ` - : ""} - `; - } - - private renderOutgoingAttacks() { - return html` - ${this.outgoingAttacks.length > 0 - ? html` -
- ${this.outgoingAttacks.map( - (attack) => html` -
- ${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`(${translateText( - "events_display.retreating", - )}...)`} -
- `, - )} -
- ` - : ""} - `; - } - - private renderOutgoingLandAttacks() { - return html` - ${this.outgoingLandAttacks.length > 0 - ? html` -
- ${this.outgoingLandAttacks.map( - (landAttack) => html` -
- ${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`(${translateText( - "events_display.retreating", - )}...)`} -
- `, - )} -
- ` - : ""} - `; - } - - private renderBoats() { - return html` - ${this.outgoingBoats.length > 0 - ? html` -
- ${this.outgoingBoats.map( - (boat) => html` -
- ${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`(${translateText( - "events_display.retreating", - )}...)`} -
- `, - )} -
- ` - : ""} - `; - } - private renderBetrayalDebuffTimer() { const myPlayer = this.game.myPlayer(); if (!myPlayer || !myPlayer.isTraitor()) { @@ -1161,17 +920,6 @@ export class EventsDisplay extends LitElement implements Layer { `, )} - - ${this.incomingAttacks.length > 0 - ? html` - - - ${this.renderIncomingAttacks()} - - - ` - : ""} - ${(() => { const myPlayer = this.game.myPlayer(); @@ -1190,45 +938,8 @@ export class EventsDisplay extends LitElement implements Layer { ` : ""} - - ${this.outgoingAttacks.length > 0 - ? html` - - - ${this.renderOutgoingAttacks()} - - - ` - : ""} - - - ${this.outgoingLandAttacks.length > 0 - ? html` - - - ${this.renderOutgoingLandAttacks()} - - - ` - : ""} - - - ${this.outgoingBoats.length > 0 - ? html` - - - ${this.renderBoats()} - - - ` - : ""} - - + ${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 (