mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 09:30:45 +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>
509 lines
13 KiB
TypeScript
509 lines
13 KiB
TypeScript
import { css, html, LitElement } from "lit";
|
|
import { customElement, state } from "lit/decorators.js";
|
|
import { translateText } from "../../../client/Utils";
|
|
import { assetUrl } from "../../../core/AssetUrls";
|
|
import { EventBus } from "../../../core/EventBus";
|
|
import {
|
|
BuildableUnit,
|
|
BuildMenus,
|
|
Gold,
|
|
PlayerBuildableUnitType,
|
|
UnitType,
|
|
} from "../../../core/game/Game";
|
|
import { TileRef } from "../../../core/game/GameMap";
|
|
import { Controller } from "../../Controller";
|
|
import {
|
|
CloseViewEvent,
|
|
MouseDownEvent,
|
|
ShowBuildMenuEvent,
|
|
ShowEmojiMenuEvent,
|
|
} from "../../InputHandler";
|
|
import { TransformHandler } from "../../TransformHandler";
|
|
import {
|
|
BuildUnitIntentEvent,
|
|
SendUpgradeStructureIntentEvent,
|
|
} from "../../Transport";
|
|
import { UIState } from "../../UIState";
|
|
import { renderNumber } from "../../Utils";
|
|
import { GameView } from "../../view";
|
|
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 mirvIcon = assetUrl("images/MIRVIcon.svg");
|
|
const missileSiloIcon = assetUrl("images/MissileSiloIconWhite.svg");
|
|
const hydrogenBombIcon = assetUrl("images/MushroomCloudIconWhite.svg");
|
|
const atomBombIcon = assetUrl("images/NukeIconWhite.svg");
|
|
const portIcon = assetUrl("images/PortIcon.svg");
|
|
const samlauncherIcon = assetUrl("images/SamLauncherIconWhite.svg");
|
|
const shieldIcon = assetUrl("images/ShieldIconWhite.svg");
|
|
|
|
export interface BuildItemDisplay {
|
|
unitType: PlayerBuildableUnitType;
|
|
icon: string;
|
|
description?: string;
|
|
key?: string;
|
|
countable?: boolean;
|
|
}
|
|
|
|
export const buildTable: BuildItemDisplay[][] = [
|
|
[
|
|
{
|
|
unitType: UnitType.AtomBomb,
|
|
icon: atomBombIcon,
|
|
description: "build_menu.desc.atom_bomb",
|
|
key: "unit_type.atom_bomb",
|
|
countable: false,
|
|
},
|
|
{
|
|
unitType: UnitType.MIRV,
|
|
icon: mirvIcon,
|
|
description: "build_menu.desc.mirv",
|
|
key: "unit_type.mirv",
|
|
countable: false,
|
|
},
|
|
{
|
|
unitType: UnitType.HydrogenBomb,
|
|
icon: hydrogenBombIcon,
|
|
description: "build_menu.desc.hydrogen_bomb",
|
|
key: "unit_type.hydrogen_bomb",
|
|
countable: false,
|
|
},
|
|
{
|
|
unitType: UnitType.Warship,
|
|
icon: warshipIcon,
|
|
description: "build_menu.desc.warship",
|
|
key: "unit_type.warship",
|
|
countable: true,
|
|
},
|
|
{
|
|
unitType: UnitType.Port,
|
|
icon: portIcon,
|
|
description: "build_menu.desc.port",
|
|
key: "unit_type.port",
|
|
countable: true,
|
|
},
|
|
{
|
|
unitType: UnitType.MissileSilo,
|
|
icon: missileSiloIcon,
|
|
description: "build_menu.desc.missile_silo",
|
|
key: "unit_type.missile_silo",
|
|
countable: true,
|
|
},
|
|
{
|
|
unitType: UnitType.SAMLauncher,
|
|
icon: samlauncherIcon,
|
|
description: "build_menu.desc.sam_launcher",
|
|
key: "unit_type.sam_launcher",
|
|
countable: true,
|
|
},
|
|
{
|
|
unitType: UnitType.DefensePost,
|
|
icon: shieldIcon,
|
|
description: "build_menu.desc.defense_post",
|
|
key: "unit_type.defense_post",
|
|
countable: true,
|
|
},
|
|
{
|
|
unitType: UnitType.City,
|
|
icon: cityIcon,
|
|
description: "build_menu.desc.city",
|
|
key: "unit_type.city",
|
|
countable: true,
|
|
},
|
|
{
|
|
unitType: UnitType.Factory,
|
|
icon: factoryIcon,
|
|
description: "build_menu.desc.factory",
|
|
key: "unit_type.factory",
|
|
countable: true,
|
|
},
|
|
],
|
|
];
|
|
|
|
export const flattenedBuildTable = buildTable.flat();
|
|
|
|
@customElement("build-menu")
|
|
export class BuildMenu extends LitElement implements Controller {
|
|
public game: GameView;
|
|
public eventBus: EventBus;
|
|
public uiState: UIState;
|
|
private clickedTile: TileRef;
|
|
public playerBuildables: BuildableUnit[] | null = null;
|
|
private filteredBuildTable: BuildItemDisplay[][] = buildTable;
|
|
public transformHandler: TransformHandler;
|
|
|
|
init() {
|
|
this.eventBus.on(ShowBuildMenuEvent, (e) => {
|
|
if (!this.game.myPlayer()?.isAlive()) {
|
|
return;
|
|
}
|
|
if (!this._hidden) {
|
|
// Players sometimes hold control while building a unit,
|
|
// so if the menu is already open, ignore the event.
|
|
return;
|
|
}
|
|
const clickedCell = this.transformHandler.screenToWorldCoordinates(
|
|
e.x,
|
|
e.y,
|
|
);
|
|
if (!this.game.isValidCoord(clickedCell.x, clickedCell.y)) {
|
|
return;
|
|
}
|
|
const tile = this.game.ref(clickedCell.x, clickedCell.y);
|
|
this.showMenu(tile);
|
|
});
|
|
this.eventBus.on(CloseViewEvent, () => this.hideMenu());
|
|
this.eventBus.on(ShowEmojiMenuEvent, () => this.hideMenu());
|
|
this.eventBus.on(MouseDownEvent, () => this.hideMenu());
|
|
}
|
|
|
|
tick() {
|
|
if (!this._hidden) {
|
|
this.refresh();
|
|
}
|
|
}
|
|
|
|
static styles = css`
|
|
:host {
|
|
display: block;
|
|
}
|
|
.build-menu {
|
|
position: fixed;
|
|
top: 50%;
|
|
left: 50%;
|
|
transform: translate(-50%, -50%);
|
|
z-index: 9999;
|
|
background-color: #1e1e1e;
|
|
padding: 15px;
|
|
box-shadow: 0 0 20px rgba(0, 0, 0, 0.5);
|
|
border-radius: 10px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
max-width: 95vw;
|
|
max-height: 95vh;
|
|
overflow-y: auto;
|
|
}
|
|
.build-description {
|
|
font-size: 0.6rem;
|
|
}
|
|
.build-row {
|
|
display: flex;
|
|
justify-content: center;
|
|
flex-wrap: wrap;
|
|
width: 100%;
|
|
}
|
|
.build-button {
|
|
position: relative;
|
|
width: 120px;
|
|
height: 140px;
|
|
border: 2px solid #444;
|
|
background-color: #2c2c2c;
|
|
color: white;
|
|
border-radius: 12px;
|
|
cursor: pointer;
|
|
transition: all 0.3s ease;
|
|
display: flex;
|
|
flex-direction: column;
|
|
justify-content: center;
|
|
align-items: center;
|
|
margin: 8px;
|
|
padding: 10px;
|
|
gap: 5px;
|
|
}
|
|
.build-button:not(:disabled):hover {
|
|
background-color: #3a3a3a;
|
|
transform: scale(1.05);
|
|
border-color: #666;
|
|
}
|
|
.build-button:not(:disabled):active {
|
|
background-color: #4a4a4a;
|
|
transform: scale(0.95);
|
|
}
|
|
.build-button:disabled {
|
|
background-color: #1a1a1a;
|
|
border-color: #333;
|
|
cursor: not-allowed;
|
|
opacity: 0.7;
|
|
}
|
|
.build-button:disabled img {
|
|
opacity: 0.5;
|
|
}
|
|
.build-button:disabled .build-cost {
|
|
color: #ff4444;
|
|
}
|
|
.build-icon {
|
|
font-size: 40px;
|
|
margin-bottom: 5px;
|
|
}
|
|
.build-name {
|
|
font-size: 14px;
|
|
font-weight: bold;
|
|
margin-bottom: 5px;
|
|
text-align: center;
|
|
}
|
|
.build-cost {
|
|
font-size: 14px;
|
|
}
|
|
.hidden {
|
|
display: none !important;
|
|
}
|
|
.build-count-chip {
|
|
position: absolute;
|
|
top: -10px;
|
|
right: -10px;
|
|
background-color: #2c2c2c;
|
|
color: white;
|
|
padding: 2px 10px;
|
|
border-radius: 10000px;
|
|
transition: all 0.3s ease;
|
|
font-size: 12px;
|
|
display: flex;
|
|
justify-content: center;
|
|
align-content: center;
|
|
border: 1px solid #444;
|
|
}
|
|
.build-button:not(:disabled):hover > .build-count-chip {
|
|
background-color: #3a3a3a;
|
|
border-color: #666;
|
|
}
|
|
.build-button:not(:disabled):active > .build-count-chip {
|
|
background-color: #4a4a4a;
|
|
}
|
|
.build-button:disabled > .build-count-chip {
|
|
background-color: #1a1a1a;
|
|
border-color: #333;
|
|
cursor: not-allowed;
|
|
}
|
|
.build-count {
|
|
font-weight: bold;
|
|
font-size: 14px;
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.build-menu {
|
|
padding: 10px;
|
|
max-height: 80vh;
|
|
width: 80vw;
|
|
}
|
|
.build-button {
|
|
width: 140px;
|
|
height: 120px;
|
|
margin: 4px;
|
|
padding: 6px;
|
|
gap: 5px;
|
|
}
|
|
.build-icon {
|
|
font-size: 28px;
|
|
}
|
|
.build-name {
|
|
font-size: 12px;
|
|
margin-bottom: 3px;
|
|
}
|
|
.build-cost {
|
|
font-size: 11px;
|
|
}
|
|
.build-count {
|
|
font-weight: bold;
|
|
font-size: 10px;
|
|
}
|
|
.build-count-chip {
|
|
padding: 1px 5px;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 480px) {
|
|
.build-menu {
|
|
padding: 8px;
|
|
max-height: 70vh;
|
|
}
|
|
.build-button {
|
|
width: calc(50% - 6px);
|
|
height: 100px;
|
|
margin: 3px;
|
|
padding: 4px;
|
|
border-width: 1px;
|
|
}
|
|
.build-icon {
|
|
font-size: 24px;
|
|
}
|
|
.build-name {
|
|
font-size: 10px;
|
|
margin-bottom: 2px;
|
|
}
|
|
.build-cost {
|
|
font-size: 9px;
|
|
}
|
|
.build-count {
|
|
font-weight: bold;
|
|
font-size: 8px;
|
|
}
|
|
.build-count-chip {
|
|
padding: 0 3px;
|
|
}
|
|
.build-button img {
|
|
width: 24px;
|
|
height: 24px;
|
|
}
|
|
.build-cost img {
|
|
width: 10px;
|
|
height: 10px;
|
|
}
|
|
}
|
|
`;
|
|
|
|
@state()
|
|
private _hidden = true;
|
|
|
|
public canBuildOrUpgrade(item: BuildItemDisplay): boolean {
|
|
if (this.game?.myPlayer() === null || this.playerBuildables === null) {
|
|
return false;
|
|
}
|
|
const unit = this.playerBuildables.find((u) => u.type === item.unitType);
|
|
return unit ? unit.canBuild !== false || unit.canUpgrade !== false : false;
|
|
}
|
|
|
|
public cost(item: BuildItemDisplay): Gold {
|
|
for (const bu of this.playerBuildables ?? []) {
|
|
if (bu.type === item.unitType) {
|
|
return bu.cost;
|
|
}
|
|
}
|
|
return 0n;
|
|
}
|
|
|
|
public count(item: BuildItemDisplay): string {
|
|
const player = this.game?.myPlayer();
|
|
if (!player) {
|
|
return "?";
|
|
}
|
|
|
|
return player.totalUnitLevels(item.unitType).toString();
|
|
}
|
|
|
|
public sendBuildOrUpgrade(buildableUnit: BuildableUnit, tile: TileRef): void {
|
|
if (buildableUnit.canUpgrade !== false) {
|
|
this.eventBus.emit(
|
|
new SendUpgradeStructureIntentEvent(
|
|
buildableUnit.canUpgrade,
|
|
buildableUnit.type,
|
|
),
|
|
);
|
|
} else if (buildableUnit.canBuild) {
|
|
const rocketDirectionUp =
|
|
buildableUnit.type === UnitType.AtomBomb ||
|
|
buildableUnit.type === UnitType.HydrogenBomb
|
|
? this.uiState.rocketDirectionUp
|
|
: undefined;
|
|
this.eventBus.emit(
|
|
new BuildUnitIntentEvent(buildableUnit.type, tile, rocketDirectionUp),
|
|
);
|
|
}
|
|
this.hideMenu();
|
|
}
|
|
|
|
render() {
|
|
return html`
|
|
<div
|
|
class="build-menu ${this._hidden ? "hidden" : ""}"
|
|
@contextmenu=${(e: MouseEvent) => e.preventDefault()}
|
|
>
|
|
${this.filteredBuildTable.map(
|
|
(row) => html`
|
|
<div class="build-row">
|
|
${row.map((item) => {
|
|
const buildableUnit = this.playerBuildables?.find(
|
|
(bu) => bu.type === item.unitType,
|
|
);
|
|
if (buildableUnit === undefined) {
|
|
return html``;
|
|
}
|
|
const enabled =
|
|
buildableUnit.canBuild !== false ||
|
|
buildableUnit.canUpgrade !== false;
|
|
return html`
|
|
<button
|
|
class="build-button"
|
|
@click=${() =>
|
|
this.sendBuildOrUpgrade(buildableUnit, this.clickedTile)}
|
|
?disabled=${!enabled}
|
|
title=${!enabled
|
|
? translateText("build_menu.not_enough_money")
|
|
: ""}
|
|
>
|
|
<img
|
|
src=${item.icon}
|
|
alt="${item.unitType}"
|
|
width="40"
|
|
height="40"
|
|
/>
|
|
<span class="build-name"
|
|
>${item.key && translateText(item.key)}</span
|
|
>
|
|
<span class="build-description"
|
|
>${item.description &&
|
|
translateText(item.description)}</span
|
|
>
|
|
<span class="build-cost" translate="no">
|
|
${renderNumber(
|
|
this.game && this.game.myPlayer() ? this.cost(item) : 0,
|
|
)}
|
|
<img
|
|
src=${goldCoinIcon}
|
|
alt="gold"
|
|
width="12"
|
|
height="12"
|
|
class="align-middle"
|
|
/>
|
|
</span>
|
|
${item.countable
|
|
? html`<div class="build-count-chip">
|
|
<span class="build-count">${this.count(item)}</span>
|
|
</div>`
|
|
: ""}
|
|
</button>
|
|
`;
|
|
})}
|
|
</div>
|
|
`,
|
|
)}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
hideMenu() {
|
|
this._hidden = true;
|
|
this.requestUpdate();
|
|
}
|
|
|
|
showMenu(clickedTile: TileRef) {
|
|
this.clickedTile = clickedTile;
|
|
this._hidden = false;
|
|
this.refresh();
|
|
}
|
|
|
|
private refresh() {
|
|
this.game
|
|
.myPlayer()
|
|
?.buildables(this.clickedTile, BuildMenus.types)
|
|
.then((buildables) => {
|
|
this.playerBuildables = buildables;
|
|
this.requestUpdate();
|
|
});
|
|
|
|
// remove disabled buildings from the buildtable
|
|
this.filteredBuildTable = this.getBuildableUnits();
|
|
}
|
|
|
|
private getBuildableUnits(): BuildItemDisplay[][] {
|
|
return buildTable.map((row) =>
|
|
row.filter((item) => !this.game?.config()?.isUnitDisabled(item.unitType)),
|
|
);
|
|
}
|
|
|
|
get isVisible() {
|
|
return !this._hidden;
|
|
}
|
|
}
|