mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 14:30:44 +00:00
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)    ## 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:
Generated
+1273
-14
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user