Improve player panel (#2060)

## Description:

Fixes #2015
Improved the Player Panel UI for better usability and appearance.

**Screenshots**

<img width="334" height="523" alt="2"
src="https://github.com/user-attachments/assets/bd0afaac-07df-4abc-a20f-208a0783e558"
/>

<img width="337" height="523" alt="3"
src="https://github.com/user-attachments/assets/f712ad77-4546-487b-9a9c-2c535b8a45f7"
/>

**Future Plan**

Add a modal for sending gold and troops to other players from the Player
Panel.

<img width="343" height="494" alt="sending troops"
src="https://github.com/user-attachments/assets/9c9c21db-e13a-426f-93e9-b477a9db442a"
/>


## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

## Please put your Discord username so you can be contacted if a bug or
regression is found:

abodcraft1

---------

Co-authored-by: evanpelle <evanpelle@gmail.com>
This commit is contained in:
Abdallah Bahrawi
2025-10-06 22:45:30 +03:00
committed by GitHub
parent 715775065d
commit 175d492b99
12 changed files with 841 additions and 328 deletions
+69
View File
@@ -0,0 +1,69 @@
import { html, TemplateResult } from "lit";
export type ButtonVariant =
| "normal"
| "red"
| "green"
| "indigo"
| "yellow"
| "sky";
export interface ActionButtonProps {
onClick: (e: MouseEvent) => void;
type?: ButtonVariant;
icon: string;
iconAlt: string;
title: string;
label: string;
disabled?: boolean;
}
const ICON_SIZE =
"h-5 w-5 shrink-0 transition-transform group-hover:scale-110 text-zinc-400";
const TEXT_SIZE =
"text-base sm:text-[14px] leading-5 font-semibold tracking-tight";
const getButtonStyles = () => {
const btnBase =
"group w-full min-w-[50px] select-none flex flex-col items-center justify-center " +
"gap-1 rounded-lg py-1.5 border border-white/10 bg-white/[0.04] shadow-sm " +
"transition-all duration-150 " +
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/20 " +
"active:translate-y-[1px]";
return {
normal: `${btnBase} text-white/90 hover:bg-white/10 hover:text-white`,
red: `${btnBase} text-red-400 hover:bg-red-500/10 hover:text-red-300 focus-visible:ring-red-400/30`,
green: `${btnBase} text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300 focus-visible:ring-emerald-400/30`,
yellow: `${btnBase} text-[#f59e0b] hover:bg-[#f59e0b]/10 hover:text-[#fbbf24] focus-visible:ring-[#f59e0b]/30`,
indigo: `${btnBase} text-indigo-400 hover:bg-indigo-500/10 hover:text-indigo-300 focus-visible:ring-indigo-400/30`,
sky: `${btnBase} text-[#38bdf8] hover:bg-[#38bdf8]/10 hover:text-[#0ea5e9] focus-visible:ring-[#38bdf8]/30`,
};
};
export const actionButton = (props: ActionButtonProps): TemplateResult => {
const {
onClick,
type = "normal",
icon,
iconAlt,
title,
label,
disabled = false,
} = props;
const buttonStyles = getButtonStyles();
const buttonClass = buttonStyles[type];
return html`
<button
@click=${onClick}
class="${buttonClass}"
title="${title}"
type="button"
aria-label="${title}"
?disabled=${disabled}
>
<img src=${icon} alt=${iconAlt} aria-hidden="true" class="${ICON_SIZE}" />
<span class="${TEXT_SIZE}">${label}</span>
</button>
`;
};
+33
View File
@@ -0,0 +1,33 @@
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
export type DividerSpacing = "sm" | "md" | "lg";
@customElement("ui-divider")
export class Divider extends LitElement {
@property({ type: String })
spacing: DividerSpacing = "md";
@property({ type: String })
color: string = "bg-zinc-700/80";
createRenderRoot() {
return this;
}
render() {
const spacingClasses: Record<DividerSpacing, string> = {
sm: "my-0.5",
md: "my-1",
lg: "my-2",
} as const;
const spacing = spacingClasses[this.spacing] ?? spacingClasses.md;
const colorClass = this.color || "bg-zinc-700/80";
return html`<div
role="separator"
aria-hidden="true"
class="${spacing} h-px ${colorClass}"
></div>`;
}
}
@@ -268,13 +268,13 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
let playerType = "";
switch (player.type()) {
case PlayerType.Bot:
playerType = translateText("player_info_overlay.bot");
playerType = translateText("player_type.bot");
break;
case PlayerType.FakeHuman:
playerType = translateText("player_info_overlay.nation");
playerType = translateText("player_type.nation");
break;
case PlayerType.Human:
playerType = translateText("player_info_overlay.player");
playerType = translateText("player_type.player");
break;
}
+599 -305
View File
@@ -5,14 +5,24 @@ import chatIcon from "../../../../resources/images/ChatIconWhite.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 traitorIcon from "../../../../resources/images/TraitorIconWhite.svg";
import { translateText } from "../../../client/Utils";
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 } from "../../../core/game/Game";
import {
AllPlayers,
PlayerActions,
PlayerProfile,
PlayerType,
Relation,
} from "../../../core/game/Game";
import { TileRef } from "../../../core/game/GameMap";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { 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 {
@@ -24,7 +34,12 @@ import {
SendEmojiIntentEvent,
SendTargetPlayerIntentEvent,
} from "../../Transport";
import { renderDuration, renderNumber, renderTroops } from "../../Utils";
import {
renderDuration,
renderNumber,
renderTroops,
translateText,
} from "../../Utils";
import { UIState } from "../UIState";
import { ChatModal } from "./ChatModal";
import { EmojiTable } from "./EmojiTable";
@@ -36,9 +51,9 @@ export class PlayerPanel extends LitElement implements Layer {
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()
public isVisible: boolean = false;
@@ -46,6 +61,74 @@ export class PlayerPanel extends LitElement implements Layer {
@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;
@@ -149,6 +232,13 @@ export class PlayerPanel extends LitElement implements Layer {
}
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();
}
@@ -159,68 +249,338 @@ export class PlayerPanel extends LitElement implements Layer {
this.hide();
}
createRenderRoot() {
return this;
}
private ctModal: ChatModal;
initEventBus(eventBus: EventBus) {
this.eventBus = eventBus;
eventBus.on(CloseViewEvent, (e) => {
if (!this.hidden) {
this.hide();
}
});
}
init() {
this.eventBus.on(MouseUpEvent, () => this.hide());
this.eventBus.on(CloseViewEvent, (e) => {
this.hide();
});
this.ctModal = document.querySelector("chat-modal") as ChatModal;
}
async tick() {
if (this.isVisible && this.tile) {
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();
if (remainingTicks > 0) {
const remainingSeconds = Math.max(
0,
Math.floor(remainingTicks / 10),
); // 10 ticks per second
this.allianceExpiryText = renderDuration(remainingSeconds);
}
} else {
this.allianceExpiryText = null;
}
this.requestUpdate();
}
private identityChipProps(type: PlayerType) {
switch (type) {
case PlayerType.FakeHuman:
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: "👤",
};
}
}
render() {
if (!this.isVisible) {
return html``;
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";
if (seconds <= 30) return "text-red-400";
if (seconds <= 60) return "text-yellow-400";
return "text-emerald-400";
}
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`
<div class="mt-1" role="status" aria-live="polite" aria-atomic="true">
<span
class="inline-flex items-center gap-2 rounded-full border border-red-400/30
bg-red-500/10 px-2.5 py-0.5 text-sm font-semibold text-red-200
shadow-[inset_0_0_8px_rgba(239,68,68,0.12)]"
title=${translateText("player_panel.traitor")}
>
<img
src=${traitorIcon}
alt=""
aria-hidden="true"
class="h-[18px] w-[18px]"
/>
<span class="tracking-tight"
>${translateText("player_panel.traitor")}</span
>
${label
? html`<span class=${dotCls}></span>
<span
class="tabular-nums font-bold text-red-100 whitespace-nowrap text-sm"
>
${label}
</span>`
: ""}
</span>
</div>
`;
}
private renderRelationPillIfNation(other: PlayerView, my: PlayerView) {
if (other.type() !== PlayerType.FakeHuman) 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`
<div class="mt-1">
<span class="text-sm font-semibold ${cls}">${name}</span>
</div>
`;
}
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`
<div class="flex items-center gap-2.5 flex-wrap">
${country && typeof flagCode === "string"
? html`<img
src="/flags/${encodeURIComponent(flagCode)}.svg"
alt=${country?.name || "Flag"}
class="h-10 w-10 rounded-full object-cover"
@error=${(e: Event) => {
(e.target as HTMLImageElement).style.display = "none";
}}
/>`
: ""}
<h1 class="text-2xl font-bold tracking-[-0.01em] truncate text-zinc-50">
${other.name()}
</h1>
${chip
? html`<span
class=${`inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-xs font-semibold ${chip.classes}`}
role="status"
aria-label=${chip.aria}
title=${translateText(chip.labelKey)}
>
<span aria-hidden="true" class="leading-none">${chip.icon}</span>
<span class="tracking-tight"
>${translateText(chip.labelKey)}</span
>
</span>`
: html``}
</div>
${this.renderTraitorBadge(other)}
${this.renderRelationPillIfNation(other, my)}
`;
}
private renderResources(other: PlayerView) {
return html`
<div class="mb-1 flex justify-between gap-2">
<div
class="inline-flex items-center gap-1.5 rounded-full bg-zinc-800 px-2.5 py-1
text-lg font-semibold text-zinc-100"
>
<span class="mr-0.5">💰</span>
<span
translate="no"
class="inline-block w-[45px] text-right text-zinc-50"
>
${renderNumber(other.gold() || 0)}
</span>
<span class="opacity-95">${translateText("player_panel.gold")}</span>
</div>
<div
class="inline-flex items-center gap-1.5 rounded-full bg-zinc-800 px-2.5 py-1
text-lg font-semibold text-zinc-100"
>
<span class="mr-0.5">🛡️</span>
<span
translate="no"
class="inline-block w-[45px] text-right text-zinc-50"
>
${renderTroops(other.troops() || 0)}
</span>
<span class="opacity-95"
>${translateText("player_panel.troops")}</span
>
</div>
</div>
`;
}
private renderStats(other: PlayerView, my: PlayerView) {
return html`
<!-- Betrayals -->
<div class="grid grid-cols-[auto,1fr] gap-x-6 gap-y-2 text-base">
<div
class="flex items-center gap-2 font-semibold text-zinc-300 leading-snug"
>
<span aria-hidden="true">⚠️</span>
<span>${translateText("player_panel.betrayals")}</span>
</div>
<div class="text-right font-semibold text-zinc-200">
${other.data.betrayals ?? 0}
</div>
</div>
<!-- Trading / Embargo -->
<div class="grid grid-cols-[auto,1fr] gap-x-6 gap-y-2 text-base">
<div
class="flex items-center gap-2 font-semibold text-zinc-300 leading-snug"
>
<span aria-hidden="true">⚓</span>
<span>${translateText("player_panel.trading")}</span>
</div>
<div class="flex items-center justify-end gap-2 font-semibold">
${other.hasEmbargoAgainst(my)
? html`<span class="text-[#f59e0b]"
>${translateText("player_panel.stopped")}</span
>`
: html`<span class="text-emerald-400"
>${translateText("player_panel.active")}</span
>`}
</div>
</div>
`;
}
private renderAlliances(other: PlayerView) {
const allies = other.allies();
return html`
<div class="text-base select-none">
<!-- Header -->
<div class="flex items-center justify-between mb-2">
<div class="font-semibold text-zinc-300 text-base">
${translateText("player_panel.alliances")}
</div>
<span
aria-label="Alliance count"
class="inline-flex items-center justify-center min-w-[20px] h-5 px-[6px] rounded-[10px]
text-[12px] text-zinc-100 bg-white/10 border border-white/20"
>
${allies.length}
</span>
</div>
<div class="mt-1 rounded-lg border border-zinc-600 bg-zinc-800/80">
<div
class="max-h-[72px] overflow-y-auto p-2 text-zinc-200 text-[12.5px] leading-relaxed"
role="list"
aria-label="Alliance list"
translate="no"
>
${allies.length > 0
? allies.map((p) => {
const color = p.territoryColor().toHex();
return html`
<div
role="listitem"
class="grid grid-cols-[16px_1fr] items-center gap-2 w-full h-[30px]
px-2 rounded-lg border border-transparent text-left
hover:bg-[#141821] hover:border-white/30 transition-colors"
title=${p.name()}
>
<span
class="inline-block w-3 h-3 rounded-full mr-2"
style="background-color: ${color}"
>
</span>
<span
class="truncate select-none pointer-events-none font-medium"
>
${p.name()}
</span>
</div>
`;
})
: html`
<div class="py-2 text-zinc-300">
${translateText("player_panel.none")}
</div>
`}
</div>
</div>
</div>
`;
}
private renderAllianceExpiry() {
if (this.allianceExpiryText === null) return html``;
return html`
<div class="grid grid-cols-[auto,1fr] gap-x-6 gap-y-2 text-base">
<div class="font-semibold text-zinc-400">
${translateText("player_panel.alliance_time_remaining")}
</div>
<div class="text-right font-semibold">
<span
class="inline-flex items-center rounded-full px-2 py-0.5 text-base font-bold ${this.getExpiryColorClass(
this.allianceExpirySeconds,
)}"
>${this.allianceExpiryText}</span
>
</div>
</div>
`;
}
private renderActions(my: PlayerView, other: PlayerView) {
const myPlayer = this.g.myPlayer();
if (myPlayer === null) return;
if (this.tile === null) return;
let other = this.g.owner(this.tile);
if (!other.isPlayer()) {
this.hide();
console.warn("Tile is not owned by a player");
return;
}
other = other as PlayerView;
const canDonateGold = this.actions?.interaction?.canDonateGold;
const canDonateTroops = this.actions?.interaction?.canDonateTroops;
const canSendAllianceRequest =
@@ -233,273 +593,207 @@ export class PlayerPanel extends LitElement implements Layer {
const canTarget = this.actions?.interaction?.canTarget;
const canEmbargo = this.actions?.interaction?.canEmbargo;
//flag icon in the playerPanel
const flagCode = other.cosmetics.flag;
const country =
typeof flagCode === "string"
? Countries.find((c) => c.code === flagCode)
: undefined;
const flagName = country?.name;
return html`
<div class="flex flex-col gap-2">
<div class="grid auto-cols-fr grid-flow-col gap-1">
${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",
})
: ""}
</div>
<div class="grid auto-cols-fr grid-flow-col gap-1">
${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",
})
: ""}
</div>
</div>
`;
}
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;
return html`
<style>
/* Soft glowing ring animation for traitors */
.traitor-ring {
border-radius: 1rem;
box-shadow:
0 0 0 2px rgba(239, 68, 68, 0.34),
0 0 12px 4px rgba(239, 68, 68, 0.22),
inset 0 0 14px rgba(239, 68, 68, 0.13);
animation: glowPulse 2.4s ease-in-out infinite;
}
@keyframes glowPulse {
0%,
100% {
box-shadow:
0 0 0 2px rgba(239, 68, 68, 0.22),
0 0 8px 2px rgba(239, 68, 68, 0.15),
inset 0 0 8px rgba(239, 68, 68, 0.07);
}
50% {
box-shadow:
0 0 0 4px rgba(239, 68, 68, 0.38),
0 0 18px 6px rgba(239, 68, 68, 0.26),
inset 0 0 18px rgba(239, 68, 68, 0.15);
}
}
</style>
<div
class="fixed inset-0 flex items-center justify-center z-[1001] pointer-events-none overflow-auto"
class="fixed inset-0 z-[1001] flex items-center justify-center overflow-auto
bg-black/40 backdrop-blur-sm backdrop-brightness-110 pointer-events-auto"
@contextmenu=${(e: MouseEvent) => e.preventDefault()}
@wheel=${(e: MouseEvent) => e.stopPropagation()}
@click=${() => this.hide()}
>
<!-- Stop clicks inside the panel from closing it -->
<div
class="pointer-events-auto max-h-[90vh] overflow-y-auto min-w-[240px] w-auto px-4 py-2"
@click=${(e: MouseEvent) => e.stopPropagation()}
>
<div
class="bg-opacity-60 bg-gray-900 p-1 lg:p-2 rounded-lg backdrop-blur-md relative w-full mt-2"
class=${`relative mt-2 w-full bg-zinc-900/90 backdrop-blur-sm p-5 shadow-2xl rounded-xl text-zinc-200
${other.isTraitor() ? "traitor-ring" : "ring-1 ring-zinc-700"}`}
>
<!-- Close button -->
<button
@click=${this.handleClose}
class="absolute -top-2 -right-2 w-6 h-6 flex items-center justify-center
bg-red-500 hover:bg-red-600 text-white rounded-full
text-sm font-bold transition-colors"
class="absolute -top-3 -right-3 flex h-7 w-7 items-center justify-center
rounded-full bg-zinc-700 text-white shadow hover:bg-red-500 transition-colors"
aria-label=${translateText("player_panel.close") || "Close"}
title=${translateText("player_panel.close") || "Close"}
>
</button>
<div class="flex flex-col gap-2 min-w-[240px]">
<!-- Name section -->
<div class="flex items-center gap-1 lg:gap-2">
<div
class="px-4 h-8 lg:h-10 flex items-center justify-center
bg-opacity-50 bg-gray-700 text-opacity-90 text-white
rounded text-sm lg:text-xl w-full"
>
${other?.name()}
</div>
</div>
<!-- Flag -->
${country
? html`
<div>
<div class="text-white text-opacity-80 text-sm px-2">
${translateText("player_panel.flag")}
</div>
<div
class="px-4 h-8 lg:h-10 flex items-center justify-center gap-4
bg-opacity-50 bg-gray-700 text-opacity-90 text-white
rounded text-sm lg:text-xl w-full"
>
${flagName}
<img
src="/flags/${flagCode}.svg"
width="60"
height="60"
/>
</div>
</div>
`
: ""}
<!-- Resources section -->
<div class="grid grid-cols-2 gap-2">
<div class="flex flex-col gap-1">
<!-- Gold -->
<div class="text-white text-opacity-80 text-sm px-2">
${translateText("player_panel.gold")}
</div>
<div
class="bg-opacity-50 bg-gray-700 rounded p-2 text-white"
translate="no"
>
${renderNumber(other.gold() || 0)}
</div>
</div>
<div class="flex flex-col gap-1">
<!-- Troops -->
<div class="text-white text-opacity-80 text-sm px-2">
${translateText("player_panel.troops")}
</div>
<div
class="bg-opacity-50 bg-gray-700 rounded p-2 text-white"
translate="no"
>
${renderTroops(other.troops() || 0)}
</div>
</div>
</div>
<div
class="flex flex-col gap-2 font-sans antialiased text-[14px] leading-relaxed"
>
<!-- Identity (flag, name, type, traitor, relation) -->
<div class="mb-1">${this.renderIdentityRow(other, my)}</div>
<!-- Attitude section -->
<div class="flex flex-col gap-1">
<div class="text-white text-opacity-80 text-sm px-2">
${translateText("player_panel.traitor")}
</div>
<div class="bg-opacity-50 bg-gray-700 rounded p-2 text-white">
${other.isTraitor()
? translateText("player_panel.yes")
: translateText("player_panel.no")}
</div>
</div>
<ui-divider></ui-divider>
<!-- Betrayals -->
<div class="flex flex-col gap-1">
<div class="text-white text-opacity-80 text-sm px-2">
${translateText("player_panel.betrayals")}
</div>
<div class="bg-opacity-50 bg-gray-700 rounded p-2 text-white">
${other.data.betrayals ?? 0}
</div>
</div>
<!-- Resources -->
${this.renderResources(other)}
<!-- Embargo -->
<div class="flex flex-col gap-1">
<div class="text-white text-opacity-80 text-sm px-2">
${translateText("player_panel.embargo")}
</div>
<div class="bg-opacity-50 bg-gray-700 rounded p-2 text-white">
${other.hasEmbargoAgainst(myPlayer)
? translateText("player_panel.yes")
: translateText("player_panel.no")}
</div>
</div>
<ui-divider></ui-divider>
<!-- Alliances -->
<div class="flex flex-col gap-1">
<div class="text-white text-opacity-80 text-sm px-2">
${translateText("player_panel.alliances")}
(${other.allies().length})
</div>
<div
class="bg-opacity-50 bg-gray-700 rounded p-2 text-white max-w-72 max-h-20 overflow-y-auto"
translate="no"
>
${other.allies().length > 0
? other
.allies()
.map((p) => p.name())
.join(", ")
: translateText("player_panel.none")}
</div>
</div>
<!-- Stats: betrayals / trading -->
${this.renderStats(other, my)}
${this.allianceExpiryText !== null
? html`
<div class="flex flex-col gap-1">
<div class="text-white text-opacity-80 text-sm px-2">
${translateText("player_panel.alliance_time_remaining")}
</div>
<div
class="bg-opacity-50 bg-gray-700 rounded p-2 text-white"
>
${this.allianceExpiryText}
</div>
</div>
`
: ""}
<ui-divider></ui-divider>
<!-- Action buttons -->
<div class="flex justify-center gap-2">
<button
@click=${(e: MouseEvent) =>
this.handleChat(e, myPlayer, other)}
class="w-10 h-10 flex items-center justify-center
bg-opacity-50 bg-gray-700 hover:bg-opacity-70
text-white rounded-lg transition-colors"
>
<img src=${chatIcon} alt="Target" class="w-6 h-6" />
</button>
${canTarget
? html`<button
@click=${(e: MouseEvent) =>
this.handleTargetClick(e, other)}
class="w-10 h-10 flex items-center justify-center
bg-opacity-50 bg-gray-700 hover:bg-opacity-70
text-white rounded-lg transition-colors"
>
<img src=${targetIcon} alt="Target" class="w-6 h-6" />
</button>`
: ""}
${canBreakAlliance
? html`<button
@click=${(e: MouseEvent) =>
this.handleBreakAllianceClick(e, myPlayer, other)}
class="w-10 h-10 flex items-center justify-center
bg-opacity-50 bg-gray-700 hover:bg-opacity-70
text-white rounded-lg transition-colors"
>
<img
src=${traitorIcon}
alt="Break Alliance"
class="w-6 h-6"
/>
</button>`
: ""}
${canSendAllianceRequest
? html`<button
@click=${(e: MouseEvent) =>
this.handleAllianceClick(e, myPlayer, other)}
class="w-10 h-10 flex items-center justify-center
bg-opacity-50 bg-gray-700 hover:bg-opacity-70
text-white rounded-lg transition-colors"
>
<img src=${allianceIcon} alt="Alliance" class="w-6 h-6" />
</button>`
: ""}
${canDonateTroops
? html`<button
@click=${(e: MouseEvent) =>
this.handleDonateTroopClick(e, myPlayer, other)}
class="w-10 h-10 flex items-center justify-center
bg-opacity-50 bg-gray-700 hover:bg-opacity-70
text-white rounded-lg transition-colors"
>
<img
src=${donateTroopIcon}
alt="Donate"
class="w-6 h-6"
/>
</button>`
: ""}
${canDonateGold
? html`<button
@click=${(e: MouseEvent) =>
this.handleDonateGoldClick(e, myPlayer, other)}
class="w-10 h-10 flex items-center justify-center
bg-opacity-50 bg-gray-700 hover:bg-opacity-70
text-white rounded-lg transition-colors"
>
<img src=${donateGoldIcon} alt="Donate" class="w-6 h-6" />
</button>`
: ""}
${canSendEmoji
? html`<button
@click=${(e: MouseEvent) =>
this.handleEmojiClick(e, myPlayer, other)}
class="w-10 h-10 flex items-center justify-center
bg-opacity-50 bg-gray-700 hover:bg-opacity-70
text-white rounded-lg transition-colors"
>
<img src=${emojiIcon} alt="Emoji" class="w-6 h-6" />
</button>`
: ""}
</div>
${canEmbargo && other !== myPlayer
? html`<button
@click=${(e: MouseEvent) =>
this.handleEmbargoClick(e, myPlayer, other)}
class="w-100 h-10 flex items-center justify-center
bg-opacity-50 bg-gray-700 hover:bg-opacity-70
text-white rounded-lg transition-colors"
>
${translateText("player_panel.stop_trade")}
</button>`
: ""}
${!canEmbargo && other !== myPlayer
? html`<button
@click=${(e: MouseEvent) =>
this.handleStopEmbargoClick(e, myPlayer, other)}
class="w-100 h-10 flex items-center justify-center
bg-opacity-50 bg-gray-700 hover:bg-opacity-70
text-white rounded-lg transition-colors"
>
${translateText("player_panel.start_trade")}
</button>`
: ""}
<!-- Alliances list -->
${this.renderAlliances(other)}
<!-- Alliance time remaining -->
${this.renderAllianceExpiry()}
<ui-divider></ui-divider>
<!-- Actions -->
${this.renderActions(my, other)}
</div>
</div>
</div>
+1
View File
@@ -160,6 +160,7 @@ export interface PlayerUpdate {
allies: number[];
embargoes: Set<PlayerID>;
isTraitor: boolean;
traitorRemainingTicks?: number;
targets: number[];
outgoingEmojis: EmojiMessage[];
outgoingAttacks: AttackUpdate[];
+3
View File
@@ -404,6 +404,9 @@ export class PlayerView {
isTraitor(): boolean {
return this.data.isTraitor;
}
getTraitorRemainingTicks(): number {
return Math.max(0, this.data.traitorRemainingTicks ?? 0);
}
outgoingEmojis(): EmojiMessage[] {
return this.data.outgoingEmojis;
}
+10 -5
View File
@@ -141,6 +141,7 @@ export class PlayerImpl implements Player {
allies: this.alliances().map((a) => a.other(this).smallID()),
embargoes: new Set([...this.embargoes.keys()].map((p) => p.toString())),
isTraitor: this.isTraitor(),
traitorRemainingTicks: this.getTraitorRemainingTicks(),
targets: this.targets().map((p) => p.smallID()),
outgoingEmojis: this.outgoingEmojis(),
outgoingAttacks: this._outgoingAttacks.map((a) => {
@@ -418,11 +419,15 @@ export class PlayerImpl implements Player {
}
isTraitor(): boolean {
return (
this.markedTraitorTick >= 0 &&
this.mg.ticks() - this.markedTraitorTick <
this.mg.config().traitorDuration()
);
return this.getTraitorRemainingTicks() > 0;
}
getTraitorRemainingTicks(): number {
if (this.markedTraitorTick < 0) return 0;
const elapsed = this.mg.ticks() - this.markedTraitorTick;
const duration = this.mg.config().traitorDuration();
const remaining = duration - elapsed;
return remaining > 0 ? remaining : 0;
}
markTraitor(): void {