unit price (#3989)

## Description:

# Ghost structure cost label

Renders the gold cost of the currently-selected build under the ghost
structure cursor, with color-coded affordability/placement state. Honors
the
existing `cursorCostLabel` user setting (legacy name `ghostPricePill`,
already
shipping ON by default).

## Behavior

| State | Color |
|---|---|
| Can afford + valid placement | white |
| Can afford + can't place here (port on land, overlap, …) | gray |
| Can't afford | red |

The number is formatted via `renderNumber` (project-wide convention —
`1.5K`,
`1.23M`, etc.) and rendered as MSDF text at a fixed world-space scale,
centered
under the ghost icon.

## Implementation

The cost was already plumbed end-to-end on
[`GhostPreviewData.cost`](src/client/render/types/Renderer.ts) but never
visualized. This PR:

- Extends [`GhostPreviewData`](src/client/render/types/Renderer.ts) with
`showCost` (from setting) and `canAfford` (gold
vs. cost check, computed in
[BuildPreviewController](src/client/controllers/BuildPreviewController.ts)).
- Adds a `setGhostCostLabel(...)` channel to the MSDF text pass — one
persistent,
non-animated text instance alongside the existing ephemeral popups. No
new
  pass, no new shader.
- Wires
[`Renderer.updateGhostPreview`](src/client/render/gl/Renderer.ts) to
push the label whenever a ghost is active.
- Renames `ConquestPopupPass` →
[`WorldTextPass`](src/client/render/gl/passes/WorldTextPass.ts) (and its
shader dir
`conquest-popup/` → `world-text/`) since it now handles conquest popups,
bonus popups, and the ghost cost label. Done with `git mv` so history is
  preserved.




https://github.com/user-attachments/assets/c5b21bf3-f440-4c28-9b94-843df9bf6a37





## 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:

evan
This commit is contained in:
Evan
2026-05-22 17:30:15 +01:00
committed by GitHub
parent 458d04e278
commit ee04a19d3c
7 changed files with 143 additions and 22 deletions
+22 -11
View File
@@ -30,7 +30,6 @@ import type { RadialMenuItem } from "./Events";
import { BarPass } from "./passes/BarPass";
import { BorderComputePass } from "./passes/BorderComputePass";
import { BorderStampPass } from "./passes/BorderStampPass";
import { ConquestPopupPass } from "./passes/ConquestPopupPass";
import { CoordinateGridPass } from "./passes/CoordinateGridPass";
import { CrosshairPass } from "./passes/CrosshairPass";
import { FalloutBloomPass } from "./passes/FalloutBloomPass";
@@ -56,6 +55,7 @@ import { TerrainPass } from "./passes/TerrainPass";
import { TerritoryPass } from "./passes/TerritoryPass";
import { TrailPass } from "./passes/TrailPass";
import { UnitPass } from "./passes/UnitPass";
import { WorldTextPass } from "./passes/WorldTextPass";
import { createRenderSettings, type RenderSettings } from "./RenderSettings";
import { AffiliationPalette } from "./utils/Affiliation";
import { buildTerrainRGBA, getPaletteSize } from "./utils/ColorUtils";
@@ -114,7 +114,7 @@ export class GPURenderer {
private crosshairPass: CrosshairPass;
private railroadPass: RailroadPass;
private barPass: BarPass;
private conquestPopupPass: ConquestPopupPass;
private worldTextPass: WorldTextPass;
private radialMenuPass: RadialMenuPass;
private selectionBoxPass: SelectionBoxPass;
private moveIndicatorPass: MoveIndicatorPass;
@@ -399,8 +399,8 @@ export class GPURenderer {
this.namePass = new NamePass(gl, header, paletteData, this.settings);
this.fxPass = new FxPass(gl, header, this.settings);
this.barPass = new BarPass(gl, header, this.settings);
this.conquestPopupPass = new ConquestPopupPass(gl, this.settings);
this.conquestPopupPass.setMapWidth(this.mapW);
this.worldTextPass = new WorldTextPass(gl, this.settings);
this.worldTextPass.setMapWidth(this.mapW);
this.radialMenuPass = new RadialMenuPass(gl);
this.selectionBoxPass = new SelectionBoxPass(gl);
this.moveIndicatorPass = new MoveIndicatorPass(gl, this.settings);
@@ -730,7 +730,7 @@ export class GPURenderer {
applyConquestEvents(events: ConquestFx[]): void {
if (events.length > 0) {
this.fxPass.applyConquestEvents(events);
this.conquestPopupPass.applyConquestEvents(events);
this.worldTextPass.applyConquestEvents(events);
}
}
@@ -741,7 +741,7 @@ export class GPURenderer {
this.localPlayerID > 0
? events.filter((e) => e.smallID === this.localPlayerID)
: events;
if (filtered.length > 0) this.conquestPopupPass.applyBonusEvents(filtered);
if (filtered.length > 0) this.worldTextPass.applyBonusEvents(filtered);
}
updateAttackRings(rings: AttackRingInput[]): void {
@@ -750,11 +750,11 @@ export class GPURenderer {
clearFx(): void {
this.fxPass.clear();
this.conquestPopupPass.clear();
this.worldTextPass.clear();
}
setFxTimeFn(fn: () => number): void {
this.fxPass.setTimeFn(fn);
this.conquestPopupPass.setTimeFn(fn);
this.worldTextPass.setTimeFn(fn);
}
updateGhostPreview(data: GhostPreviewData | null): void {
@@ -762,6 +762,17 @@ export class GPURenderer {
this.railroadPass.updateGhostPreview(data);
this.rangeCirclePass.updateGhostPreview(data);
this.crosshairPass.updateGhostPreview(data);
this.worldTextPass.setGhostCostLabel(
data && data.showCost && data.cost > 0
? {
tileX: data.tileX,
tileY: data.tileY,
cost: data.cost,
canAfford: data.canAfford,
canPlace: data.canBuild || data.canUpgrade,
}
: null,
);
this.samGhostVisible =
data !== null && SAM_RADIUS_GHOST_TYPES.has(data.ghostType);
this.samRadiusPass.setVisible(
@@ -1162,8 +1173,8 @@ export class GPURenderer {
this.fxPass.draw(cam, zoom);
}
this.conquestPopupPass.tick();
this.conquestPopupPass.draw(cam, zoom);
this.worldTextPass.tick();
this.worldTextPass.draw(cam, zoom);
if (this.gridView) this.coordinateGridPass.draw(cam, zoom);
if (pe.name && !this.gridView)
@@ -1203,7 +1214,7 @@ export class GPURenderer {
this.unitPass.dispose();
this.namePass.dispose();
this.fxPass.dispose();
this.conquestPopupPass.dispose();
this.worldTextPass.dispose();
this.radialMenuPass.dispose();
this.selectionBoxPass.dispose();
this.moveIndicatorPass.dispose();