Feat: Add cost to ghost structure icon when using keyboard shortcuts (#2650)

## Description:

Introduces a dynamic textbox under the cursor and populates it with
price when a keyboard hotkey is pressed. Prices update correctly based
on current value of the structure or strike being purchased, even if the
value is 0 (during `Infinite Gold` mode). Price value updates live even
if the price box is currently being shown (for example, when voluntarily
removing a structure causes the price to change. See video below).

### Video Demo 


https://github.com/user-attachments/assets/3f974268-c14b-4129-9629-5a0f7db8b30c


The more in depth demo was too big for GitHub, but I uploaded it on the
Discord

https://discord.com/channels/1284581928254701718/1447907175522504704/1451483322260914297

### Live price updates on tooltip


https://github.com/user-attachments/assets/0d98739c-6f24-4fcd-a047-cc304e7e86aa

### Works with `Infinite Gold` mode


https://github.com/user-attachments/assets/25bd2919-77cd-4735-8c3f-043306f53b8f




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

bijx
This commit is contained in:
bijx
2025-12-19 22:06:21 -05:00
committed by GitHub
parent 8a281da429
commit 4e8aa1f066
2 changed files with 81 additions and 8 deletions
@@ -110,7 +110,12 @@ export class SpriteFactory {
ghostStage: PIXI.Container,
pos: { x: number; y: number },
structureType: UnitType,
): PIXI.Container {
): {
container: PIXI.Container;
priceText: PIXI.BitmapText;
priceBg: PIXI.Graphics;
priceBox: { height: number; y: number; paddingX: number; minWidth: number };
} {
const parentContainer = new PIXI.Container();
const texture = this.createTexture(
structureType,
@@ -123,12 +128,45 @@ export class SpriteFactory {
sprite.anchor.set(0.5);
sprite.alpha = 0.5;
parentContainer.addChild(sprite);
const priceText = new PIXI.BitmapText({
text: "125K",
style: { fontFamily: "round_6x6_modified", fontSize: 12 },
});
priceText.anchor.set(0.5);
const priceGroup = new PIXI.Container();
const boxHeight = 18;
const boxY =
(sprite.height > 0 ? sprite.height / 2 : 16) + boxHeight / 2 + 4;
// a way to resize the pill horizontally based on the text width
const paddingX = 8;
const minWidth = 32;
const textWidth = priceText.width;
const boxWidth = Math.max(minWidth, textWidth + paddingX * 2);
const priceBg = new PIXI.Graphics();
priceBg
.roundRect(-boxWidth / 2, boxY - boxHeight / 2, boxWidth, boxHeight, 4)
.fill({ color: 0x000000, alpha: 0.65 });
priceText.position.set(0, boxY);
priceGroup.addChild(priceBg);
priceGroup.addChild(priceText);
parentContainer.addChild(priceGroup);
parentContainer.position.set(pos.x, pos.y);
parentContainer.scale.set(
Math.min(1, this.transformHandler.scale / ICON_SCALE_FACTOR_ZOOMED_OUT),
);
ghostStage.addChild(parentContainer);
return parentContainer;
return {
container: parentContainer,
priceText,
priceBg,
priceBox: { height: boxHeight, y: boxY, paddingX, minWidth },
};
}
// --- internal helpers ---
@@ -25,6 +25,7 @@ import {
BuildUnitIntentEvent,
SendUpgradeStructureIntentEvent,
} from "../../Transport";
import { renderNumber } from "../../Utils";
import { TransformHandler } from "../TransformHandler";
import { UIState } from "../UIState";
import { Layer } from "./Layer";
@@ -58,6 +59,9 @@ class StructureRenderInfo {
export class StructureIconsLayer implements Layer {
private ghostUnit: {
container: PIXI.Container;
priceText: PIXI.BitmapText;
priceBg: PIXI.Graphics;
priceBox: { height: number; y: number; paddingX: number; minWidth: number };
range: PIXI.Container | null;
rangeLevel?: number;
buildableUnit: BuildableUnit;
@@ -271,6 +275,7 @@ export class StructureIconsLayer implements Layer {
canBuild: false,
canUpgrade: false,
});
this.updateGhostPrice(0);
this.ghostUnit.container.filters = [
new OutlineFilter({ thickness: 2, color: "rgba(255, 0, 0, 1)" }),
];
@@ -278,6 +283,7 @@ export class StructureIconsLayer implements Layer {
}
this.ghostUnit.buildableUnit = unit;
this.updateGhostPrice(unit.cost ?? 0);
const targetLevel = this.resolveGhostRangeLevel(unit);
this.updateGhostRange(targetLevel);
@@ -312,6 +318,30 @@ export class StructureIconsLayer implements Layer {
});
}
private updateGhostPrice(cost: bigint | number) {
if (!this.ghostUnit) return;
const { priceText, priceBg, priceBox } = this.ghostUnit;
priceText.text = renderNumber(cost);
priceText.position.set(0, priceBox.y);
const textWidth = priceText.width;
const boxWidth = Math.max(
priceBox.minWidth,
textWidth + priceBox.paddingX * 2,
);
priceBg.clear();
priceBg
.roundRect(
-boxWidth / 2,
priceBox.y - priceBox.height / 2,
boxWidth,
priceBox.height,
4,
)
.fill({ color: 0x000000, alpha: 0.65 });
}
private createStructure(e: MouseUpEvent) {
if (!this.ghostUnit) return;
if (
@@ -367,16 +397,21 @@ export class StructureIconsLayer implements Layer {
const rect = this.transformHandler.boundingRect();
const localX = this.mousePos.x - rect.left;
const localY = this.mousePos.y - rect.top;
const ghost = this.factory.createGhostContainer(
player,
this.ghostStage,
{ x: localX, y: localY },
type,
);
this.ghostUnit = {
container: this.factory.createGhostContainer(
player,
this.ghostStage,
{ x: localX, y: localY },
type,
),
container: ghost.container,
priceText: ghost.priceText,
priceBg: ghost.priceBg,
priceBox: ghost.priceBox,
range: null,
buildableUnit: { type, canBuild: false, canUpgrade: false, cost: 0n },
};
this.updateGhostPrice(0);
const baseLevel = this.resolveGhostRangeLevel(this.ghostUnit.buildableUnit);
this.updateGhostRange(baseLevel);
}