import { html, LitElement } from "lit"; import { customElement, state } from "lit/decorators.js"; import allianceIcon from "../../../../resources/images/AllianceIconWhite.svg"; import chatIcon from "../../../../resources/images/ChatIconWhite.svg"; import disabledIcon from "../../../../resources/images/DisabledIcon.svg"; import donateGoldIcon from "../../../../resources/images/DonateGoldIconWhite.svg"; import donateTroopIcon from "../../../../resources/images/DonateTroopIconWhite.svg"; import emojiIcon from "../../../../resources/images/EmojiIconWhite.svg"; import stopTradingIcon from "../../../../resources/images/StopIconWhite.png"; import targetIcon from "../../../../resources/images/TargetIconWhite.svg"; import startTradingIcon from "../../../../resources/images/TradingIconWhite.png"; import traitorIcon from "../../../../resources/images/TraitorIconLightRed.svg"; import breakAllianceIcon from "../../../../resources/images/TraitorIconWhite.svg"; import { EventBus } from "../../../core/EventBus"; import { AllPlayers, PlayerActions, PlayerProfile, PlayerType, Relation, } from "../../../core/game/Game"; import { TileRef } from "../../../core/game/GameMap"; import { GameView, PlayerView } from "../../../core/game/GameView"; import { Emoji, flattenedEmojiTable } from "../../../core/Util"; import { actionButton } from "../../components/ui/ActionButton"; import "../../components/ui/Divider"; import Countries from "../../data/countries.json"; import { CloseViewEvent, MouseUpEvent } from "../../InputHandler"; import { SendAllianceRequestIntentEvent, SendBreakAllianceIntentEvent, SendEmbargoAllIntentEvent, SendEmbargoIntentEvent, SendEmojiIntentEvent, SendKickPlayerIntentEvent, SendTargetPlayerIntentEvent, } from "../../Transport"; import { renderDuration, renderNumber, renderTroops, translateText, } from "../../Utils"; import { UIState } from "../UIState"; import { ChatModal } from "./ChatModal"; import { EmojiTable } from "./EmojiTable"; import { Layer } from "./Layer"; import "./SendResourceModal"; @customElement("player-panel") export class PlayerPanel extends LitElement implements Layer { public g: GameView; public eventBus: EventBus; public emojiTable: EmojiTable; public uiState: UIState; private actions: PlayerActions | null = null; private tile: TileRef | null = null; private _profileForPlayerId: number | null = null; @state() private sendTarget: PlayerView | null = null; @state() private sendMode: "troops" | "gold" | "none" = "none"; @state() public isVisible: boolean = false; @state() private allianceExpiryText: string | null = null; @state() private allianceExpirySeconds: number | null = null; @state() private otherProfile: PlayerProfile | null = null; private ctModal: ChatModal; createRenderRoot() { return this; } initEventBus(eventBus: EventBus) { this.eventBus = eventBus; eventBus.on(CloseViewEvent, (e) => { if (this.isVisible) { this.hide(); } }); } init() { this.eventBus.on(MouseUpEvent, () => this.hide()); this.ctModal = document.querySelector("chat-modal") as ChatModal; if (!this.ctModal) { console.warn("ChatModal element not found in DOM"); } } async tick() { if (this.isVisible && this.tile) { const owner = this.g.owner(this.tile); if (owner && owner.isPlayer()) { const pv = owner as PlayerView; const id = pv.id(); // fetch only if we don't have it or the player changed if (this._profileForPlayerId !== Number(id)) { this.otherProfile = await pv.profile(); this._profileForPlayerId = Number(id); } } // Refresh actions & alliance expiry const myPlayer = this.g.myPlayer(); if (myPlayer !== null && myPlayer.isAlive()) { this.actions = await myPlayer.actions(this.tile); if (this.actions?.interaction?.allianceExpiresAt !== undefined) { const expiresAt = this.actions.interaction.allianceExpiresAt; const remainingTicks = expiresAt - this.g.ticks(); const remainingSeconds = Math.max(0, Math.floor(remainingTicks / 10)); // 10 ticks per second if (remainingTicks > 0) { this.allianceExpirySeconds = remainingSeconds; this.allianceExpiryText = renderDuration(remainingSeconds); } else { this.allianceExpirySeconds = null; this.allianceExpiryText = null; } } else { this.allianceExpirySeconds = null; this.allianceExpiryText = null; } this.requestUpdate(); } } } public show(actions: PlayerActions, tile: TileRef) { this.actions = actions; this.tile = tile; this.isVisible = true; this.requestUpdate(); } public hide() { this.isVisible = false; this.sendMode = "none"; this.sendTarget = null; this.requestUpdate(); } private handleClose(e: Event) { e.stopPropagation(); this.hide(); } private handleAllianceClick( e: Event, myPlayer: PlayerView, other: PlayerView, ) { e.stopPropagation(); this.eventBus.emit(new SendAllianceRequestIntentEvent(myPlayer, other)); this.hide(); } private handleBreakAllianceClick( e: Event, myPlayer: PlayerView, other: PlayerView, ) { e.stopPropagation(); this.eventBus.emit(new SendBreakAllianceIntentEvent(myPlayer, other)); this.hide(); } private openSendTroops(target: PlayerView) { this.sendTarget = target; this.sendMode = "troops"; } private openSendGold(target: PlayerView) { this.sendTarget = target; this.sendMode = "gold"; } private handleDonateTroopClick( e: Event, myPlayer: PlayerView, other: PlayerView, ) { e.stopPropagation(); this.openSendTroops(other); } private handleDonateGoldClick( e: Event, myPlayer: PlayerView, other: PlayerView, ) { e.stopPropagation(); this.openSendGold(other); } private closeSend = () => { this.sendTarget = null; this.sendMode = "none"; }; private confirmSend = ( e: CustomEvent<{ amount: number; closePanel?: boolean }>, ) => { this.closeSend(); if (e.detail?.closePanel) this.hide(); }; private handleEmbargoClick( e: Event, myPlayer: PlayerView, other: PlayerView, ) { e.stopPropagation(); this.eventBus.emit(new SendEmbargoIntentEvent(other, "start")); this.hide(); } private handleStopEmbargoClick( e: Event, myPlayer: PlayerView, other: PlayerView, ) { e.stopPropagation(); this.eventBus.emit(new SendEmbargoIntentEvent(other, "stop")); this.hide(); } private onStopTradingAllClick(e: Event) { e.stopPropagation(); this.eventBus.emit(new SendEmbargoAllIntentEvent("start")); } private onStartTradingAllClick(e: Event) { e.stopPropagation(); this.eventBus.emit(new SendEmbargoAllIntentEvent("stop")); } private handleEmojiClick(e: Event, myPlayer: PlayerView, other: PlayerView) { e.stopPropagation(); this.emojiTable.showTable((emoji: string) => { if (myPlayer === other) { this.eventBus.emit( new SendEmojiIntentEvent( AllPlayers, flattenedEmojiTable.indexOf(emoji as Emoji), ), ); } else { this.eventBus.emit( new SendEmojiIntentEvent( other, flattenedEmojiTable.indexOf(emoji as Emoji), ), ); } this.emojiTable.hideTable(); this.hide(); }); } private handleChat(e: Event, sender: PlayerView, other: PlayerView) { e.stopPropagation(); if (!this.ctModal) { console.warn("ChatModal element not found in DOM"); return; } this.ctModal.open(sender, other); this.hide(); } private handleTargetClick(e: Event, other: PlayerView) { e.stopPropagation(); this.eventBus.emit(new SendTargetPlayerIntentEvent(other.id())); this.hide(); } private handleKickClick(e: Event, other: PlayerView) { e.stopPropagation(); const targetClientID = other.clientID(); if (!targetClientID) { console.warn("Cannot kick player without clientID"); return; } this.eventBus.emit(new SendKickPlayerIntentEvent(targetClientID)); this.hide(); } private identityChipProps(type: PlayerType) { switch (type) { case PlayerType.Nation: return { labelKey: "player_type.nation", aria: "Nation player", classes: "border-indigo-400/25 bg-indigo-500/10 text-indigo-200", icon: "🏛️", }; case PlayerType.Bot: return { labelKey: "player_type.bot", aria: "Bot", classes: "border-purple-400/25 bg-purple-500/10 text-purple-200", icon: "🤖", }; case PlayerType.Human: default: return { labelKey: "player_type.player", aria: "Human player", classes: "border-zinc-400/20 bg-zinc-500/5 text-zinc-300", icon: "👤", }; } } private getRelationClass(relation: Relation): string { const base = "inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 " + "shadow-[inset_0_0_8px_rgba(255,255,255,0.04)]"; switch (relation) { case Relation.Hostile: return `${base} border-red-400/30 bg-red-500/10 text-red-200`; case Relation.Distrustful: return `${base} border-red-300/40 bg-red-300/10 text-red-300`; case Relation.Friendly: return `${base} border-emerald-400/30 bg-emerald-500/10 text-emerald-200`; case Relation.Neutral: default: return `${base} border-zinc-400/30 bg-zinc-500/10 text-zinc-200`; } } private getRelationName(relation: Relation): string { switch (relation) { case Relation.Hostile: return translateText("relation.hostile"); case Relation.Distrustful: return translateText("relation.distrustful"); case Relation.Friendly: return translateText("relation.friendly"); case Relation.Neutral: default: return translateText("relation.neutral"); } } private getExpiryColorClass(seconds: number | null): string { if (seconds === null) return "text-white"; // Default color if (seconds <= 30) return "text-red-400"; // Last 30 seconds: Red if (seconds <= 60) return "text-yellow-400"; // Last 60 seconds: Yellow return "text-emerald-400"; // More than 60 seconds: Green } private getTraitorRemainingSeconds(player: PlayerView): number | null { const ticksLeft = player.data.traitorRemainingTicks ?? 0; if (!player.isTraitor() || ticksLeft <= 0) return null; return Math.ceil(ticksLeft / 10); // 10 ticks = 1 second } private renderTraitorBadge(other: PlayerView) { if (!other.isTraitor()) return html``; const secs = this.getTraitorRemainingSeconds(other); const label = secs !== null ? renderDuration(secs) : null; const dotCls = secs !== null ? `mx-1 h-[4px] w-[4px] rounded-full bg-red-400/70 ${secs <= 10 ? "animate-pulse" : ""}` : ""; return html`