mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 07:50:45 +00:00
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:
@@ -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(),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
|
||||
+103
-9
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user