Add progress bars to show loading time and healthbars (#1107)

## Description:


Add progress bars to show construction time, loading time and health
bars in the UI layer

The progress bars always show at least one pixel of progression (better
visuals)


![buildcity](https://github.com/user-attachments/assets/7181642a-742d-4996-8ca9-748b55c04a58)

![launchNuke](https://github.com/user-attachments/assets/85fbed8f-3d91-4d7e-9c01-737ee5868992)

![ships2](https://github.com/user-attachments/assets/9fd53e6a-b2c7-4044-8b65-6f61231775b1)


## 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
- [x] I understand that submitting code with bugs that could have been
caught through manual testing blocks releases and new features for all
contributors

## Please put your Discord username so you can be contacted if a bug or
regression is found:

Vivacious Box

---------

Co-authored-by: evanpelle <evanpelle@gmail.com>
This commit is contained in:
Vivacious Box
2025-06-10 23:50:31 +03:00
committed by evanpelle
parent 1cfeaf8c2a
commit c4895df40f
6 changed files with 1657 additions and 19 deletions
+1273 -14
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -43,6 +43,7 @@
"autoprefixer": "^10.4.20",
"babel-jest": "^29.7.0",
"binary-base64-loader": "^1.0.0",
"canvas": "^3.1.0",
"chai": "^5.1.1",
"concurrently": "^8.2.2",
"cross-env": "^7.0.3",
@@ -57,6 +58,7 @@
"html-loader": "^5.1.0",
"husky": "^9.1.7",
"jest": "^29.7.0",
"jest-environment-jsdom": "^30.0.0-beta.3",
"lint-staged": "^15.4.3",
"mrmime": "^2.0.0",
"postcss": "^8.5.1",
+61
View File
@@ -0,0 +1,61 @@
export class ProgressBar {
private static readonly CLEAR_PADDING = 2;
constructor(
private colors: string[] = [],
private ctx: CanvasRenderingContext2D,
private x: number,
private y: number,
private w: number,
private h: number,
private progress: number = 0, // Progress from 0 to 1
) {
this.setProgress(progress);
}
setProgress(progress: number): void {
progress = Math.max(0, Math.min(1, progress));
this.clear();
// Draw the loading bar background
this.ctx.fillStyle = "rgba(0, 0, 0, 1)";
this.ctx.fillRect(this.x - 1, this.y - 1, this.w, this.h);
// Draw the loading progress
if (this.colors.length === 0) {
this.ctx.fillStyle = "#808080"; // default gray
} else {
const idx = Math.min(
this.colors.length - 1,
Math.floor(progress * this.colors.length),
);
this.ctx.fillStyle = this.colors[idx];
}
this.ctx.fillRect(
this.x,
this.y,
Math.max(1, Math.floor(progress * (this.w - 2))),
this.h - 2,
);
this.progress = progress;
}
clear() {
this.ctx.clearRect(
this.x - ProgressBar.CLEAR_PADDING,
this.y - ProgressBar.CLEAR_PADDING,
this.w + ProgressBar.CLEAR_PADDING,
this.h + ProgressBar.CLEAR_PADDING,
);
}
getX(): number {
return this.x;
}
getY(): number {
return this.y;
}
getProgress(): number {
return this.progress;
}
}
+127 -5
View File
@@ -1,12 +1,24 @@
import { Colord } from "colord";
import { EventBus } from "../../../core/EventBus";
import { Theme } from "../../../core/configuration/Config";
import { UnitType } from "../../../core/game/Game";
import { Tick, UnitType } from "../../../core/game/Game";
import { GameUpdateType } from "../../../core/game/GameUpdates";
import { GameView, UnitView } from "../../../core/game/GameView";
import { UnitSelectionEvent } from "../../InputHandler";
import { ProgressBar } from "../ProgressBar";
import { TransformHandler } from "../TransformHandler";
import { Layer } from "./Layer";
const COLOR_PROGRESSION = [
"rgb(232, 25, 25)",
"rgb(240, 122, 25)",
"rgb(202, 231, 15)",
"rgb(44, 239, 18)",
];
const HEALTHBAR_WIDTH = 11; // Width of the health bar
const LOADINGBAR_WIDTH = 18; // Width of the loading bar
const PROGRESSBAR_HEIGHT = 3; // Height of a bar
/**
* Layer responsible for drawing UI elements that overlay the game
* such as selection boxes, health bars, etc.
@@ -17,7 +29,11 @@ export class UILayer implements Layer {
private theme: Theme | null = null;
private selectionAnimTime = 0;
private allProgressBars: Map<
number,
{ unit: UnitView; startTick: Tick; endTick: Tick; progressBar: ProgressBar }
> = new Map();
private allHealthBars: Map<number, ProgressBar> = new Map();
// Keep track of currently selected unit
private selectedUnit: UnitView | null = null;
@@ -51,6 +67,16 @@ export class UILayer implements Layer {
if (this.selectedUnit && this.selectedUnit.type() === UnitType.Warship) {
this.drawSelectionBox(this.selectedUnit);
}
this.game
.updatesSinceLastTick()
?.[GameUpdateType.Unit]?.map((unit) => this.game.unit(unit.id))
?.forEach((unitView) => {
if (unitView === undefined) return;
this.onUnitEvent(unitView);
});
this.updateProgressBars();
}
init() {
@@ -76,6 +102,42 @@ export class UILayer implements Layer {
this.canvas.height = this.game.height();
}
onUnitEvent(unit: UnitView) {
switch (unit.type()) {
case UnitType.Construction: {
const playerId = this.game.myPlayer()?.id();
if (
unit.isActive() &&
playerId !== undefined &&
unit.owner().id() === playerId
) {
const constructionType = unit.constructionType();
if (constructionType === undefined) {
// Skip units without construction type
return;
}
const endTick =
this.game.unitInfo(constructionType).constructionDuration || 0;
this.drawLoadingBar(unit, endTick);
}
break;
}
case UnitType.Warship: {
this.drawHealthBar(unit);
break;
}
case UnitType.SAMLauncher:
case UnitType.MissileSilo:
if (unit.isActive() && unit.isCooldown()) {
const endTick = unit.ticksLeftInCooldown() || 0;
this.drawLoadingBar(unit, endTick);
}
break;
default:
return;
}
}
/**
* Handle the unit selection event
*/
@@ -187,11 +249,71 @@ export class UILayer implements Layer {
}
/**
* Draw health bar for a unit (placeholder for future implementation)
* Draw health bar for a unit
*/
public drawHealthBar(unit: UnitView) {
// This is a placeholder for future health bar implementation
// It would draw a health bar above units that have health
const maxHealth = this.game.unitInfo(unit.type()).maxHealth;
if (maxHealth === undefined || this.context === null) {
return;
}
if (
this.allHealthBars.has(unit.id()) &&
(unit.health() >= maxHealth || unit.health() <= 0 || !unit.isActive())
) {
// full hp/dead warships dont need a hp bar
this.allHealthBars.get(unit.id())?.clear();
this.allHealthBars.delete(unit.id());
} else if (unit.health() < maxHealth && unit.health() > 0) {
this.allHealthBars.get(unit.id())?.clear();
const healthBar = new ProgressBar(
COLOR_PROGRESSION,
this.context,
this.game.x(unit.tile()) - 4,
this.game.y(unit.tile()) - 6,
HEALTHBAR_WIDTH,
PROGRESSBAR_HEIGHT,
unit.health() / maxHealth,
);
// keep track of units that have health bars for clearing purposes
this.allHealthBars.set(unit.id(), healthBar);
}
}
private updateProgressBars() {
const currentTick = this.game.ticks();
this.allProgressBars.forEach((progressBarInfo, unitId) => {
const progress =
(currentTick - progressBarInfo.startTick) / progressBarInfo.endTick;
if (progress >= 1 || !progressBarInfo.unit.isActive()) {
this.allProgressBars.get(unitId)?.progressBar.clear();
this.allProgressBars.delete(unitId);
return;
}
progressBarInfo.progressBar.setProgress(progress);
});
}
public drawLoadingBar(unit: UnitView, endTick: Tick) {
if (!this.context) {
return;
}
if (!this.allProgressBars.has(unit.id())) {
const progressBar = new ProgressBar(
COLOR_PROGRESSION,
this.context,
this.game.x(unit.tile()) - 8,
this.game.y(unit.tile()) - 10,
LOADINGBAR_WIDTH,
PROGRESSBAR_HEIGHT,
0,
);
this.allProgressBars.set(unit.id(), {
unit,
startTick: this.game.ticks(),
endTick,
progressBar,
});
}
}
paintCell(x: number, y: number, color: Colord, alpha: number) {
+58
View File
@@ -0,0 +1,58 @@
/**
* @jest-environment jsdom
*/
import { ProgressBar } from "../../../src/client/graphics/ProgressBar";
describe("ProgressBar", () => {
let ctx: CanvasRenderingContext2D;
let canvas: HTMLCanvasElement;
beforeEach(() => {
canvas = document.createElement("canvas");
canvas.width = 100;
canvas.height = 20;
ctx = canvas.getContext("2d")!;
});
it("should initialize and draw the background", () => {
const spyClearRect = jest.spyOn(ctx, "clearRect");
const spyFillRect = jest.spyOn(ctx, "fillRect");
const spyFillStyle = jest.spyOn(ctx, "fillStyle", "set");
const bar = new ProgressBar(["#ff0000", "#00ff00"], ctx, 2, 2, 80, 10, 0.5);
expect(spyClearRect).toHaveBeenCalledWith(0, 0, 82, 12);
expect(spyFillRect).toHaveBeenCalledWith(1, 1, 80, 10);
expect(spyFillStyle).toHaveBeenCalledWith("#00ff00");
expect(bar.getX()).toBe(2);
expect(bar.getY()).toBe(2);
});
it("should set progress and draw the progress bar", () => {
const bar = new ProgressBar(["#ff0000", "#00ff00"], ctx, 2, 2, 80, 10);
const spyFillRect = jest.spyOn(ctx, "fillRect");
bar.setProgress(0.5);
expect(bar.getProgress()).toBe(0.5);
expect(spyFillRect).toHaveBeenCalledWith(
2,
2,
Math.floor(0.5 * (80 - 2)),
8,
);
expect(ctx.fillStyle).toBe("#00ff00");
bar.setProgress(0.1);
expect(ctx.fillStyle).toBe("#ff0000");
});
it("should clamp progress between 0 and 1 on init", () => {
const bar = new ProgressBar(["#ff0000", "#00ff00"], ctx, 2, 2, 80, 10, -1);
expect(bar.getProgress()).toBe(0);
const bar2 = new ProgressBar(["#ff0000", "#00ff00"], ctx, 2, 2, 80, 10, 2);
expect(bar2.getProgress()).toBe(1);
});
it("should handle empty colors array gracefully", () => {
const bar = new ProgressBar([], ctx, 2, 2, 80, 10, 0.5);
expect(() => bar.setProgress(0.5)).not.toThrow();
expect(ctx.fillStyle).toBe("#808080");
});
});
+136
View File
@@ -0,0 +1,136 @@
/**
* @jest-environment jsdom
*/
import { UILayer } from "../../../src/client/graphics/layers/UILayer";
import { UnitSelectionEvent } from "../../../src/client/InputHandler";
import { UnitView } from "../../../src/core/game/GameView";
describe("UILayer", () => {
let game: any;
let eventBus: any;
let transformHandler: any;
beforeEach(() => {
game = {
width: () => 100,
height: () => 100,
config: () => ({
theme: () => ({
territoryColor: () => ({
lighten: () => ({ alpha: () => ({ toRgbString: () => "#fff" }) }),
}),
}),
}),
x: () => 10,
y: () => 10,
unitInfo: () => ({ maxHealth: 10, constructionDuration: 5 }),
myPlayer: () => ({ id: () => 1 }),
ticks: () => 1,
updatesSinceLastTick: () => undefined,
};
eventBus = { on: jest.fn() };
transformHandler = {};
});
it("should initialize and redraw canvas", () => {
const ui = new UILayer(game, eventBus, transformHandler);
ui.redraw();
expect(ui["canvas"].width).toBe(100);
expect(ui["canvas"].height).toBe(100);
expect(ui["context"]).not.toBeNull();
});
it("should handle unit selection event", () => {
const ui = new UILayer(game, eventBus, transformHandler);
ui.redraw();
const unit = {
type: () => "Warship",
isActive: () => true,
tile: () => ({}),
owner: () => ({}),
};
const event = { isSelected: true, unit };
ui.drawSelectionBox = jest.fn();
ui["onUnitSelection"](event as UnitSelectionEvent);
expect(ui.drawSelectionBox).toHaveBeenCalledWith(unit);
});
it("should add and clear health bars", () => {
const ui = new UILayer(game, eventBus, transformHandler);
ui.redraw();
const unit = {
id: () => 1,
type: () => "Warship",
health: () => 5,
tile: () => ({}),
owner: () => ({}),
isActive: () => true,
} as unknown as UnitView;
ui.drawHealthBar(unit);
expect(ui["allHealthBars"].has(1)).toBe(true);
// a full hp unit doesnt have a health bar
unit.health = () => 10;
ui.drawHealthBar(unit);
expect(ui["allHealthBars"].has(1)).toBe(false);
// a dead unit doesnt have a health bar
unit.health = () => 5;
ui.drawHealthBar(unit);
expect(ui["allHealthBars"].has(1)).toBe(true);
unit.health = () => 0;
ui.drawHealthBar(unit);
expect(ui["allHealthBars"].has(1)).toBe(false);
});
it("should add loading bar for unit", () => {
const ui = new UILayer(game, eventBus, transformHandler);
ui.redraw();
const unit = {
id: () => 2,
tile: () => ({}),
isActive: () => true,
} as unknown as UnitView;
ui.drawLoadingBar(unit, 5);
expect(ui["allProgressBars"].has(2)).toBe(true);
});
it("should remove loading bar for inactive unit", () => {
const ui = new UILayer(game, eventBus, transformHandler);
ui.redraw();
const unit = {
id: () => 2,
type: () => "Construction",
constructionType: () => "City",
owner: () => ({ id: () => 1 }),
tile: () => ({}),
isActive: () => true,
} as unknown as UnitView;
ui.onUnitEvent(unit);
expect(ui["allProgressBars"].has(2)).toBe(true);
// an inactive unit should not have a loading bar
unit.isActive = () => false;
ui.tick();
expect(ui["allProgressBars"].has(2)).toBe(false);
});
it("should remove loading bar for a finished progress bar", () => {
const ui = new UILayer(game, eventBus, transformHandler);
ui.redraw();
const unit = {
id: () => 2,
type: () => "Construction",
constructionType: () => "City",
owner: () => ({ id: () => 1 }),
tile: () => ({}),
isActive: () => true,
} as unknown as UnitView;
ui.onUnitEvent(unit);
expect(ui["allProgressBars"].has(2)).toBe(true);
game.ticks = () => 6; // simulate enough ticks for completion
ui.tick();
expect(ui["allProgressBars"].has(2)).toBe(false);
});
});