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
@@ -16,6 +16,7 @@ import {
} from "../../core/game/Game";
import { TileRef } from "../../core/game/GameMap";
import { GameView } from "../../core/game/GameView";
import { UserSettings } from "../../core/game/UserSettings";
import { Controller } from "../Controller";
import {
ConfirmGhostStructureEvent,
@@ -57,6 +58,7 @@ export class BuildPreviewController implements Controller {
public uiState: UIState,
private transformHandler: TransformHandler,
private view: WebGLGameView,
private userSettings: UserSettings,
) {}
init() {
@@ -335,13 +337,16 @@ export class BuildPreviewController implements Controller {
break;
}
const cost = u.cost;
return {
ghostType: u.type,
tileX: this.game.x(tileRef),
tileY: this.game.y(tileRef),
canBuild: u.canBuild !== false,
canUpgrade: u.canUpgrade !== false,
cost: Number(u.cost),
cost: Number(cost),
showCost: this.userSettings.cursorCostLabel(),
canAfford: myPlayer.gold() >= cost,
ghostRailPaths: u.ghostRailPaths,
overlappingRailroads: u.overlappingRailroads,
ownerID: myPlayer.smallID(),
+8 -1
View File
@@ -271,7 +271,14 @@ export function createRenderer(
const layers: Controller[] = [
new WarshipSelectionController(game, eventBus, transformHandler, view),
new BuildPreviewController(game, eventBus, uiState, transformHandler, view),
new BuildPreviewController(
game,
eventBus,
uiState,
transformHandler,
view,
userSettings,
),
new HoverHighlightController(game, eventBus, transformHandler, view),
new StructureHighlightController(eventBus, view),
new AttackingTroopsOverlay(game, transformHandler, eventBus, userSettings),
+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)