## Description:

Make the unit display bar a proper unit build bar
Add shortcuts for all structures and units
Add ranges for ranged structures and units
Changed the shortcuts to use the key instead of the code for
internationalization purposes


![buildbar](https://github.com/user-attachments/assets/6407dc9c-14b4-40cc-8faa-cdd9e88c9fd2)
<img width="285" height="517" alt="image"
src="https://github.com/user-attachments/assets/91bb01e6-e48c-4255-ace1-306af9cdc25b"
/>

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

Mr.Box

---------

Co-authored-by: evanpelle <evanpelle@gmail.com>
Co-authored-by: icslucas <carolinacarazolli@gmail.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
Vivacious Box
2025-10-02 21:38:28 +02:00
committed by GitHub
parent 6061c97d78
commit 311d43ab4f
17 changed files with 1283 additions and 515 deletions
@@ -0,0 +1,447 @@
import * as PIXI from "pixi.js";
import { Theme } from "../../../core/configuration/Config";
import { Cell, UnitType } from "../../../core/game/Game";
import { GameView, PlayerView, UnitView } from "../../../core/game/GameView";
import { TransformHandler } from "../TransformHandler";
import anchorIcon from "../../../../resources/images/AnchorIcon.png";
import cityIcon from "../../../../resources/images/CityIcon.png";
import factoryIcon from "../../../../resources/images/FactoryUnit.png";
import missileSiloIcon from "../../../../resources/images/MissileSiloUnit.png";
import SAMMissileIcon from "../../../../resources/images/SamLauncherUnit.png";
import shieldIcon from "../../../../resources/images/ShieldIcon.png";
export const STRUCTURE_SHAPES: Partial<Record<UnitType, ShapeType>> = {
[UnitType.City]: "circle",
[UnitType.Port]: "pentagon",
[UnitType.Factory]: "circle",
[UnitType.DefensePost]: "octagon",
[UnitType.SAMLauncher]: "square",
[UnitType.MissileSilo]: "triangle",
[UnitType.AtomBomb]: "cross",
[UnitType.HydrogenBomb]: "cross",
[UnitType.Warship]: "cross",
};
export const LEVEL_SCALE_FACTOR = 3;
export const ICON_SCALE_FACTOR_ZOOMED_IN = 3.5;
export const ICON_SCALE_FACTOR_ZOOMED_OUT = 1.4;
export const DOTS_ZOOM_THRESHOLD = 0.5;
export const ZOOM_THRESHOLD = 4.3;
export const ICON_SIZE = {
circle: 28,
octagon: 28,
pentagon: 30,
square: 28,
triangle: 28,
cross: 20,
};
export const OFFSET_ZOOM_Y = 4;
export type ShapeType =
| "triangle"
| "square"
| "pentagon"
| "octagon"
| "circle"
| "cross";
export class SpriteFactory {
private theme: Theme;
private game: GameView;
private transformHandler: TransformHandler;
private renderSprites: boolean;
private readonly textureCache: Map<string, PIXI.Texture> = new Map();
private readonly structuresInfos: Map<
UnitType,
{ iconPath: string; image: HTMLImageElement | null }
> = new Map([
[UnitType.City, { iconPath: cityIcon, image: null }],
[UnitType.Factory, { iconPath: factoryIcon, image: null }],
[UnitType.DefensePost, { iconPath: shieldIcon, image: null }],
[UnitType.Port, { iconPath: anchorIcon, image: null }],
[UnitType.MissileSilo, { iconPath: missileSiloIcon, image: null }],
[UnitType.SAMLauncher, { iconPath: SAMMissileIcon, image: null }],
]);
constructor(
theme: Theme,
game: GameView,
transformHandler: TransformHandler,
renderSprites: boolean,
) {
this.theme = theme;
this.game = game;
this.transformHandler = transformHandler;
this.renderSprites = renderSprites;
this.structuresInfos.forEach((u, unitType) => this.loadIcon(u, unitType));
}
private loadIcon(
unitInfo: {
iconPath: string;
image: HTMLImageElement | null;
},
unitType: UnitType,
) {
const image = new Image();
image.src = unitInfo.iconPath;
image.onload = () => {
unitInfo.image = image;
this.invalidateTextureCache(unitType);
};
image.onerror = () => {
console.error(
`Failed to load icon for ${unitType}: ${unitInfo.iconPath}`,
);
};
}
private invalidateTextureCache(unitType: UnitType) {
for (const key of Array.from(this.textureCache.keys())) {
if (
key.endsWith(`-${unitType}-icon`) ||
key === `construction-${unitType}-icon`
) {
this.textureCache.delete(key);
}
}
}
createGhostContainer(
player: PlayerView,
ghostStage: PIXI.Container,
pos: { x: number; y: number },
structureType: UnitType,
): PIXI.Container {
const parentContainer = new PIXI.Container();
const texture = this.createTexture(structureType, player, false, true);
const sprite = new PIXI.Sprite(texture);
sprite.anchor.set(0.5);
sprite.alpha = 0.5;
parentContainer.addChild(sprite);
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;
}
// --- internal helpers ---
public createUnitContainer(
unit: UnitView,
options: { type?: "icon" | "dot" | "level"; stage: PIXI.Container },
): PIXI.Container {
const parentContainer = new PIXI.Container();
const tile = unit.tile();
const worldPos = new Cell(this.game.x(tile), this.game.y(tile));
const screenPos = this.transformHandler.worldToScreenCoordinates(worldPos);
const isConstruction = unit.type() === UnitType.Construction;
const constructionType = unit.constructionType();
const structureType = isConstruction ? constructionType! : unit.type();
const { type, stage } = options;
const { scale } = this.transformHandler;
if (type === "icon" || type === "dot") {
if (isConstruction && constructionType === undefined) {
console.warn(
`Unit ${unit.id()} is a construction but has no construction type.`,
);
return parentContainer;
}
const texture = this.createTexture(
structureType,
unit.owner(),
isConstruction,
type === "icon",
);
const sprite = new PIXI.Sprite(texture);
sprite.anchor.set(0.5);
parentContainer.addChild(sprite);
}
if ((type === "icon" || type === "level") && unit.level() > 1) {
const text = new PIXI.BitmapText({
text: unit.level().toString(),
style: { fontFamily: "round_6x6_modified", fontSize: 14 },
});
text.anchor.set(0.5);
const shape = STRUCTURE_SHAPES[structureType];
if (shape !== undefined) {
text.position.y = Math.round(-ICON_SIZE[shape] / 2 - 2);
}
parentContainer.addChild(text);
}
const posX = Math.round(screenPos.x);
let posY = Math.round(screenPos.y);
if (type === "level" && scale >= ZOOM_THRESHOLD && this.renderSprites) {
posY = Math.round(screenPos.y - scale * OFFSET_ZOOM_Y);
}
parentContainer.position.set(posX, posY);
if (type === "icon") {
const s =
scale >= ZOOM_THRESHOLD && !this.renderSprites
? Math.max(1, scale / ICON_SCALE_FACTOR_ZOOMED_IN)
: Math.min(1, scale / ICON_SCALE_FACTOR_ZOOMED_OUT);
parentContainer.scale.set(s);
} else if (type === "level") {
parentContainer.scale.set(Math.max(1, scale / LEVEL_SCALE_FACTOR));
}
stage.addChild(parentContainer);
return parentContainer;
}
private createTexture(
type: UnitType,
owner: PlayerView,
isConstruction: boolean,
renderIcon: boolean,
): PIXI.Texture {
const cacheKey = isConstruction
? `construction-${type}` + (renderIcon ? "-icon" : "")
: `${this.theme.territoryColor(owner).toRgbString()}-${type}` +
(renderIcon ? "-icon" : "");
if (this.textureCache.has(cacheKey)) {
return this.textureCache.get(cacheKey)!;
}
const shape = STRUCTURE_SHAPES[type];
const texture = shape
? this.createIcon(owner, type, isConstruction, shape, renderIcon)
: PIXI.Texture.EMPTY;
this.textureCache.set(cacheKey, texture);
return texture;
}
private createIcon(
owner: PlayerView,
structureType: UnitType,
isConstruction: boolean,
shape: string,
renderIcon: boolean,
): PIXI.Texture {
const structureCanvas = document.createElement("canvas");
let iconSize = ICON_SIZE[shape];
if (!renderIcon) {
iconSize /= 2.5;
}
structureCanvas.width = Math.ceil(iconSize);
structureCanvas.height = Math.ceil(iconSize);
const context = structureCanvas.getContext("2d")!;
const tc = owner.territoryColor();
const bc = owner.borderColor();
const darker = bc.luminance() < tc.luminance() ? bc : tc;
const lighter = bc.luminance() < tc.luminance() ? tc : bc;
let borderColor: string;
if (isConstruction) {
context.fillStyle = "rgb(198, 198, 198)";
borderColor = "rgb(128, 127, 127)";
} else {
context.fillStyle = lighter
.lighten(0.13)
.alpha(renderIcon ? 0.65 : 1)
.toRgbString();
const darken = darker.isLight() ? 0.17 : 0.15;
borderColor = darker.darken(darken).toRgbString();
}
context.strokeStyle = borderColor;
context.lineWidth = 1;
const halfIconSize = iconSize / 2;
switch (shape) {
case "triangle":
context.beginPath();
context.moveTo(halfIconSize, 1); // Top
context.lineTo(iconSize - 1, iconSize - 1); // Bottom right
context.lineTo(0, iconSize - 1); // Bottom left
context.closePath();
context.fill();
context.stroke();
break;
case "square":
context.fillRect(1, 1, iconSize - 2, iconSize - 2);
context.strokeRect(1, 1, iconSize - 3, iconSize - 3);
break;
case "octagon":
{
const cx = halfIconSize;
const cy = halfIconSize;
const r = halfIconSize - 1;
const step = (Math.PI * 2) / 8;
context.beginPath();
for (let i = 0; i < 8; i++) {
const angle = step * i - Math.PI / 8; // slight rotation for flat top
const x = cx + r * Math.cos(angle);
const y = cy + r * Math.sin(angle);
if (i === 0) {
context.moveTo(x, y);
} else {
context.lineTo(x, y);
}
}
context.closePath();
context.fill();
context.stroke();
}
break;
case "pentagon":
{
const cx = halfIconSize;
const cy = halfIconSize;
const r = halfIconSize - 1;
const step = (Math.PI * 2) / 5;
context.beginPath();
for (let i = 0; i < 5; i++) {
const angle = step * i - Math.PI / 2; // rotate to have flat base or point up
const x = cx + r * Math.cos(angle);
const y = cy + r * Math.sin(angle);
if (i === 0) {
context.moveTo(x, y);
} else {
context.lineTo(x, y);
}
}
context.closePath();
context.fill();
context.stroke();
}
break;
case "cross": {
context.strokeStyle = "rgba(0, 0, 0, 1)";
context.fillStyle = "rgba(0, 0, 0, 1)";
const gap = iconSize * 0.18; // gap at center
const lineLen = iconSize / 2;
context.save();
context.translate(halfIconSize, halfIconSize);
// Up
context.beginPath();
context.moveTo(0, -gap);
context.lineTo(0, -lineLen);
context.stroke();
// Down
context.beginPath();
context.moveTo(0, gap);
context.lineTo(0, lineLen);
context.stroke();
// Left
context.beginPath();
context.moveTo(-gap, 0);
context.lineTo(-lineLen, 0);
context.stroke();
// Right
context.beginPath();
context.moveTo(gap, 0);
context.lineTo(lineLen, 0);
context.stroke();
context.restore();
break;
}
case "circle":
context.beginPath();
context.arc(
halfIconSize,
halfIconSize,
halfIconSize - 1,
0,
Math.PI * 2,
);
context.fill();
context.stroke();
break;
default:
throw new Error(`Unknown shape: ${shape}`);
}
const structureInfo = this.structuresInfos.get(structureType);
if (!structureInfo?.image) {
return PIXI.Texture.from(structureCanvas);
}
if (renderIcon) {
const SHAPE_OFFSETS = {
triangle: [6, 11],
square: [5, 5],
octagon: [6, 6],
pentagon: [7, 7],
circle: [6, 6],
cross: [0, 0],
};
const [offsetX, offsetY] = SHAPE_OFFSETS[shape] || [0, 0];
context.drawImage(
this.getImageColored(structureInfo.image, borderColor),
offsetX,
offsetY,
);
}
return PIXI.Texture.from(structureCanvas);
}
public createRange(
type: UnitType,
stage: PIXI.Container,
pos: { x: number; y: number },
): PIXI.Container | null {
if (stage === undefined) throw new Error("Not initialized");
const parentContainer = new PIXI.Container();
const circle = new PIXI.Graphics();
let radius = 0;
switch (type) {
case UnitType.SAMLauncher:
radius = this.game.config().defaultSamRange();
break;
case UnitType.Factory:
radius = this.game.config().trainStationMaxRange();
break;
case UnitType.DefensePost:
radius = this.game.config().defensePostRange();
break;
case UnitType.AtomBomb:
radius = this.game.config().nukeMagnitudes(UnitType.AtomBomb).outer;
break;
case UnitType.HydrogenBomb:
radius = this.game.config().nukeMagnitudes(UnitType.HydrogenBomb).outer;
break;
default:
return null;
}
circle
.circle(0, 0, radius)
.stroke({ width: 1, color: 0xffffff, alpha: 0.2 });
parentContainer.addChild(circle);
parentContainer.position.set(pos.x, pos.y);
parentContainer.scale.set(this.transformHandler.scale);
stage.addChild(parentContainer);
return parentContainer;
}
private getImageColored(
image: HTMLImageElement,
color: string,
): HTMLCanvasElement {
const imageCanvas = document.createElement("canvas");
imageCanvas.width = image.width;
imageCanvas.height = image.height;
const ctx = imageCanvas.getContext("2d")!;
ctx.fillStyle = color;
ctx.fillRect(0, 0, imageCanvas.width, imageCanvas.height);
ctx.globalCompositeOperation = "destination-in";
ctx.drawImage(image, 0, 0);
return imageCanvas;
}
}