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`
${translateText("player_panel.traitor")} ${label ? html` ${label} ` : ""}
`; } private renderRelationPillIfNation(other: PlayerView, my: PlayerView) { if (other.type() !== PlayerType.Nation) return html``; if (other.isTraitor()) return html``; if (my?.isAlliedWith && my.isAlliedWith(other)) return html``; if (!this.otherProfile || !my) return html``; const relation = this.otherProfile.relations?.[my.smallID()] ?? Relation.Neutral; const cls = this.getRelationClass(relation); const name = this.getRelationName(relation); return html`
${name}
`; } private renderIdentityRow(other: PlayerView, my: PlayerView) { const flagCode = other.cosmetics.flag; const country = typeof flagCode === "string" ? Countries.find((c) => c.code === flagCode) : undefined; const chip = other.type() === PlayerType.Human ? null : this.identityChipProps(other.type()); return html`
${country && typeof flagCode === "string" ? html`${country?.name { (e.target as HTMLImageElement).style.display = "none"; }} />` : ""}

${other.name()}

${chip ? html` ${translateText(chip.labelKey)} ` : html``}
${this.renderTraitorBadge(other)} ${this.renderRelationPillIfNation(other, my)} `; } private renderResources(other: PlayerView) { return html`
💰 ${renderNumber(other.gold() || 0)} ${translateText("player_panel.gold")}
🛡️ ${renderTroops(other.troops() || 0)} ${translateText("player_panel.troops")}
`; } private renderStats(other: PlayerView, my: PlayerView) { return html`
${translateText("player_panel.betrayals")}
${other.data.betrayals ?? 0}
${translateText("player_panel.trading")}
${other.hasEmbargoAgainst(my) ? html`${translateText("player_panel.stopped")}` : html`${translateText("player_panel.active")}`}
`; } private renderAlliances(other: PlayerView) { const allies = other.allies(); const nameCollator = new Intl.Collator(undefined, { sensitivity: "base" }); const alliesSorted = [...allies].sort((a, b) => nameCollator.compare(a.name(), b.name()), ); return html`
${translateText("player_panel.alliances")}
${allies.length}
`; } private renderAllianceExpiry() { if (this.allianceExpiryText === null) return html``; return html`
${translateText("player_panel.alliance_time_remaining")}
${this.allianceExpiryText}
`; } private renderActions(my: PlayerView, other: PlayerView) { const myPlayer = this.g.myPlayer(); const canDonateGold = this.actions?.interaction?.canDonateGold; const canDonateTroops = this.actions?.interaction?.canDonateTroops; const canSendAllianceRequest = this.actions?.interaction?.canSendAllianceRequest; const canSendEmoji = other === myPlayer ? this.actions?.canSendEmojiAllPlayers : this.actions?.interaction?.canSendEmoji; const canBreakAlliance = this.actions?.interaction?.canBreakAlliance; const canTarget = this.actions?.interaction?.canTarget; const canEmbargo = this.actions?.interaction?.canEmbargo; const canKick = this.actions?.interaction?.canKick; return html`
${actionButton({ onClick: (e: MouseEvent) => this.handleChat(e, my, other), icon: chatIcon, iconAlt: "Chat", title: translateText("player_panel.chat"), label: translateText("player_panel.chat"), })} ${canSendEmoji ? actionButton({ onClick: (e: MouseEvent) => this.handleEmojiClick(e, my, other), icon: emojiIcon, iconAlt: "Emoji", title: translateText("player_panel.emotes"), label: translateText("player_panel.emotes"), type: "normal", }) : ""} ${canTarget ? actionButton({ onClick: (e: MouseEvent) => this.handleTargetClick(e, other), icon: targetIcon, iconAlt: "Target", title: translateText("player_panel.target"), label: translateText("player_panel.target"), type: "normal", }) : ""} ${canDonateTroops ? actionButton({ onClick: (e: MouseEvent) => this.handleDonateTroopClick(e, my, other), icon: donateTroopIcon, iconAlt: "Troops", title: translateText("player_panel.send_troops"), label: translateText("player_panel.troops"), type: "normal", }) : ""} ${canDonateGold ? actionButton({ onClick: (e: MouseEvent) => this.handleDonateGoldClick(e, my, other), icon: donateGoldIcon, iconAlt: "Gold", title: translateText("player_panel.send_gold"), label: translateText("player_panel.gold"), type: "normal", }) : ""}
${other !== my ? canEmbargo ? actionButton({ onClick: (e: MouseEvent) => this.handleEmbargoClick(e, my, other), icon: stopTradingIcon, iconAlt: "Stop Trading", title: translateText("player_panel.stop_trade"), label: translateText("player_panel.stop_trade"), type: "yellow", }) : actionButton({ onClick: (e: MouseEvent) => this.handleStopEmbargoClick(e, my, other), icon: startTradingIcon, iconAlt: "Start Trading", title: translateText("player_panel.start_trade"), label: translateText("player_panel.start_trade"), type: "green", }) : ""} ${canBreakAlliance ? actionButton({ onClick: (e: MouseEvent) => this.handleBreakAllianceClick(e, my, other), icon: breakAllianceIcon, iconAlt: "Break Alliance", title: translateText("player_panel.break_alliance"), label: translateText("player_panel.break_alliance"), type: "red", }) : ""} ${canSendAllianceRequest ? actionButton({ onClick: (e: MouseEvent) => this.handleAllianceClick(e, my, other), icon: allianceIcon, iconAlt: "Alliance", title: translateText("player_panel.send_alliance"), label: translateText("player_panel.send_alliance"), type: "indigo", }) : ""} ${canKick && !this.g.isLobbyCreator(other) ? actionButton({ onClick: (e: MouseEvent) => this.handleKickClick(e, other), icon: disabledIcon, iconAlt: "Kick", title: translateText("player_panel.kick"), label: translateText("player_panel.kick"), type: "red", }) : ""}
${other === my ? html`
${actionButton({ onClick: (e: MouseEvent) => this.onStopTradingAllClick(e), icon: stopTradingIcon, iconAlt: "Stop Trading With All", title: !this.actions?.canEmbargoAll ? `${translateText("player_panel.stop_trade_all")} - ${translateText("cooldown")}` : translateText("player_panel.stop_trade_all"), label: !this.actions?.canEmbargoAll ? `${translateText("player_panel.stop_trade_all")} ⏳` : translateText("player_panel.stop_trade_all"), type: "yellow", disabled: !this.actions?.canEmbargoAll, })} ${actionButton({ onClick: (e: MouseEvent) => this.onStartTradingAllClick(e), icon: startTradingIcon, iconAlt: "Start Trading With All", title: !this.actions?.canEmbargoAll ? `${translateText("player_panel.start_trade_all")} - ${translateText("cooldown")}` : translateText("player_panel.start_trade_all"), label: !this.actions?.canEmbargoAll ? `${translateText("player_panel.start_trade_all")} ⏳` : translateText("player_panel.start_trade_all"), type: "green", disabled: !this.actions?.canEmbargoAll, })}
` : ""}
`; } render() { if (!this.isVisible) return html``; const my = this.g.myPlayer(); if (!my) return html``; if (!this.tile) return html``; const owner = this.g.owner(this.tile); if (!owner || !owner.isPlayer()) { this.hide(); console.warn("Tile is not owned by a player"); return html``; } const other = owner as PlayerView; const myGoldNum = my.gold(); const myTroopsNum = Number(my.troops()); return html`
e.preventDefault()} @wheel=${(e: MouseEvent) => e.stopPropagation()} @click=${() => this.hide()} >
e.stopPropagation()} >
${this.renderIdentityRow(other, my)}
${this.sendTarget ? html` ` : ""} ${this.renderResources(other)} ${this.renderStats(other, my)} ${this.renderAlliances(other)} ${this.renderAllianceExpiry()} ${this.renderActions(my, other)}
`; } }