mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 09:50: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>
512 lines
16 KiB
TypeScript
512 lines
16 KiB
TypeScript
import { html, LitElement, TemplateResult } from "lit";
|
|
import { customElement, property, state } from "lit/decorators.js";
|
|
import { assetUrl } from "../../../core/AssetUrls";
|
|
import { EventBus } from "../../../core/EventBus";
|
|
import {
|
|
PlayerProfile,
|
|
PlayerType,
|
|
Relation,
|
|
Unit,
|
|
UnitType,
|
|
} from "../../../core/game/Game";
|
|
import { TileRef } from "../../../core/game/GameMap";
|
|
import { AllianceView } from "../../../core/game/GameUpdates";
|
|
import { Controller } from "../../Controller";
|
|
import {
|
|
ContextMenuEvent,
|
|
MouseMoveEvent,
|
|
TouchEvent,
|
|
} from "../../InputHandler";
|
|
import { themeProvider } from "../../theme/ThemeProvider";
|
|
import { TransformHandler } from "../../TransformHandler";
|
|
import {
|
|
getTranslatedPlayerTeamLabel,
|
|
renderDuration,
|
|
renderNumber,
|
|
renderTroops,
|
|
translateText,
|
|
} from "../../Utils";
|
|
import { GameView, PlayerView, UnitView } from "../../view";
|
|
import {
|
|
EMOJI_ICON_KIND,
|
|
getFirstPlacePlayer,
|
|
getPlayerIcons,
|
|
IMAGE_ICON_KIND,
|
|
} from "../PlayerIcons";
|
|
import { ImmunityBarVisibleEvent } from "./ImmunityTimer";
|
|
import { CloseRadialMenuEvent } from "./RadialMenu";
|
|
import "./RelationSmiley";
|
|
import { SpawnBarVisibleEvent } from "./SpawnTimer";
|
|
const soldierIconAquarius = assetUrl("images/SoldierIconAquarius.svg");
|
|
const allianceIcon = assetUrl("images/AllianceIcon.svg");
|
|
const warshipIcon = assetUrl("images/BattleshipIconWhite.svg");
|
|
const cityIcon = assetUrl("images/CityIconWhite.svg");
|
|
const factoryIcon = assetUrl("images/FactoryIconWhite.svg");
|
|
const goldCoinIcon = assetUrl("images/GoldCoinIcon.svg");
|
|
const missileSiloIcon = assetUrl("images/MissileSiloIconWhite.svg");
|
|
const portIcon = assetUrl("images/PortIcon.svg");
|
|
const samLauncherIcon = assetUrl("images/SamLauncherIconWhite.svg");
|
|
const soldierIcon = assetUrl("images/SoldierIcon.svg");
|
|
|
|
function euclideanDistWorld(
|
|
coord: { x: number; y: number },
|
|
tileRef: TileRef,
|
|
game: GameView,
|
|
): number {
|
|
const x = game.x(tileRef);
|
|
const y = game.y(tileRef);
|
|
const dx = coord.x - x;
|
|
const dy = coord.y - y;
|
|
return Math.sqrt(dx * dx + dy * dy);
|
|
}
|
|
|
|
function distSortUnitWorld(coord: { x: number; y: number }, game: GameView) {
|
|
return (a: Unit | UnitView, b: Unit | UnitView) => {
|
|
const distA = euclideanDistWorld(coord, a.tile(), game);
|
|
const distB = euclideanDistWorld(coord, b.tile(), game);
|
|
return distA - distB;
|
|
};
|
|
}
|
|
|
|
@customElement("player-info-overlay")
|
|
export class PlayerInfoOverlay extends LitElement implements Controller {
|
|
@property({ type: Object })
|
|
public game!: GameView;
|
|
|
|
@property({ type: Object })
|
|
public eventBus!: EventBus;
|
|
|
|
@property({ type: Object })
|
|
public transform!: TransformHandler;
|
|
|
|
@state()
|
|
private player: PlayerView | null = null;
|
|
|
|
@state()
|
|
private playerProfile: PlayerProfile | null = null;
|
|
|
|
@state()
|
|
private unit: UnitView | null = null;
|
|
|
|
@state()
|
|
private _isInfoVisible: boolean = false;
|
|
|
|
@state()
|
|
private spawnBarVisible = false;
|
|
@state()
|
|
private immunityBarVisible = false;
|
|
|
|
private _isActive = false;
|
|
|
|
private get barOffset(): number {
|
|
return (this.spawnBarVisible ? 7 : 0) + (this.immunityBarVisible ? 7 : 0);
|
|
}
|
|
|
|
private lastMouseUpdate = 0;
|
|
|
|
init() {
|
|
this.eventBus.on(MouseMoveEvent, (e: MouseMoveEvent) =>
|
|
this.onMouseEvent(e),
|
|
);
|
|
this.eventBus.on(ContextMenuEvent, (e: ContextMenuEvent) =>
|
|
this.maybeShow(e.x, e.y),
|
|
);
|
|
this.eventBus.on(TouchEvent, (e: TouchEvent) => this.maybeShow(e.x, e.y));
|
|
this.eventBus.on(CloseRadialMenuEvent, () => this.hide());
|
|
this.eventBus.on(SpawnBarVisibleEvent, (e) => {
|
|
this.spawnBarVisible = e.visible;
|
|
});
|
|
this.eventBus.on(ImmunityBarVisibleEvent, (e) => {
|
|
this.immunityBarVisible = e.visible;
|
|
});
|
|
this._isActive = true;
|
|
}
|
|
|
|
private onMouseEvent(event: MouseMoveEvent) {
|
|
const now = Date.now();
|
|
if (now - this.lastMouseUpdate < 100) {
|
|
return;
|
|
}
|
|
this.lastMouseUpdate = now;
|
|
this.maybeShow(event.x, event.y);
|
|
}
|
|
|
|
public hide() {
|
|
this.setVisible(false);
|
|
this.unit = null;
|
|
this.player = null;
|
|
}
|
|
|
|
public maybeShow(x: number, y: number) {
|
|
this.hide();
|
|
const worldCoord = this.transform.screenToWorldCoordinates(x, y);
|
|
if (!this.game.isValidCoord(worldCoord.x, worldCoord.y)) {
|
|
return;
|
|
}
|
|
|
|
const tile = this.game.ref(worldCoord.x, worldCoord.y);
|
|
if (!tile) return;
|
|
|
|
const owner = this.game.owner(tile);
|
|
|
|
if (owner && owner.isPlayer()) {
|
|
this.player = owner as PlayerView;
|
|
this.player.profile().then((p) => {
|
|
this.playerProfile = p;
|
|
});
|
|
this.setVisible(true);
|
|
} else if (!this.game.isLand(tile)) {
|
|
const units = this.game
|
|
.units(UnitType.Warship, UnitType.TradeShip, UnitType.TransportShip)
|
|
.filter((u) => euclideanDistWorld(worldCoord, u.tile(), this.game) < 50)
|
|
.sort(distSortUnitWorld(worldCoord, this.game));
|
|
|
|
if (units.length > 0) {
|
|
this.unit = units[0];
|
|
this.setVisible(true);
|
|
}
|
|
}
|
|
}
|
|
|
|
tick() {
|
|
this.requestUpdate();
|
|
}
|
|
|
|
setVisible(visible: boolean) {
|
|
this._isInfoVisible = visible;
|
|
this.requestUpdate();
|
|
}
|
|
|
|
private getPlayerNameColor(isFriendly: boolean): string {
|
|
if (isFriendly) return "text-green-500";
|
|
return "text-white";
|
|
}
|
|
|
|
private getRelationSmiley(
|
|
player: PlayerView,
|
|
myPlayer: PlayerView | null | undefined,
|
|
): TemplateResult | string {
|
|
if (!myPlayer || myPlayer === player || player.type() !== PlayerType.Nation)
|
|
return "";
|
|
const relation =
|
|
this.playerProfile?.relations[myPlayer.smallID()] ?? Relation.Neutral;
|
|
if (relation === Relation.Neutral) return "";
|
|
return html`<relation-smiley .relation=${relation}></relation-smiley>`;
|
|
}
|
|
|
|
private getRelationName(relation: Relation): string {
|
|
switch (relation) {
|
|
case Relation.Hostile:
|
|
return translateText("relation.hostile");
|
|
case Relation.Distrustful:
|
|
return translateText("relation.distrustful");
|
|
case Relation.Neutral:
|
|
return translateText("relation.neutral");
|
|
case Relation.Friendly:
|
|
return translateText("relation.friendly");
|
|
default:
|
|
return translateText("relation.default");
|
|
}
|
|
}
|
|
|
|
private displayUnitCount(player: PlayerView, type: UnitType, icon: string) {
|
|
return !this.game.config().isUnitDisabled(type)
|
|
? html`<div
|
|
class="flex items-center justify-center gap-0.5 lg:gap-1 p-0.5 lg:p-1 border rounded-md border-gray-500 text-[10px] lg:text-xs w-9 lg:w-12 h-6 lg:h-7"
|
|
translate="no"
|
|
>
|
|
<img
|
|
src=${icon}
|
|
class="w-3 h-3 lg:w-4 lg:h-4 object-contain shrink-0"
|
|
/>
|
|
<span>${player.totalUnitLevels(type)}</span>
|
|
</div>`
|
|
: "";
|
|
}
|
|
|
|
private allianceExpirationText(alliance: AllianceView) {
|
|
const { expiresAt } = alliance;
|
|
const remainingTicks = expiresAt - this.game.ticks();
|
|
let remainingSeconds = 0;
|
|
if (remainingTicks > 0) {
|
|
remainingSeconds = Math.max(0, Math.floor(remainingTicks / 10)); // 10 ticks per second
|
|
}
|
|
return renderDuration(remainingSeconds);
|
|
}
|
|
|
|
private renderPlayerNameIcons(player: PlayerView) {
|
|
const firstPlace = getFirstPlacePlayer(this.game);
|
|
const icons = getPlayerIcons({
|
|
game: this.game,
|
|
player,
|
|
// Because we already show the alliance icon next to the alliance expiration timer, we don't need to show it a second time in this render
|
|
includeAllianceIcon: false,
|
|
firstPlace,
|
|
alliancesDisabled: this.game.config().disableAlliances(),
|
|
});
|
|
|
|
if (icons.length === 0) {
|
|
return html``;
|
|
}
|
|
|
|
return html`<span class="flex items-center gap-1 ml-1 shrink-0">
|
|
${icons.map((icon) =>
|
|
icon.kind === EMOJI_ICON_KIND && icon.text
|
|
? html`<span class="text-sm shrink-0" translate="no"
|
|
>${icon.text}</span
|
|
>`
|
|
: icon.kind === IMAGE_ICON_KIND && icon.src
|
|
? html`<img src=${icon.src} alt="" class="w-4 h-4 shrink-0" />`
|
|
: html``,
|
|
)}
|
|
</span>`;
|
|
}
|
|
|
|
private renderPlayerInfo(player: PlayerView) {
|
|
const myPlayer = this.game.myPlayer();
|
|
const isFriendly = myPlayer?.isFriendly(player);
|
|
const isAllied = myPlayer?.isAlliedWith(player);
|
|
let allianceHtml: TemplateResult | null = null;
|
|
const maxTroops = this.game.config().maxTroops(player);
|
|
const attackingTroops = player
|
|
.outgoingAttacks()
|
|
.map((a) => a.troops)
|
|
.reduce((a, b) => a + b, 0);
|
|
const totalTroops = player.troops();
|
|
|
|
if (isAllied) {
|
|
const alliance = myPlayer
|
|
?.alliances()
|
|
.find((alliance) => alliance.other === player.id());
|
|
if (alliance !== undefined) {
|
|
allianceHtml = html` <div
|
|
class="flex items-center ml-auto mr-0 gap-1 text-sm font-bold leading-tight"
|
|
>
|
|
<img src=${allianceIcon} width="20" height="20" />
|
|
${this.allianceExpirationText(alliance)}
|
|
</div>`;
|
|
}
|
|
}
|
|
let playerType = "";
|
|
switch (player.type()) {
|
|
case PlayerType.Bot:
|
|
playerType = translateText("player_type.bot");
|
|
break;
|
|
case PlayerType.Nation:
|
|
playerType = translateText("player_type.nation");
|
|
break;
|
|
case PlayerType.Human:
|
|
playerType = translateText("player_type.player");
|
|
break;
|
|
}
|
|
const playerTeam = getTranslatedPlayerTeamLabel(player.team());
|
|
|
|
return html`
|
|
<div class="flex items-start gap-1 lg:gap-2 p-1 lg:p-1.5">
|
|
<!-- Left: Gold & Troop bar -->
|
|
<div class="flex flex-col gap-1 shrink-0 w-28 md:w-36">
|
|
<div class="flex items-center gap-1">
|
|
<div
|
|
class="flex flex-1 items-center justify-center px-1 py-0.5 border rounded-md border-yellow-400 font-bold text-yellow-400 text-sm lg:gap-1"
|
|
translate="no"
|
|
>
|
|
<img src=${goldCoinIcon} width="13" height="13" />
|
|
<span class="px-0.5">${renderNumber(player.gold())}</span>
|
|
</div>
|
|
<div
|
|
class="flex flex-1 flex-col items-center justify-center text-xs font-bold ${attackingTroops >
|
|
0
|
|
? "text-aquarius"
|
|
: "text-white/40"} drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)]"
|
|
translate="no"
|
|
>
|
|
<span class="flex items-center gap-px leading-none text-xs"
|
|
><img
|
|
class="w-2.5 h-2.5 inline-block ${attackingTroops > 0
|
|
? ""
|
|
: "brightness-0 invert opacity-40"}"
|
|
src=${attackingTroops > 0 ? soldierIconAquarius : soldierIcon}
|
|
alt=""
|
|
aria-hidden="true"
|
|
/>↑</span
|
|
>
|
|
<span class="tabular-nums leading-none text-sm mt-0.5"
|
|
>${renderTroops(attackingTroops)}</span
|
|
>
|
|
</div>
|
|
</div>
|
|
<div class="w-28 md:w-36" translate="no">
|
|
${this.renderTroopBar(totalTroops, attackingTroops, maxTroops)}
|
|
</div>
|
|
</div>
|
|
<!-- Right: Player identity + Units below -->
|
|
<div class="flex flex-col justify-between self-stretch">
|
|
<div
|
|
class="flex items-center gap-2 font-bold text-sm lg:text-lg ${this.getPlayerNameColor(
|
|
isFriendly ?? false,
|
|
)}"
|
|
>
|
|
${player.cosmetics.flag
|
|
? html`<img
|
|
class="h-6 object-contain"
|
|
src=${assetUrl(player.cosmetics.flag!)}
|
|
/>`
|
|
: html``}
|
|
<span>${player.displayName()}</span>
|
|
${this.getRelationSmiley(player, myPlayer)}
|
|
${playerTeam !== "" && player.type() !== PlayerType.Bot
|
|
? html`<div class="flex flex-col leading-tight">
|
|
<span class="text-gray-400 text-xs font-normal"
|
|
>${playerType}</span
|
|
>
|
|
<span class="text-xs font-normal text-gray-400"
|
|
>[<span
|
|
style="color: ${themeProvider
|
|
.current()
|
|
.teamColor(player.team()!)
|
|
.toHex()}"
|
|
>${playerTeam}</span
|
|
>]</span
|
|
>
|
|
</div>`
|
|
: html`<span class="text-gray-400 text-xs font-normal"
|
|
>${playerType}</span
|
|
>`}
|
|
${this.renderPlayerNameIcons(player)} ${allianceHtml ?? ""}
|
|
</div>
|
|
<div class="flex gap-0.5 lg:gap-1 items-center mt-0.5">
|
|
${this.displayUnitCount(player, UnitType.City, cityIcon)}
|
|
${this.displayUnitCount(player, UnitType.Factory, factoryIcon)}
|
|
${this.displayUnitCount(player, UnitType.Port, portIcon)}
|
|
${this.displayUnitCount(
|
|
player,
|
|
UnitType.MissileSilo,
|
|
missileSiloIcon,
|
|
)}
|
|
${this.displayUnitCount(
|
|
player,
|
|
UnitType.SAMLauncher,
|
|
samLauncherIcon,
|
|
)}
|
|
${this.displayUnitCount(player, UnitType.Warship, warshipIcon)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private renderTroopBar(
|
|
totalTroops: number,
|
|
attackingTroops: number,
|
|
maxTroops: number,
|
|
) {
|
|
const base = Math.max(maxTroops, 1);
|
|
const greenPercentRaw = (totalTroops / base) * 100;
|
|
const orangePercentRaw = (attackingTroops / base) * 100;
|
|
|
|
const greenPercent = Math.max(0, Math.min(100, greenPercentRaw));
|
|
const orangePercent = Math.max(
|
|
0,
|
|
Math.min(100 - greenPercent, orangePercentRaw),
|
|
);
|
|
|
|
return html`
|
|
<div
|
|
class="w-full h-5 lg:h-6 border border-gray-600 rounded-md bg-gray-900/60 overflow-hidden relative"
|
|
>
|
|
<div class="h-full flex">
|
|
${greenPercent > 0
|
|
? html`<div
|
|
class="h-full bg-sky-700 transition-[width] duration-200"
|
|
style="width: ${greenPercent}%;"
|
|
></div>`
|
|
: ""}
|
|
${orangePercent > 0
|
|
? html`<div
|
|
class="h-full bg-malibu-blue transition-[width] duration-200"
|
|
style="width: ${orangePercent}%;"
|
|
></div>`
|
|
: ""}
|
|
</div>
|
|
<div
|
|
class="absolute inset-0 flex items-center justify-between px-1.5 text-sm font-bold leading-none pointer-events-none"
|
|
translate="no"
|
|
>
|
|
<span class="text-white drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)]"
|
|
>${renderTroops(totalTroops)}</span
|
|
>
|
|
<span class="text-white drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)]"
|
|
>${renderTroops(maxTroops)}</span
|
|
>
|
|
</div>
|
|
<img
|
|
src=${soldierIcon}
|
|
alt=""
|
|
aria-hidden="true"
|
|
width="14"
|
|
height="14"
|
|
class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 brightness-0 invert drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)] pointer-events-none"
|
|
/>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private renderUnitInfo(unit: UnitView) {
|
|
const isAlly =
|
|
(unit.owner() === this.game.myPlayer() ||
|
|
this.game.myPlayer()?.isFriendly(unit.owner())) ??
|
|
false;
|
|
|
|
return html`
|
|
<div class="p-2">
|
|
<div class="font-bold mb-1 ${isAlly ? "text-green-500" : "text-white"}">
|
|
${unit.owner().displayName()}
|
|
</div>
|
|
<div class="mt-1">
|
|
<div class="text-sm opacity-80">${unit.type()}</div>
|
|
${unit.hasHealth()
|
|
? html` <div class="text-sm">Health: ${unit.health()}</div> `
|
|
: ""}
|
|
${unit.type() === UnitType.TransportShip
|
|
? html`
|
|
<div class="text-sm">
|
|
Troops: ${renderTroops(unit.troops())}
|
|
</div>
|
|
`
|
|
: ""}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
render() {
|
|
if (!this._isActive) {
|
|
return html``;
|
|
}
|
|
|
|
const containerClasses = this._isInfoVisible
|
|
? "opacity-100 visible"
|
|
: "opacity-0 invisible pointer-events-none";
|
|
|
|
return html`
|
|
<div
|
|
class="fixed top-0 left-0 right-0 sm:left-1/2 sm:right-auto sm:-translate-x-1/2 z-[1001]"
|
|
style="margin-top: ${this.barOffset}px;"
|
|
@click=${() => this.hide()}
|
|
@contextmenu=${(e: MouseEvent) => e.preventDefault()}
|
|
>
|
|
<div
|
|
class="bg-gray-800/92 backdrop-blur-sm shadow-xs min-[1200px]:rounded-lg sm:rounded-b-lg shadow-lg text-white text-lg lg:text-base w-full sm:w-[500px] overflow-hidden ${containerClasses}"
|
|
>
|
|
${this.player !== null ? this.renderPlayerInfo(this.player) : ""}
|
|
${this.unit !== null ? this.renderUnitInfo(this.unit) : ""}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
createRenderRoot() {
|
|
return this; // Disable shadow DOM to allow Tailwind styles
|
|
}
|
|
}
|