mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-26 14:54:36 +00:00
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:
@@ -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>
|
||||
`;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -160,6 +160,7 @@ export interface PlayerUpdate {
|
||||
allies: number[];
|
||||
embargoes: Set<PlayerID>;
|
||||
isTraitor: boolean;
|
||||
traitorRemainingTicks?: number;
|
||||
targets: number[];
|
||||
outgoingEmojis: EmojiMessage[];
|
||||
outgoingAttacks: AttackUpdate[];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user