diff --git a/src/client/controllers/BuildPreviewController.ts b/src/client/controllers/BuildPreviewController.ts index 14b5ad3ab..ea9338e93 100644 --- a/src/client/controllers/BuildPreviewController.ts +++ b/src/client/controllers/BuildPreviewController.ts @@ -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(), diff --git a/src/client/hud/GameRenderer.ts b/src/client/hud/GameRenderer.ts index 391d718ed..4dc2595e6 100644 --- a/src/client/hud/GameRenderer.ts +++ b/src/client/hud/GameRenderer.ts @@ -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), diff --git a/src/client/render/gl/Renderer.ts b/src/client/render/gl/Renderer.ts index b3065113a..33db1a4eb 100644 --- a/src/client/render/gl/Renderer.ts +++ b/src/client/render/gl/Renderer.ts @@ -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(); diff --git a/src/client/render/gl/passes/ConquestPopupPass.ts b/src/client/render/gl/passes/WorldTextPass.ts similarity index 78% rename from src/client/render/gl/passes/ConquestPopupPass.ts rename to src/client/render/gl/passes/WorldTextPass.ts index ad8959bdd..e4da633f0 100644 --- a/src/client/render/gl/passes/ConquestPopupPass.ts +++ b/src/client/render/gl/passes/WorldTextPass.ts @@ -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; } diff --git a/src/client/render/gl/shaders/conquest-popup/conquest-popup.frag.glsl b/src/client/render/gl/shaders/world-text/world-text.frag.glsl similarity index 100% rename from src/client/render/gl/shaders/conquest-popup/conquest-popup.frag.glsl rename to src/client/render/gl/shaders/world-text/world-text.frag.glsl diff --git a/src/client/render/gl/shaders/conquest-popup/conquest-popup.vert.glsl b/src/client/render/gl/shaders/world-text/world-text.vert.glsl similarity index 100% rename from src/client/render/gl/shaders/conquest-popup/conquest-popup.vert.glsl rename to src/client/render/gl/shaders/world-text/world-text.vert.glsl diff --git a/src/client/render/types/Renderer.ts b/src/client/render/types/Renderer.ts index 383f307d4..26fb67383 100644 --- a/src/client/render/types/Renderer.ts +++ b/src/client/render/types/Renderer.ts @@ -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)