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();
@@ -1,9 +1,10 @@
/**
* ConquestPopupPass MSDF-rendered floating text popups.
* WorldTextPass MSDF-rendered text in world space.
*
* Renders two kinds of popups using the same MSDF atlas as NamePass:
* - Conquest popups: "+ 500" gold text at conquered player locations (static position, fade only)
* - Bonus popups: "+ 45K" income text at port tiles (rises upward + fades)
* One pass, one MSDF atlas, several callers:
* - Conquest popups: "+ 500" gold text at conquered player locations (fade only)
* - Bonus popups: "+ 45K" income text at port tiles (rises upward + fades)
* - Ghost cost label: persistent build-cost number under the ghost cursor
*/
import type { BonusEvent, ConquestFx } from "../../types";
@@ -16,8 +17,9 @@ import { layoutString } from "./name-pass/TextLayout";
import { CHAR_RANGE, MAX_CHARS } from "./name-pass/Types";
import { assetUrl } from "src/core/AssetUrls";
import fragSrc from "../shaders/conquest-popup/conquest-popup.frag.glsl?raw";
import vertSrc from "../shaders/conquest-popup/conquest-popup.vert.glsl?raw";
import { renderNumber } from "../../../Utils";
import fragSrc from "../shaders/world-text/world-text.frag.glsl?raw";
import vertSrc from "../shaders/world-text/world-text.vert.glsl?raw";
const atlasUrl = assetUrl("atlases/msdf-atlas.png");
@@ -36,6 +38,12 @@ const CONQUEST_Y_OFFSET = 8;
/** World-space font size for conquest popups. */
const CONQUEST_SCALE = 6;
const CONQUEST_OUTLINE_WIDTH = 2.0;
/** Tiles below the ghost icon center for the cost label. */
const GHOST_COST_Y_OFFSET = 3;
/** World-space font size — smaller than popups so it sits unobtrusively under the icon. */
const GHOST_COST_SCALE = 4;
/** Matches player-name outline width for a consistent UI look. */
const GHOST_COST_OUTLINE_WIDTH = 1.4;
// ---------------------------------------------------------------------------
// Active popup tracking
@@ -62,10 +70,10 @@ function formatGold(gold: number): string {
}
// ---------------------------------------------------------------------------
// ConquestPopupPass
// WorldTextPass
// ---------------------------------------------------------------------------
export class ConquestPopupPass {
export class WorldTextPass {
private gl: WebGL2RenderingContext;
private program: WebGLProgram;
private maxInstances = 512;
@@ -101,6 +109,16 @@ export class ConquestPopupPass {
// Active popups (both conquest and bonus, unified)
private active: ActivePopup[] = [];
// Persistent ghost-cost label (separate from popup lifecycle; doesn't fade).
private ghostCostLabel: {
x: number;
y: number;
text: string;
colorR: number;
colorG: number;
colorB: number;
} | null = null;
// Settings reference
private settings: RenderSettings;
@@ -277,12 +295,56 @@ export class ConquestPopupPass {
}
}
/**
* Set or clear the ghost-cost label rendered under the build cursor.
* `null` clears it. Called from Renderer.updateGhostPreview.
*/
setGhostCostLabel(
label: {
tileX: number;
tileY: number;
cost: number;
canAfford: boolean;
canPlace: boolean;
} | null,
): void {
if (label === null) {
this.ghostCostLabel = null;
return;
}
// Color precedence: red (can't afford) > gray (can't place here) > white (OK).
let r = 1,
g = 1,
b = 1;
if (!label.canAfford) {
g = 0.3;
b = 0.3;
} else if (!label.canPlace) {
r = 0.6;
g = 0.6;
b = 0.6;
}
// The vertex shader adds +0.5 to (x, y) for tile-center alignment, so we
// pass raw tile coords here — same convention as the other popup entries.
this.ghostCostLabel = {
x: label.tileX,
y: label.tileY + GHOST_COST_Y_OFFSET,
text: renderNumber(label.cost),
colorR: r,
colorG: g,
colorB: b,
};
}
// -------------------------------------------------------------------------
// Tick — cull expired, rebuild instance buffer
// -------------------------------------------------------------------------
tick(): void {
if (this.active.length === 0) return;
if (this.active.length === 0 && this.ghostCostLabel === null) {
this.instanceCount = 0;
return;
}
const now = this.now();
// Remove expired popups (swap-remove)
@@ -340,6 +402,38 @@ export class ConquestPopupPass {
}
}
// Ghost cost label — persistent, no fade or rise. layoutString already
// centers cursors around 0, so passing the tile coord places the text
// centered on the tile (vertex shader adds the +0.5 tile-center offset).
const label = this.ghostCostLabel;
if (label) {
layoutString(
label.text,
this.glyph,
this.kernTable,
this.charCodes,
this.cursors,
);
const len = Math.min(label.text.length, MAX_CHARS);
for (let i = 0; i < len; i++) {
if (this.charCodes[i] === 0) continue;
if (count >= this.maxInstances) this.growBuffer();
const off = count * FLOATS_PER_INSTANCE;
this.instanceData[off + 0] = label.x;
this.instanceData[off + 1] = label.y;
this.instanceData[off + 2] = this.cursors[i];
this.instanceData[off + 3] = this.charCodes[i];
this.instanceData[off + 4] = 1;
this.instanceData[off + 5] = label.colorR;
this.instanceData[off + 6] = label.colorG;
this.instanceData[off + 7] = label.colorB;
this.instanceData[off + 8] = GHOST_COST_SCALE;
this.instanceData[off + 9] = GHOST_COST_OUTLINE_WIDTH;
count++;
}
}
this.instanceCount = count;
}
+4
View File
@@ -155,6 +155,10 @@ export interface GhostPreviewData {
canBuild: boolean; // Valid placement?
canUpgrade: boolean; // Upgrading existing structure?
cost: number; // Gold cost
/** Whether to render the cost label under the ghost (user setting). */
showCost: boolean;
/** True if the player has enough gold to afford this build (drives label color). */
canAfford: boolean;
ghostRailPaths: TileRef[][]; // TileRef paths (City/Port only)
overlappingRailroads: TileRef[]; // TileRefs containing rails in snap zone
ownerID: number; // Player's smallID (for color)