import { html, LitElement } from "lit"; import { customElement, state } from "lit/decorators.js"; import Countries from "resources/countries.json" with { type: "json" }; 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 { CloseViewEvent, MouseUpEvent, SwapRocketDirectionEvent, } from "../../InputHandler"; import { SendAllianceRequestIntentEvent, SendBreakAllianceIntentEvent, SendEmbargoAllIntentEvent, SendEmbargoIntentEvent, SendEmojiIntentEvent, 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 "./PlayerModerationModal"; import "./SendResourceModal"; import allianceIcon from "/images/AllianceIconWhite.svg?url"; import chatIcon from "/images/ChatIconWhite.svg?url"; import donateGoldIcon from "/images/DonateGoldIconWhite.svg?url"; import donateTroopIcon from "/images/DonateTroopIconWhite.svg?url"; import emojiIcon from "/images/EmojiIconWhite.svg?url"; import shieldIcon from "/images/ShieldIconWhite.svg?url"; import stopTradingIcon from "/images/StopIconWhite.png?url"; import targetIcon from "/images/TargetIconWhite.svg?url"; import startTradingIcon from "/images/TradingIconWhite.png?url"; import traitorIcon from "/images/TraitorIconLightRed.svg?url"; import breakAllianceIcon from "/images/TraitorIconWhite.svg?url"; @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; private kickedPlayerIDs = new Set(); @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; @state() private suppressNextHide: boolean = false; @state() private moderationTarget: PlayerView | null = null; private ctModal: ChatModal; createRenderRoot() { return this; } initEventBus(eventBus: EventBus) { this.eventBus = eventBus; eventBus.on(CloseViewEvent, (e) => { if (this.isVisible) { this.hide(); } }); eventBus.on(SwapRocketDirectionEvent, (event) => { this.uiState.rocketDirectionUp = event.rocketDirectionUp; this.requestUpdate(); }); } init() { this.eventBus.on(MouseUpEvent, () => { if (this.suppressNextHide) { this.suppressNextHide = false; return; } 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.moderationTarget = null; this.isVisible = true; this.requestUpdate(); } public openSendGoldModal( actions: PlayerActions, tile: TileRef, target: PlayerView, ) { this.suppressNextHide = true; this.actions = actions; this.tile = tile; this.sendTarget = target; this.sendMode = "gold"; this.moderationTarget = null; this.isVisible = true; this.requestUpdate(); } public hide() { this.isVisible = false; this.sendMode = "none"; this.sendTarget = null; this.moderationTarget = 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.suppressNextHide = true; this.sendTarget = target; this.sendMode = "troops"; } private openSendGold(target: PlayerView) { this.suppressNextHide = true; 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 openModeration(e: MouseEvent, other: PlayerView) { e.stopPropagation(); this.suppressNextHide = true; this.moderationTarget = other; } private closeModeration = () => { this.moderationTarget = null; }; private handleModerationKicked = (e: CustomEvent<{ playerId?: string }>) => { const playerId = e.detail?.playerId; if (playerId) this.kickedPlayerIDs.add(String(playerId)); this.closeModeration(); this.hide(); }; private handleToggleRocketDirection(e: Event) { e.stopPropagation(); const next = !this.uiState.rocketDirectionUp; this.eventBus.emit(new SwapRocketDirectionEvent(next)); } 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 size-1 rounded-full bg-red-400/70 ${secs <= 10 ? "animate-pulse" : ""}` : ""; return html`
${translateText("player_panel.traitor")} ${label ? html` ${label} ` : ""}
`; } private renderModeration(my: PlayerView, other: PlayerView) { if (!my.isLobbyCreator()) return html``; const moderationTitle = translateText("player_panel.moderation"); return html`
${actionButton({ onClick: (e: MouseEvent) => this.openModeration(e, other), icon: shieldIcon, iconAlt: "Moderation", title: moderationTitle, label: moderationTitle, type: "red", })}
`; } 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 renderRocketDirectionToggle() { return html` `; } 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}
    ${alliesSorted.length === 0 ? html`
  • ${translateText("common.none")}
  • ` : alliesSorted.map( (p) => html`
  • ${p.name()}
  • `, )}
`; } 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; 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 ? html`` : html`
${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", }) : ""}
`} ${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, })}
` : ""} ${this.renderModeration(my, other)}
`; } 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.moderationTarget ? html` ` : ""} ${this.renderResources(other)} ${other === my ? this.renderRocketDirectionToggle() : ""} ${this.renderStats(other, my)} ${this.renderAlliances(other)} ${this.renderAllianceExpiry()} ${this.renderActions(my, other)}
`; } }