= 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 (