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 (