Files
OpenFrontIO/src/client/graphics/layers/StructureLayer.ts
T
Scott Anderson 70745faac4 Enable strictNullChecks, eqeqeq (#436)
## Description:

Improve type safety and runtime correctness by:
1. Enabling TypeScript's
[strictNullChecks](https://www.typescriptlang.org/tsconfig/#strictNullChecks)
compiler option.
2. Replacing all loose equality operators (`==` and `!=`) with strict
equality operators (`===` and `!==`).
3. Cleaning up of type declarations, null handling logic, and equality
expressions throughout the project.

Currently, the code allows implicit assumptions that `null` and
`undefined` are interchangeable, and relies on type-coercing equality
checks that can introduce subtle bugs. These practices make it difficult
to reason about when values may be absent and hinder the effectiveness
of static analysis.

Migrating to strict null checks and enforcing strict equality
comparisons will clarify intent, reduce bugs, and make the codebase
safer and easier to maintain.

Fixes #466 

## Please complete the following:

- [x] I have added screenshots for all UI updates
- [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

---------

Co-authored-by: Scott Anderson <662325+scottanderson@users.noreply.github.com>
Co-authored-by: evanpelle <openfrontio@gmail.com>
2025-05-15 16:39:40 -07:00

320 lines
9.1 KiB
TypeScript

import { colord, Colord } from "colord";
import { Theme } from "../../../core/configuration/Config";
import { EventBus } from "../../../core/EventBus";
import { Layer } from "./Layer";
import cityIcon from "../../../../resources/images/buildings/cityAlt1.png";
import shieldIcon from "../../../../resources/images/buildings/fortAlt2.png";
import anchorIcon from "../../../../resources/images/buildings/port1.png";
import MissileSiloReloadingIcon from "../../../../resources/images/buildings/silo1-reloading.png";
import missileSiloIcon from "../../../../resources/images/buildings/silo1.png";
import SAMMissileReloadingIcon from "../../../../resources/images/buildings/silo4-reloading.png";
import SAMMissileIcon from "../../../../resources/images/buildings/silo4.png";
import { Cell, UnitType } from "../../../core/game/Game";
import {
euclDistFN,
hexDistFN,
manhattanDistFN,
rectDistFN,
} from "../../../core/game/GameMap";
import { GameUpdateType } from "../../../core/game/GameUpdates";
import { GameView, UnitView } from "../../../core/game/GameView";
const underConstructionColor = colord({ r: 150, g: 150, b: 150 });
const reloadingColor = colord({ r: 255, g: 0, b: 0 });
type DistanceFunction = typeof euclDistFN;
enum UnitBorderType {
Round,
Diamond,
Square,
Hexagon,
}
interface UnitRenderConfig {
icon: string;
borderRadius: number;
territoryRadius: number;
borderType: UnitBorderType;
}
export class StructureLayer implements Layer {
private canvas: HTMLCanvasElement;
private context: CanvasRenderingContext2D;
private unitIcons: Map<string, ImageData> = new Map();
private theme: Theme;
// Configuration for supported unit types only
private readonly unitConfigs: Partial<Record<UnitType, UnitRenderConfig>> = {
[UnitType.Port]: {
icon: anchorIcon,
borderRadius: 8.525,
territoryRadius: 6.525,
borderType: UnitBorderType.Round,
},
[UnitType.City]: {
icon: cityIcon,
borderRadius: 8.525,
territoryRadius: 6.525,
borderType: UnitBorderType.Round,
},
[UnitType.MissileSilo]: {
icon: missileSiloIcon,
borderRadius: 8.525,
territoryRadius: 6.525,
borderType: UnitBorderType.Square,
},
[UnitType.DefensePost]: {
icon: shieldIcon,
borderRadius: 8.525,
territoryRadius: 6.525,
borderType: UnitBorderType.Hexagon,
},
[UnitType.SAMLauncher]: {
icon: SAMMissileIcon,
borderRadius: 8.525,
territoryRadius: 6.525,
borderType: UnitBorderType.Square,
},
};
constructor(
private game: GameView,
private eventBus: EventBus,
) {
this.theme = game.config().theme();
this.loadIconData();
this.loadIcon("reloadingSam", {
icon: SAMMissileReloadingIcon,
borderRadius: 8.525,
territoryRadius: 6.525,
borderType: UnitBorderType.Square,
});
this.loadIcon("reloadingSilo", {
icon: MissileSiloReloadingIcon,
borderRadius: 8.525,
territoryRadius: 6.525,
borderType: UnitBorderType.Square,
});
}
private loadIcon(unitType: string, config: UnitRenderConfig) {
const image = new Image();
image.src = config.icon;
image.onload = () => {
// Create temporary canvas for icon processing
const tempCanvas = document.createElement("canvas");
const tempContext = tempCanvas.getContext("2d");
if (tempContext === null) throw new Error("2d context not supported");
tempCanvas.width = image.width;
tempCanvas.height = image.height;
// Draw the unit icon
tempContext.drawImage(image, 0, 0);
const iconData = tempContext.getImageData(
0,
0,
tempCanvas.width,
tempCanvas.height,
);
this.unitIcons.set(unitType, iconData);
console.log(
`icon data width height: ${iconData.width}, ${iconData.height}`,
);
};
}
private loadIconData() {
Object.entries(this.unitConfigs).forEach(([unitType, config]) => {
this.loadIcon(unitType, config);
});
}
shouldTransform(): boolean {
return true;
}
tick() {
const updates = this.game.updatesSinceLastTick();
const unitUpdates = updates !== null ? updates[GameUpdateType.Unit] : [];
for (const u of unitUpdates) {
const unit = this.game.unit(u.id);
if (unit === undefined) continue;
this.handleUnitRendering(unit);
}
}
init() {
this.redraw();
}
redraw() {
console.log("structure layer redrawing");
this.canvas = document.createElement("canvas");
const context = this.canvas.getContext("2d", { alpha: true });
if (context === null) throw new Error("2d context not supported");
this.context = context;
this.canvas.width = this.game.width();
this.canvas.height = this.game.height();
this.game.units().forEach((u) => this.handleUnitRendering(u));
}
renderLayer(context: CanvasRenderingContext2D) {
context.drawImage(
this.canvas,
-this.game.width() / 2,
-this.game.height() / 2,
this.game.width(),
this.game.height(),
);
}
private isUnitTypeSupported(unitType: UnitType): boolean {
return unitType in this.unitConfigs;
}
private drawBorder(
unit: UnitView,
borderColor: Colord,
config: UnitRenderConfig,
distanceFN: DistanceFunction,
) {
// Draw border and territory
for (const tile of this.game.bfs(
unit.tile(),
distanceFN(unit.tile(), config.borderRadius, true),
)) {
this.paintCell(
new Cell(this.game.x(tile), this.game.y(tile)),
borderColor,
255,
);
}
for (const tile of this.game.bfs(
unit.tile(),
distanceFN(unit.tile(), config.territoryRadius, true),
)) {
this.paintCell(
new Cell(this.game.x(tile), this.game.y(tile)),
unit.type() === UnitType.Construction
? underConstructionColor
: this.theme.territoryColor(unit.owner()),
130,
);
}
}
private getDrawFN(type: UnitBorderType) {
switch (type) {
case UnitBorderType.Round:
return euclDistFN;
case UnitBorderType.Diamond:
return manhattanDistFN;
case UnitBorderType.Square:
return rectDistFN;
case UnitBorderType.Hexagon:
return hexDistFN;
}
}
private handleUnitRendering(unit: UnitView) {
const unitType = unit.constructionType() ?? unit.type();
const iconType = unitType;
if (!this.isUnitTypeSupported(unitType)) return;
const config = this.unitConfigs[unitType];
let icon: ImageData | undefined;
if (unitType === UnitType.SAMLauncher && unit.isCooldown()) {
icon = this.unitIcons.get("reloadingSam");
} else {
icon = this.unitIcons.get(iconType);
}
if (unitType === UnitType.MissileSilo && unit.isCooldown()) {
icon = this.unitIcons.get("reloadingSilo");
} else {
icon = this.unitIcons.get(iconType);
}
if (!config || !icon) return;
const drawFunction = this.getDrawFN(config.borderType);
// Clear previous rendering
for (const tile of this.game.bfs(
unit.tile(),
drawFunction(unit.tile(), config.borderRadius, true),
)) {
this.clearCell(new Cell(this.game.x(tile), this.game.y(tile)));
}
if (!unit.isActive()) return;
let borderColor = this.theme.borderColor(unit.owner());
if (unitType === UnitType.SAMLauncher && unit.isCooldown()) {
borderColor = reloadingColor;
} else if (unit.type() === UnitType.Construction) {
borderColor = underConstructionColor;
}
if (unitType === UnitType.MissileSilo && unit.isCooldown()) {
borderColor = reloadingColor;
} else if (unit.type() === UnitType.Construction) {
borderColor = underConstructionColor;
}
this.drawBorder(unit, borderColor, config, drawFunction);
const startX = this.game.x(unit.tile()) - Math.floor(icon.width / 2);
const startY = this.game.y(unit.tile()) - Math.floor(icon.height / 2);
// Draw the icon
this.renderIcon(icon, startX, startY, icon.width, icon.height, unit);
}
private renderIcon(
iconData: ImageData,
startX: number,
startY: number,
width: number,
height: number,
unit: UnitView,
) {
let color = this.theme.borderColor(unit.owner());
if (unit.type() === UnitType.Construction) {
color = underConstructionColor;
}
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const iconIndex = (y * width + x) * 4;
const alpha = iconData.data[iconIndex + 3];
if (alpha > 0) {
const targetX = startX + x;
const targetY = startY + y;
if (
targetX >= 0 &&
targetX < this.game.width() &&
targetY >= 0 &&
targetY < this.game.height()
) {
this.paintCell(new Cell(targetX, targetY), color, alpha);
}
}
}
}
}
paintCell(cell: Cell, color: Colord, alpha: number) {
this.clearCell(cell);
this.context.fillStyle = color.alpha(alpha / 255).toRgbString();
this.context.fillRect(cell.x, cell.y, 1, 1);
}
clearCell(cell: Cell) {
this.context.clearRect(cell.x, cell.y, 1, 1);
}
}