mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 07:40:43 +00:00
aa4b490e68
## Summary The WebGL renderer was adapted from an external extension and carried a lot of machinery this integration never uses (replay playback, its own input/event system, a GL radial menu). This PR is two mechanical cleanup passes with **no behavior change**: delete the dead code, then untangle the `GameView` naming collision. **78 files, +142 / −2,197.** ### Pass 1 — remove dead extension baggage - **Replay/copy mode**: `FrameData.tileMode` was hard-coded `"live"`; the copy branches in `frame/Upload.ts`, `UploadOptions` (never passed), `applyFullFrame`/`applyFullTiles`/`applyDelta` on the facade and `GPURenderer`, `HeatManager.resetForSeek`, and the seek-upload methods on `TerritoryPass`/`TrailPass` were all unreachable. Also deletes `types/Replay.ts`, `types/FrameSource.ts`, `types/GameUpdates.ts`, `types/Game.ts` (imported only by the types barrel). - **FrameEvents**: trimmed from 14 fields to the 3 actually populated and read (`deadUnits`, `conquestEvents`, `bonusEvents`). The other 11 fed the extension's stats system and were never written or read here. - **GL radial menu**: `RadialMenuPass`, its 4 shaders, and ~10 API methods on facade + renderer had zero callers — the game uses the DOM/d3 radial menu in `hud/layers/RadialMenu.ts`. The pass was constructed and drawn every frame for nothing. - **Facade event system**: `GameViewEventMap` defined 10 event types (`click`, `hover`, `scroll`, …) but only `contextrestored` was ever emitted — input actually flows through `InputHandler` → EventBus → controllers. Replaced the listener map with a single `onContextRestored` callback and deleted `Events.ts`. Also fixed the stale header comment claiming the facade handles user interaction. - **Unused API surface**: removed ~20 facade/renderer methods with zero callers (camera passthroughs like `panTo`/`zoomTo`/`fitMap`/`screenToWorld`, hit-testing queries, SAM replay setters, `setSelectedUnit`, `clearFx`/`setFxTimeFn`, `onFrame`/`afterRender`/fps tracking). Deliberately left alone: `Camera`'s pan/zoom primitives (building blocks for a possible future camera unification) and the `timeFn` plumbing inside the FX passes (deeply embedded as defaults; only the dead renderer-level wrappers were removed). ### Pass 2 — untangle the three GameViews - `render/gl/GameView.ts` → **`MapRenderer.ts`** (class `MapRenderer`). Every importer was already aliasing it as `WebGLGameView` to dodge the collision with the simulation-mirror `GameView` in `client/view/`, so this removes aliasing rather than adding churn. `render/CLAUDE.md` updated. - Deleted the `src/core/game/GameView.ts` back-compat shim (its own TODO asked for this). All 51 importers now import from `src/client/view/` directly via a new 3-line barrel `view/index.ts`. ## Test plan - `tsc --noEmit` clean, `eslint` clean - Full test suite passes (1,385 + 65 server tests) - Manual verification via headless Chromium: started a singleplayer game and confirmed the renderer works end-to-end — terrain draws, spawn-phase overlay shows, territories fill with borders after spawning, player names/flags render, no renderer console errors 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1024 lines
34 KiB
TypeScript
1024 lines
34 KiB
TypeScript
import { html, LitElement } from "lit";
|
|
import { customElement, state } from "lit/decorators.js";
|
|
import Countries from "resources/countries.json" with { type: "json" };
|
|
import { assetUrl } from "../../../core/AssetUrls";
|
|
import { EventBus } from "../../../core/EventBus";
|
|
import {
|
|
AllPlayers,
|
|
PlayerActions,
|
|
PlayerProfile,
|
|
PlayerType,
|
|
Relation,
|
|
} from "../../../core/game/Game";
|
|
import { TileRef } from "../../../core/game/GameMap";
|
|
import { Emoji, flattenedEmojiTable } from "../../../core/Util";
|
|
import { actionButton } from "../../components/ui/ActionButton";
|
|
import "../../components/ui/Divider";
|
|
import { Controller } from "../../Controller";
|
|
import {
|
|
CloseViewEvent,
|
|
MouseUpEvent,
|
|
SwapRocketDirectionEvent,
|
|
} from "../../InputHandler";
|
|
import {
|
|
SendAllianceRequestIntentEvent,
|
|
SendBreakAllianceIntentEvent,
|
|
SendEmbargoAllIntentEvent,
|
|
SendEmbargoIntentEvent,
|
|
SendEmojiIntentEvent,
|
|
SendTargetPlayerIntentEvent,
|
|
} from "../../Transport";
|
|
import { UIState } from "../../UIState";
|
|
import {
|
|
renderDuration,
|
|
renderNumber,
|
|
renderTroops,
|
|
translateText,
|
|
} from "../../Utils";
|
|
import { GameView, PlayerView } from "../../view";
|
|
import { ChatModal } from "./ChatModal";
|
|
import { EmojiTable } from "./EmojiTable";
|
|
import "./PlayerModerationModal";
|
|
import "./SendResourceModal";
|
|
const allianceIcon = assetUrl("images/AllianceIconWhite.svg");
|
|
const chatIcon = assetUrl("images/ChatIconWhite.svg");
|
|
const donateGoldIcon = assetUrl("images/DonateGoldIconWhite.svg");
|
|
const donateTroopIcon = assetUrl("images/DonateTroopIconWhite.svg");
|
|
const emojiIcon = assetUrl("images/EmojiIconWhite.svg");
|
|
const shieldIcon = assetUrl("images/ShieldIconWhite.svg");
|
|
const stopTradingIcon = assetUrl("images/StopIconWhite.png");
|
|
const targetIcon = assetUrl("images/TargetIconWhite.svg");
|
|
const startTradingIcon = assetUrl("images/TradingIconWhite.png");
|
|
const traitorIcon = assetUrl("images/TraitorIconLightRed.svg");
|
|
const breakAllianceIcon = assetUrl("images/TraitorIconWhite.svg");
|
|
|
|
@customElement("player-panel")
|
|
export class PlayerPanel extends LitElement implements Controller {
|
|
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<string>();
|
|
|
|
@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;
|
|
@state() private playerRole: string | null = null;
|
|
|
|
setRole(role: string | null): void {
|
|
this.playerRole = role;
|
|
}
|
|
|
|
private get isAdminRole(): boolean {
|
|
return this.playerRole === "admin" || this.playerRole === "root";
|
|
}
|
|
|
|
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, null);
|
|
if (this.actions?.interaction?.allianceInfo?.expiresAt !== undefined) {
|
|
const expiresAt = this.actions.interaction.allianceInfo.expiresAt;
|
|
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",
|
|
classes: "border-indigo-400/25 bg-indigo-500/10 text-indigo-200",
|
|
icon: "🏛️",
|
|
};
|
|
case PlayerType.Bot:
|
|
return {
|
|
labelKey: "player_type.bot",
|
|
classes: "border-purple-400/25 bg-purple-500/10 text-purple-200",
|
|
icon: "⚔️",
|
|
};
|
|
case PlayerType.Human:
|
|
default:
|
|
return {
|
|
labelKey: "player_type.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.getTraitorRemainingTicks();
|
|
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`
|
|
<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="size-4.5" />
|
|
<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 renderModeration(
|
|
my: PlayerView,
|
|
other: PlayerView,
|
|
isAdmin: boolean,
|
|
) {
|
|
if (!my.isLobbyCreator() && !isAdmin) return html``;
|
|
const moderationTitle = translateText("player_panel.moderation");
|
|
|
|
return html`
|
|
<ui-divider></ui-divider>
|
|
<div class="grid auto-cols-fr grid-flow-col gap-1">
|
|
${actionButton({
|
|
onClick: (e: MouseEvent) => this.openModeration(e, other),
|
|
icon: shieldIcon,
|
|
iconAlt: "Moderation",
|
|
title: moderationTitle,
|
|
label: moderationTitle,
|
|
type: "red",
|
|
})}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
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`
|
|
<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=${assetUrl(`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";
|
|
}}
|
|
/>`
|
|
: ""}
|
|
|
|
<div class="flex-1 min-w-0">
|
|
<h2
|
|
class="text-xl font-bold tracking-[-0.01em] text-zinc-50 truncate"
|
|
title=${other.displayName()}
|
|
>
|
|
${other.displayName()}
|
|
</h2>
|
|
</div>
|
|
${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=${translateText(chip.labelKey)}
|
|
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-lg bg-white/4 px-3 py-1.5 shrink-0
|
|
text-white w-35"
|
|
>
|
|
<span class="mr-0.5">💰</span>
|
|
<span translate="no" class="tabular-nums w-[5ch] font-semibold">
|
|
${renderNumber(other.gold() || 0)}
|
|
</span>
|
|
<span class="text-zinc-200 whitespace-nowrap">
|
|
${translateText("player_panel.gold")}</span
|
|
>
|
|
</div>
|
|
|
|
<div
|
|
class="inline-flex items-center gap-1.5 rounded-lg bg-white/4 px-3 py-1.5
|
|
text-white w-35 shrink-0"
|
|
>
|
|
<span class="mr-0.5">🛡️</span>
|
|
<span translate="no" class="tabular-nums w-[5ch] font-semibold">
|
|
${renderTroops(other.troops() || 0)}
|
|
</span>
|
|
<span class="text-zinc-200 whitespace-nowrap">
|
|
${translateText("player_panel.troops")}</span
|
|
>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private renderRocketDirectionToggle() {
|
|
return html`
|
|
<ui-divider></ui-divider>
|
|
<button
|
|
class="flex w-full items-center justify-between rounded-xl bg-white/5 px-3 py-2 text-left text-white hover:bg-white/8 active:scale-[0.995] transition"
|
|
@click=${(e: Event) => this.handleToggleRocketDirection(e)}
|
|
>
|
|
<div class="flex flex-col">
|
|
<span class="text-sm font-semibold tracking-tight">
|
|
${translateText("player_panel.flip_rocket_trajectory")}
|
|
</span>
|
|
<span class="text-xs text-zinc-300" translate="no">
|
|
${this.uiState.rocketDirectionUp
|
|
? translateText("player_panel.arc_up")
|
|
: translateText("player_panel.arc_down")}
|
|
</span>
|
|
</div>
|
|
<span class="text-lg" aria-hidden="true">🔀</span>
|
|
</button>
|
|
`;
|
|
}
|
|
|
|
private renderStats(other: PlayerView, my: PlayerView) {
|
|
return html`
|
|
<!-- Betrayals -->
|
|
<div class="grid grid-cols-[auto_1fr] gap-x-6 gap-y-2">
|
|
<div
|
|
class="flex items-center gap-2 text-[15px] font-medium text-zinc-100 leading-snug"
|
|
>
|
|
<span aria-hidden="true">⚠️</span>
|
|
<span>${translateText("player_panel.betrayals")}</span>
|
|
</div>
|
|
<div class="text-right text-[14px] font-semibold text-zinc-200">
|
|
${other.betrayals()}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Trading / Embargo -->
|
|
<div class="grid grid-cols-[auto_1fr] gap-x-6 gap-y-2">
|
|
<div
|
|
class="flex items-center gap-2 text-[15px] font-medium text-zinc-100 leading-snug"
|
|
>
|
|
<span aria-hidden="true">⚓</span>
|
|
<span>${translateText("player_panel.trading")}</span>
|
|
</div>
|
|
<div
|
|
class="flex items-center justify-end gap-2 text-[14px] font-semibold"
|
|
>
|
|
${other.hasEmbargoAgainst(my)
|
|
? html`<span class="text-amber-400"
|
|
>${translateText("player_panel.stopped")}</span
|
|
>`
|
|
: html`<span class="text-blue-400"
|
|
>${translateText("player_panel.active")}</span
|
|
>`}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
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.displayName(), b.displayName()),
|
|
);
|
|
|
|
return html`
|
|
<div class="select-none">
|
|
<div class="flex items-center justify-between mb-2">
|
|
<div
|
|
id="alliances-title"
|
|
class="text-[15px] font-medium text-zinc-200"
|
|
>
|
|
${translateText("player_panel.alliances")}
|
|
</div>
|
|
<span
|
|
aria-labelledby="alliances-title"
|
|
class="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-[10px]
|
|
text-[12px] text-zinc-100 bg-white/10 border border-white/20"
|
|
>
|
|
${allies.length}
|
|
</span>
|
|
</div>
|
|
|
|
<div
|
|
class="rounded-lg bg-zinc-800/70 ring-1 ring-zinc-700/60 w-full min-w-0"
|
|
>
|
|
<ul
|
|
class="max-h-30 overflow-y-auto p-2
|
|
flex flex-wrap gap-1.5
|
|
scrollbar-thin scrollbar-thumb-zinc-600 hover:scrollbar-thumb-zinc-500 scrollbar-track-zinc-800"
|
|
role="list"
|
|
aria-labelledby="alliances-title"
|
|
translate="no"
|
|
>
|
|
${alliesSorted.length === 0
|
|
? html`<li class="text-zinc-400 text-[14px] px-1">
|
|
${translateText("common.none")}
|
|
</li>`
|
|
: alliesSorted.map(
|
|
(p) =>
|
|
html`<li
|
|
class="max-w-full inline-flex items-center gap-1.5
|
|
rounded-md border border-white/10 bg-white/5
|
|
px-2.5 py-1 text-[14px] text-zinc-100
|
|
hover:bg-white/8 active:scale-[0.99] transition"
|
|
title=${p.displayName()}
|
|
>
|
|
<span class="truncate">${p.displayName()}</span>
|
|
</li>`,
|
|
)}
|
|
</ul>
|
|
</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-300">
|
|
${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-[14px] font-bold ${this.getExpiryColorClass(
|
|
this.allianceExpirySeconds,
|
|
)}"
|
|
>${this.allianceExpiryText}</span
|
|
>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
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`
|
|
<div class="flex flex-col gap-2.5">
|
|
<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>
|
|
<ui-divider></ui-divider>
|
|
${other === my
|
|
? html``
|
|
: html`
|
|
<div class="grid auto-cols-fr grid-flow-col gap-1">
|
|
${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>
|
|
`}
|
|
${other === my
|
|
? html`<div class="grid auto-cols-fr grid-flow-col gap-1">
|
|
${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,
|
|
})}
|
|
</div>`
|
|
: ""}
|
|
${this.renderModeration(my, other, this.isAdminRole)}
|
|
</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;
|
|
const myGoldNum = my.gold();
|
|
const myTroopsNum = Number(my.troops());
|
|
|
|
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 z-10001 flex items-center justify-center overflow-auto
|
|
bg-black/15 backdrop-brightness-110 pointer-events-auto"
|
|
@contextmenu=${(e: MouseEvent) => e.preventDefault()}
|
|
@wheel=${(e: MouseEvent) => e.stopPropagation()}
|
|
@click=${() => this.hide()}
|
|
>
|
|
<div
|
|
class="pointer-events-auto max-h-[90vh] min-w-75 max-w-100 px-4 py-2"
|
|
@click=${(e: MouseEvent) => e.stopPropagation()}
|
|
>
|
|
<div class="relative">
|
|
<div
|
|
class="absolute inset-2 -z-10 rounded-2xl bg-black/25 backdrop-blur-[2px]"
|
|
></div>
|
|
<div
|
|
class=${`relative w-full bg-zinc-900/95 rounded-2xl text-zinc-100 shadow-2xl shadow-black/50
|
|
${other.isTraitor() ? "traitor-ring" : "ring-1 ring-white/5"}`}
|
|
>
|
|
<div class="overflow-visible">
|
|
<div
|
|
class="overflow-auto [-webkit-overflow-scrolling:touch] resize-y max-h-[calc(100vh-120px-env(safe-area-inset-bottom))]"
|
|
>
|
|
<div class="sticky top-0 z-20 flex justify-end p-2">
|
|
<button
|
|
@click=${this.handleClose}
|
|
class="absolute right-3 top-3 z-20 flex h-7 w-7 items-center justify-center rounded-full bg-zinc-700 text-white shadow-sm hover:bg-red-500 transition-colors"
|
|
aria-label=${translateText("common.close") || "Close"}
|
|
title=${translateText("common.close") || "Close"}
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
|
|
<div
|
|
class="p-6 flex flex-col gap-2 font-sans antialiased text-[14.5px] leading-relaxed"
|
|
>
|
|
<!-- Identity (flag, name, type, traitor, relation) -->
|
|
<div class="mb-1">${this.renderIdentityRow(other, my)}</div>
|
|
|
|
${this.sendTarget
|
|
? html`
|
|
<send-resource-modal
|
|
.open=${this.sendMode !== "none"}
|
|
.mode=${this.sendMode}
|
|
.total=${this.sendMode === "troops"
|
|
? myTroopsNum
|
|
: myGoldNum}
|
|
.uiState=${this.uiState}
|
|
.myPlayer=${my}
|
|
.target=${this.sendTarget}
|
|
.gameView=${this.g}
|
|
.eventBus=${this.eventBus}
|
|
.format=${this.sendMode === "troops"
|
|
? renderTroops
|
|
: renderNumber}
|
|
@confirm=${this.confirmSend}
|
|
@close=${this.closeSend}
|
|
></send-resource-modal>
|
|
`
|
|
: ""}
|
|
${this.moderationTarget
|
|
? html`
|
|
<player-moderation-modal
|
|
.open=${true}
|
|
.myPlayer=${my}
|
|
.target=${this.moderationTarget}
|
|
.eventBus=${this.eventBus}
|
|
.isAdmin=${this.isAdminRole}
|
|
.alreadyKicked=${this.kickedPlayerIDs.has(
|
|
String(this.moderationTarget.id()),
|
|
)}
|
|
@close=${this.closeModeration}
|
|
@kicked=${this.handleModerationKicked}
|
|
></player-moderation-modal>
|
|
`
|
|
: ""}
|
|
|
|
<ui-divider></ui-divider>
|
|
|
|
<!-- Resources -->
|
|
${this.renderResources(other)}
|
|
|
|
<!-- Rocket direction toggle -->
|
|
${other === my ? this.renderRocketDirectionToggle() : ""}
|
|
|
|
<ui-divider></ui-divider>
|
|
|
|
<!-- Stats: betrayals / trading -->
|
|
${this.renderStats(other, my)}
|
|
|
|
<ui-divider></ui-divider>
|
|
|
|
<!-- Alliances list -->
|
|
${this.renderAlliances(other)}
|
|
|
|
<!-- Alliance time remaining -->
|
|
${this.renderAllianceExpiry()}
|
|
|
|
<ui-divider></ui-divider>
|
|
|
|
<!-- Actions -->
|
|
${this.renderActions(my, other)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
}
|