Files
OpenFrontIO/src/client/graphics/layers/StructureLayer.ts
T
2026-05-09 19:35:39 -07:00

304 lines
9.5 KiB
TypeScript

import { colord, Colord } from "colord";
import { Theme } from "src/core/configuration/Theme";
import { assetUrl } from "../../../core/AssetUrls";
import { EventBus } from "../../../core/EventBus";
import { TransformHandler } from "../TransformHandler";
import { Layer } from "./Layer";
import { Cell, UnitType } from "../../../core/game/Game";
import { euclDistFN, isometricDistFN } from "../../../core/game/GameMap";
import { GameUpdateType } from "../../../core/game/GameUpdates";
import { GameView, UnitView } from "../../../core/game/GameView";
const cityIcon = assetUrl("images/buildings/cityAlt1.png");
const factoryIcon = assetUrl("images/buildings/factoryAlt1.png");
const shieldIcon = assetUrl("images/buildings/fortAlt3.png");
const anchorIcon = assetUrl("images/buildings/port1.png");
const missileSiloIcon = assetUrl("images/buildings/silo1.png");
const SAMMissileIcon = assetUrl("images/buildings/silo4.png");
const underConstructionColor = colord("rgb(150,150,150)");
// Base radius values and scaling factor for unit borders and territories
const BASE_BORDER_RADIUS = 16.5;
const BASE_TERRITORY_RADIUS = 13.5;
const RADIUS_SCALE_FACTOR = 0.5;
const ZOOM_THRESHOLD = 4.3; // below this zoom level, structures are not rendered
interface UnitRenderConfig {
icon: string;
borderRadius: number;
territoryRadius: number;
}
export class StructureLayer implements Layer {
private canvas: HTMLCanvasElement;
private context: CanvasRenderingContext2D;
private unitIcons: Map<string, HTMLImageElement> = new Map();
private theme: Theme;
private tempCanvas: HTMLCanvasElement;
private tempContext: CanvasRenderingContext2D;
// Configuration for supported unit types only
private readonly unitConfigs: Partial<Record<UnitType, UnitRenderConfig>> = {
[UnitType.Port]: {
icon: anchorIcon,
borderRadius: BASE_BORDER_RADIUS * RADIUS_SCALE_FACTOR,
territoryRadius: BASE_TERRITORY_RADIUS * RADIUS_SCALE_FACTOR,
},
[UnitType.City]: {
icon: cityIcon,
borderRadius: BASE_BORDER_RADIUS * RADIUS_SCALE_FACTOR,
territoryRadius: BASE_TERRITORY_RADIUS * RADIUS_SCALE_FACTOR,
},
[UnitType.Factory]: {
icon: factoryIcon,
borderRadius: BASE_BORDER_RADIUS * RADIUS_SCALE_FACTOR,
territoryRadius: BASE_TERRITORY_RADIUS * RADIUS_SCALE_FACTOR,
},
[UnitType.MissileSilo]: {
icon: missileSiloIcon,
borderRadius: BASE_BORDER_RADIUS * RADIUS_SCALE_FACTOR,
territoryRadius: BASE_TERRITORY_RADIUS * RADIUS_SCALE_FACTOR,
},
[UnitType.DefensePost]: {
icon: shieldIcon,
borderRadius: BASE_BORDER_RADIUS * RADIUS_SCALE_FACTOR,
territoryRadius: BASE_TERRITORY_RADIUS * RADIUS_SCALE_FACTOR,
},
[UnitType.SAMLauncher]: {
icon: SAMMissileIcon,
borderRadius: BASE_BORDER_RADIUS * RADIUS_SCALE_FACTOR,
territoryRadius: BASE_TERRITORY_RADIUS * RADIUS_SCALE_FACTOR,
},
};
constructor(
private game: GameView,
private eventBus: EventBus,
private transformHandler: TransformHandler,
) {
this.theme = game.config().theme();
this.tempCanvas = document.createElement("canvas");
const tempContext = this.tempCanvas.getContext("2d");
if (tempContext === null) throw new Error("2d context not supported");
this.tempContext = tempContext;
this.loadIconData();
}
private loadIcon(unitType: string, config: UnitRenderConfig) {
const image = new Image();
// crossOrigin must be set before src so the fetch is CORS-checked.
// Without this, an icon served from CDN_BASE taints any canvas/texture
// it's drawn into, and WebGL refuses to upload it via texImage2D.
image.crossOrigin = "anonymous";
image.src = config.icon;
image.onload = () => {
this.unitIcons.set(unitType, image);
console.log(
`icon loaded: ${unitType}, size: ${image.width}x${image.height}`,
);
};
image.onerror = () => {
console.error(`Failed to load icon for ${unitType}: ${config.icon}`);
};
}
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;
// Firefox's GPU limit is 8192, only known browser issue
const maxTextureSize = 8192;
const scaleX = maxTextureSize / this.game.width();
const scaleY = maxTextureSize / this.game.height();
const targetScale = Math.min(2, scaleX, scaleY);
this.canvas.width = Math.max(
1,
Math.floor(this.game.width() * targetScale),
);
this.canvas.height = Math.max(
1,
Math.floor(this.game.height() * targetScale),
);
// Enable smooth scaling
this.context.imageSmoothingEnabled = true;
this.context.imageSmoothingQuality = "high";
this.context.scale(
this.canvas.width / (this.game.width() * 2),
this.canvas.height / (this.game.height() * 2),
);
Promise.all(
Array.from(this.unitIcons.values()).map((img) =>
img.decode?.().catch((err) => {
console.warn("Failed to decode unit icon image:", err);
}),
),
).finally(() => {
this.game.units().forEach((u) => this.handleUnitRendering(u));
});
}
renderLayer(context: CanvasRenderingContext2D) {
if (
this.transformHandler.scale <= ZOOM_THRESHOLD ||
!this.game.config().userSettings()?.structureSprites()
) {
return;
}
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,
) {
// Draw border and territory
for (const tile of this.game.bfs(
unit.tile(),
isometricDistFN(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(),
isometricDistFN(unit.tile(), config.territoryRadius, true),
)) {
this.paintCell(
new Cell(this.game.x(tile), this.game.y(tile)),
unit.isUnderConstruction()
? underConstructionColor
: unit.owner().territoryColor(),
130,
);
}
}
private handleUnitRendering(unit: UnitView) {
const unitType = unit.type();
const iconType = unitType;
if (!this.isUnitTypeSupported(unitType)) return;
const config = this.unitConfigs[unitType];
let icon: HTMLImageElement | undefined;
let borderColor = unit.owner().borderColor();
// Handle cooldown states and special icons
if (unit.isUnderConstruction()) {
icon = this.unitIcons.get(iconType);
borderColor = underConstructionColor;
} else {
icon = this.unitIcons.get(iconType);
}
if (!config || !icon) return;
// Clear previous rendering
for (const tile of this.game.bfs(
unit.tile(),
euclDistFN(unit.tile(), config.borderRadius + 1, true),
)) {
this.clearCell(new Cell(this.game.x(tile), this.game.y(tile)));
}
if (!unit.isActive()) return;
this.drawBorder(unit, borderColor, config);
// Render icon at 1/2 scale for better quality
const scaledWidth = icon.width >> 1;
const scaledHeight = icon.height >> 1;
const startX = this.game.x(unit.tile()) - (scaledWidth >> 1);
const startY = this.game.y(unit.tile()) - (scaledHeight >> 1);
this.renderIcon(icon, startX, startY - 4, scaledWidth, scaledHeight, unit);
}
private renderIcon(
image: HTMLImageElement,
startX: number,
startY: number,
width: number,
height: number,
unit: UnitView,
) {
let color = unit.owner().borderColor();
if (unit.isUnderConstruction()) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
color = underConstructionColor;
}
// Make temp canvas at the final render size (2x scale)
this.tempCanvas.width = width * 2;
this.tempCanvas.height = height * 2;
// Enable smooth scaling
this.tempContext.imageSmoothingEnabled = true;
this.tempContext.imageSmoothingQuality = "high";
// Draw the image at final size with high quality scaling
this.tempContext.drawImage(image, 0, 0, width * 2, height * 2);
// Restore the alpha channel
this.tempContext.globalCompositeOperation = "destination-in";
this.tempContext.drawImage(image, 0, 0, width * 2, height * 2);
// Draw the final result to the main canvas
this.context.drawImage(this.tempCanvas, startX * 2, startY * 2);
}
paintCell(cell: Cell, color: Colord, alpha: number) {
this.clearCell(cell);
this.context.fillStyle = color.alpha(alpha / 255).toRgbString();
this.context.fillRect(cell.x * 2, cell.y * 2, 2, 2);
}
clearCell(cell: Cell) {
this.context.clearRect(cell.x * 2, cell.y * 2, 2, 2);
}
}