/* eslint-disable max-lines */ import { html, LitElement, TemplateResult } from "lit"; import { customElement, state } from "lit/decorators.js"; import { DirectiveResult } from "lit/directive.js"; import { unsafeHTML, UnsafeHTMLDirective } from "lit/directives/unsafe-html.js"; import allianceIcon from "../../../../resources/images/AllianceIconWhite.svg"; import chatIcon from "../../../../resources/images/ChatIconWhite.svg"; import donateGoldIcon from "../../../../resources/images/DonateGoldIconWhite.svg"; import swordIcon from "../../../../resources/images/SwordIconWhite.svg"; import { EventBus } from "../../../core/EventBus"; import { AllPlayers, getMessageCategory, MessageCategory, MessageType, PlayerType, Tick, UnitType, } from "../../../core/game/Game"; import { AllianceExpiredUpdate, AllianceRequestReplyUpdate, AllianceRequestUpdate, AttackUpdate, BrokeAllianceUpdate, DisplayChatMessageUpdate, DisplayMessageUpdate, EmojiUpdate, GameUpdateType, TargetPlayerUpdate, UnitIncomingUpdate, } from "../../../core/game/GameUpdates"; import { CancelAttackIntentEvent, CancelBoatIntentEvent, SendAllianceExtensionIntentEvent, SendAllianceReplyIntentEvent, } 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 { getMessageTypeClasses, translateText } from "../../Utils"; type GameEvent = { description: string; unsafeDescription?: boolean; buttons?: { text: string; className: string; action: () => void; preventClose?: boolean; }[]; type: MessageType; highlight?: boolean; createdAt: number; onDelete?: () => void; // lower number: lower on the display priority?: number; duration?: Tick; focusID?: number; unitView?: UnitView; }; @customElement("events-display") export class EventsDisplay extends LitElement implements Layer { public eventBus: EventBus; public game: GameView; private active = false; private events: GameEvent[] = []; // 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 = false; @state() private _isVisible = false; @state() private newEvents = 0; @state() private latestGoldAmount: bigint | null = null; @state() private goldAmountAnimating = false; private goldAmountTimeoutId: ReturnType | null = null; @state() private eventsFilters: Map = new Map([ [MessageCategory.ATTACK, false], [MessageCategory.TRADE, false], [MessageCategory.ALLIANCE, false], [MessageCategory.CHAT, false], ]); private renderButton(options: { content: string | TemplateResult | DirectiveResult; 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 toggleHidden() { this._hidden = !this._hidden; if (this._hidden) { this.newEvents = 0; } this.requestUpdate(); } private toggleEventFilter(filterName: MessageCategory) { const currentState = this.eventsFilters.get(filterName) ?? false; this.eventsFilters.set(filterName, !currentState); this.requestUpdate(); } private updateMap = [ [GameUpdateType.DisplayEvent, this.onDisplayMessageEvent.bind(this)], [GameUpdateType.DisplayChatEvent, this.onDisplayChatEvent.bind(this)], [GameUpdateType.AllianceRequest, this.onAllianceRequestEvent.bind(this)], [ GameUpdateType.AllianceRequestReply, this.onAllianceRequestReplyEvent.bind(this), ], [GameUpdateType.BrokeAlliance, this.onBrokeAllianceEvent.bind(this)], [GameUpdateType.TargetPlayer, this.onTargetPlayerEvent.bind(this)], [GameUpdateType.Emoji, this.onEmojiMessageEvent.bind(this)], [GameUpdateType.UnitIncoming, this.onUnitIncomingEvent.bind(this)], [GameUpdateType.AllianceExpired, this.onAllianceExpiredEvent.bind(this)], ] as const; constructor() { super(); this.events = []; this.incomingAttacks = []; this.outgoingAttacks = []; this.outgoingBoats = []; } init() {} tick() { this.active = true; if (!this._isVisible && !this.game.inSpawnPhase()) { this._isVisible = true; this.requestUpdate(); } const myPlayer = this.game.myPlayer(); if (!myPlayer || !myPlayer.isAlive()) { if (this._isVisible) { this._isVisible = false; this.requestUpdate(); } return; } this.checkForAllianceExpirations(); const updates = this.game.updatesSinceLastTick(); if (updates) { for (const [ut, fn] of this.updateMap) { updates[ut]?.forEach(fn as (event: unknown) => void); } } let remainingEvents = this.events.filter((event) => { const shouldKeep = this.game.ticks() - event.createdAt < (event.duration ?? 600); if (!shouldKeep && event.onDelete) { event.onDelete(); } return shouldKeep; }); if (remainingEvents.length > 30) { remainingEvents = remainingEvents.slice(-30); } if (this.events.length !== remainingEvents.length) { this.events = remainingEvents; 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(); } disconnectedCallback() { if (this.goldAmountTimeoutId !== null) { clearTimeout(this.goldAmountTimeoutId); this.goldAmountTimeoutId = null; } } private checkForAllianceExpirations() { const myPlayer = this.game.myPlayer(); if (!myPlayer?.isAlive()) return; for (const alliance of myPlayer.alliances()) { if ( alliance.expiresAt > this.game.ticks() + this.game.config().allianceExtensionPromptOffset() ) { continue; } if ( (this.alliancesCheckedAt.get(alliance.id) ?? 0) >= this.game.ticks() - this.game.config().allianceExtensionPromptOffset() ) { // We've already displayed a message for this alliance. continue; } this.alliancesCheckedAt.set(alliance.id, this.game.ticks()); const other = this.game.player(alliance.other); if (!other.isAlive()) continue; this.addEvent({ description: translateText("events_display.about_to_expire", { name: other.name(), }), type: MessageType.RENEW_ALLIANCE, duration: this.game.config().allianceExtensionPromptOffset() - 3 * 10, // 3 second buffer buttons: [ { text: translateText("events_display.focus"), className: "btn-gray", action: () => this.eventBus.emit(new GoToPlayerEvent(other)), preventClose: true, }, { text: translateText("events_display.renew_alliance", { name: other.name(), }), className: "btn", action: () => this.eventBus.emit(new SendAllianceExtensionIntentEvent(other)), }, { text: translateText("events_display.ignore"), className: "btn-info", action: () => {}, }, ], highlight: true, createdAt: this.game.ticks(), focusID: other.smallID(), }); } } private addEvent(event: GameEvent) { this.events = [...this.events, event]; if (this._hidden === true) { this.newEvents++; } this.requestUpdate(); } private removeEvent(index: number) { this.events = [ ...this.events.slice(0, index), ...this.events.slice(index + 1), ]; } shouldTransform(): boolean { return false; } renderLayer(): void {} onDisplayMessageEvent(event: DisplayMessageUpdate) { const myPlayer = this.game.myPlayer(); if ( event.playerID !== null && (!myPlayer || myPlayer.smallID() !== event.playerID) ) { return; } if (event.goldAmount !== undefined) { const hasChanged = this.latestGoldAmount !== event.goldAmount; this.latestGoldAmount = event.goldAmount; if (this.goldAmountTimeoutId !== null) { clearTimeout(this.goldAmountTimeoutId); } this.goldAmountTimeoutId = setTimeout(() => { this.latestGoldAmount = null; this.goldAmountTimeoutId = null; this.requestUpdate(); }, 5000); if (hasChanged) { this.goldAmountAnimating = true; setTimeout(() => { this.goldAmountAnimating = false; this.requestUpdate(); }, 600); } } let description: string = event.message; if (event.params !== undefined) { if (event.message.startsWith("events_display.")) { description = translateText(event.message, event.params); } } this.addEvent({ description: description, createdAt: this.game.ticks(), highlight: true, type: event.messageType, unsafeDescription: true, }); } onDisplayChatEvent(event: DisplayChatMessageUpdate) { const myPlayer = this.game.myPlayer(); if ( event.playerID === null || !myPlayer || myPlayer.smallID() !== event.playerID ) { return; } const baseMessage = translateText(`chat.${event.category}.${event.key}`); let translatedMessage = baseMessage; if (event.target) { try { const targetPlayer = this.game.player(event.target); const targetName = targetPlayer?.displayName() ?? event.target; translatedMessage = baseMessage.replace("[P1]", targetName); } catch (e) { console.warn( `Failed to resolve player for target ID '${event.target}'`, e, ); return; } } let otherPlayerDiplayName = ""; if (event.recipient !== null) { //'recipient' parameter contains sender ID or recipient ID const player = this.game.player(event.recipient); otherPlayerDiplayName = player ? player.displayName() : ""; } this.addEvent({ description: translateText(event.isFrom ? "chat.from" : "chat.to", { user: otherPlayerDiplayName, msg: translatedMessage, }), createdAt: this.game.ticks(), highlight: true, type: MessageType.CHAT, unsafeDescription: false, }); } onAllianceRequestEvent(update: AllianceRequestUpdate) { const myPlayer = this.game.myPlayer(); if (!myPlayer || update.recipientID !== myPlayer.smallID()) { return; } const requestor = this.game.playerBySmallID( update.requestorID, ) as PlayerView; const recipient = this.game.playerBySmallID( update.recipientID, ) as PlayerView; this.addEvent({ description: translateText("events_display.request_alliance", { name: requestor.name(), }), buttons: [ { text: translateText("events_display.focus"), className: "btn-gray", action: () => this.eventBus.emit(new GoToPlayerEvent(requestor)), preventClose: true, }, { text: translateText("events_display.accept_alliance"), className: "btn", action: () => this.eventBus.emit( new SendAllianceReplyIntentEvent(requestor, recipient, true), ), }, { text: translateText("events_display.reject_alliance"), className: "btn-info", action: () => this.eventBus.emit( new SendAllianceReplyIntentEvent(requestor, recipient, false), ), }, ], highlight: true, type: MessageType.ALLIANCE_REQUEST, createdAt: this.game.ticks(), onDelete: () => this.eventBus.emit( new SendAllianceReplyIntentEvent(requestor, recipient, false), ), priority: 0, duration: 150, focusID: update.requestorID, }); } onAllianceRequestReplyEvent(update: AllianceRequestReplyUpdate) { const myPlayer = this.game.myPlayer(); if (!myPlayer) { return; } // myPlayer can deny alliances without clicking on the button if (update.request.recipientID === myPlayer.smallID()) { // Remove alliance requests whose requestors are the same as the reply's requestor // Noop unless the request was denied through other means (e.g attacking the requestor) this.events = this.events.filter( (event) => !( event.type === MessageType.ALLIANCE_REQUEST && event.focusID === update.request.requestorID ), ); this.requestUpdate(); return; } if (update.request.requestorID !== myPlayer.smallID()) { return; } const recipient = this.game.playerBySmallID( update.request.recipientID, ) as PlayerView; this.addEvent({ description: translateText("events_display.alliance_request_status", { name: recipient.name(), status: update.accepted ? translateText("events_display.alliance_accepted") : translateText("events_display.alliance_rejected"), }), type: update.accepted ? MessageType.ALLIANCE_ACCEPTED : MessageType.ALLIANCE_REJECTED, highlight: true, createdAt: this.game.ticks(), focusID: update.request.recipientID, }); } onBrokeAllianceEvent(update: BrokeAllianceUpdate) { const myPlayer = this.game.myPlayer(); if (!myPlayer) return; const betrayed = this.game.playerBySmallID(update.betrayedID) as PlayerView; const traitor = this.game.playerBySmallID(update.traitorID) as PlayerView; if (betrayed.isDisconnected()) return; // Do not send the message if betraying a disconnected player if (!betrayed.isTraitor() && traitor === myPlayer) { const malusPercent = Math.round( (1 - this.game.config().traitorDefenseDebuff()) * 100, ); const traitorDuration = Math.floor( this.game.config().traitorDuration() * 0.1, ); const durationText = traitorDuration === 1 ? translateText("events_display.duration_second") : translateText("events_display.duration_seconds_plural", { seconds: traitorDuration, }); this.addEvent({ description: translateText("events_display.betrayal_description", { name: betrayed.name(), malusPercent: malusPercent, durationText: durationText, }), type: MessageType.ALLIANCE_BROKEN, highlight: true, createdAt: this.game.ticks(), focusID: update.betrayedID, }); } else if (betrayed === myPlayer) { const buttons = [ { text: translateText("events_display.focus"), className: "btn-gray", action: () => this.eventBus.emit(new GoToPlayerEvent(traitor)), preventClose: true, }, ]; this.addEvent({ description: translateText("events_display.betrayed_you", { name: traitor.name(), }), type: MessageType.ALLIANCE_BROKEN, highlight: true, createdAt: this.game.ticks(), focusID: update.traitorID, buttons, }); } } onAllianceExpiredEvent(update: AllianceExpiredUpdate) { const myPlayer = this.game.myPlayer(); if (!myPlayer) return; const otherID = update.player1ID === myPlayer.smallID() ? update.player2ID : update.player2ID === myPlayer.smallID() ? update.player1ID : null; if (otherID === null) return; const other = this.game.playerBySmallID(otherID) as PlayerView; if (!other || !myPlayer.isAlive() || !other.isAlive()) return; this.addEvent({ description: translateText("events_display.alliance_expired", { name: other.name(), }), type: MessageType.ALLIANCE_EXPIRED, highlight: true, createdAt: this.game.ticks(), focusID: otherID, }); } onTargetPlayerEvent(event: TargetPlayerUpdate) { const other = this.game.playerBySmallID(event.playerID) as PlayerView; const myPlayer = this.game.myPlayer() as PlayerView; if (!myPlayer || !myPlayer.isFriendly(other)) return; const target = this.game.playerBySmallID(event.targetID) as PlayerView; this.addEvent({ description: translateText("events_display.attack_request", { name: other.name(), target: target.name(), }), type: MessageType.ATTACK_REQUEST, highlight: true, createdAt: this.game.ticks(), focusID: event.targetID, }); } 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)); } onEmojiMessageEvent(update: EmojiUpdate) { const myPlayer = this.game.myPlayer(); if (!myPlayer) return; const recipient = update.emoji.recipientID === AllPlayers ? AllPlayers : this.game.playerBySmallID(update.emoji.recipientID); const sender = this.game.playerBySmallID( update.emoji.senderID, ) as PlayerView; if (recipient === myPlayer) { this.addEvent({ description: `${sender.displayName()}: ${update.emoji.message}`, unsafeDescription: true, type: MessageType.CHAT, highlight: true, createdAt: this.game.ticks(), focusID: update.emoji.senderID, }); } else if (sender === myPlayer && recipient !== AllPlayers) { this.addEvent({ description: translateText("events_display.sent_emoji", { name: (recipient as PlayerView).displayName(), emoji: update.emoji.message, }), unsafeDescription: true, type: MessageType.CHAT, highlight: true, createdAt: this.game.ticks(), focusID: recipient.smallID(), }); } } onUnitIncomingEvent(event: UnitIncomingUpdate) { const myPlayer = this.game.myPlayer(); if (!myPlayer || myPlayer.smallID() !== event.playerID) { return; } const unitView = this.game.unit(event.unitID); this.addEvent({ description: event.message, type: event.messageType, unsafeDescription: false, highlight: true, createdAt: this.game.ticks(), unitView: unitView, }); } private getEventDescription( event: GameEvent, ): string | DirectiveResult { return event.unsafeDescription ? unsafeHTML(onlyImages(event.description)) : 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 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, })} `, )} ` : ""} `; } 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 flex-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 flex-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 flex-shrink-0", disabled: boat.retreating(), }) : html`(${translateText( "events_display.retreating", )}...)`}
`, )}
` : ""} `; } render() { if (!this.active || !this._isVisible) { return html``; } const styles = html` `; const filteredEvents = this.events.filter((event) => { const category = getMessageCategory(event.type); return !this.eventsFilters.get(category); }); filteredEvents.sort((a, b) => { const aPrior = a.priority ?? 100000; const bPrior = b.priority ?? 100000; if (aPrior === bPrior) { return a.createdAt - b.createdAt; } return bPrior - aPrior; }); return html` ${styles} ${this._hidden ? html`
${this.renderButton({ content: html` Events ${this.newEvents} `, onClick: this.toggleHidden, className: "text-white cursor-pointer pointer-events-auto w-fit p-2 " + "lg:p-3 rounded-md bg-gray-800/70 backdrop-blur", })}
` : html`
${this.renderButton({ content: html``, onClick: () => this.toggleEventFilter(MessageCategory.ATTACK), className: "cursor-pointer pointer-events-auto", })} ${this.renderButton({ content: html``, onClick: () => this.toggleEventFilter(MessageCategory.TRADE), className: "cursor-pointer pointer-events-auto", })} ${this.renderButton({ content: html``, onClick: () => this.toggleEventFilter(MessageCategory.ALLIANCE), className: "cursor-pointer pointer-events-auto", })} ${this.renderButton({ content: html``, onClick: () => this.toggleEventFilter(MessageCategory.CHAT), className: "cursor-pointer pointer-events-auto", })}
${this.latestGoldAmount !== null ? html`+${renderNumber(this.latestGoldAmount)}` : ""} ${this.renderButton({ content: translateText("leaderboard.hide"), onClick: this.toggleHidden, className: "text-white cursor-pointer pointer-events-auto", })}
${filteredEvents.map( (event, index) => html` `, )} ${this.incomingAttacks.length > 0 ? html` ` : ""} ${this.outgoingAttacks.length > 0 ? html` ` : ""} ${this.outgoingLandAttacks.length > 0 ? html` ` : ""} ${this.outgoingBoats.length > 0 ? html` ` : ""} ${filteredEvents.length === 0 && this.incomingAttacks.length === 0 && this.outgoingAttacks.length === 0 && this.outgoingLandAttacks.length === 0 && this.outgoingBoats.length === 0 ? html` ` : ""}
${event.focusID ? this.renderButton({ content: this.getEventDescription(event), onClick: () => { event.focusID && this.emitGoToPlayerEvent(event.focusID); }, className: "text-left", }) : event.unitView ? this.renderButton({ content: this.getEventDescription(event), onClick: () => { event.unitView && this.emitGoToUnitEvent( event.unitView, ); }, className: "text-left", }) : this.getEventDescription(event)} ${event.buttons ? html`
${event.buttons.map( (btn) => html` `, )}
` : ""}
${this.renderIncomingAttacks()}
${this.renderOutgoingAttacks()}
${this.renderOutgoingLandAttacks()}
${this.renderBoats()}
 
`} `; } createRenderRoot() { return this; } }