mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-29 00:44:16 +00:00
New icons (#1287)
## Description: Add a new pixi layer for rendering structure icons Add new sprites for structures  ## 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:
@@ -28,6 +28,7 @@ import { RailroadLayer } from "./layers/RailroadLayer";
|
||||
import { ReplayPanel } from "./layers/ReplayPanel";
|
||||
import { SpawnAd } from "./layers/SpawnAd";
|
||||
import { SpawnTimer } from "./layers/SpawnTimer";
|
||||
import { StructureIconsLayer } from "./layers/StructureIconsLayer";
|
||||
import { StructureLayer } from "./layers/StructureLayer";
|
||||
import { TeamStats } from "./layers/TeamStats";
|
||||
import { TerrainLayer } from "./layers/TerrainLayer";
|
||||
@@ -227,6 +228,7 @@ export function createRenderer(
|
||||
new TerritoryLayer(game, eventBus, transformHandler, userSettings),
|
||||
new RailroadLayer(game),
|
||||
structureLayer,
|
||||
new StructureIconsLayer(game, transformHandler),
|
||||
new UnitLayer(game, eventBus, transformHandler),
|
||||
new FxLayer(game),
|
||||
new UILayer(game, eventBus, transformHandler),
|
||||
@@ -312,6 +314,7 @@ export class GameRenderer {
|
||||
resizeCanvas() {
|
||||
this.canvas.width = window.innerWidth;
|
||||
this.canvas.height = window.innerHeight;
|
||||
this.transformHandler.updateCanvasBoundingRect();
|
||||
//this.redraw()
|
||||
}
|
||||
|
||||
@@ -325,24 +328,33 @@ export class GameRenderer {
|
||||
.toHex();
|
||||
this.context.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
|
||||
// Save the current context state
|
||||
this.context.save();
|
||||
|
||||
this.transformHandler.handleTransform(this.context);
|
||||
|
||||
this.layers.forEach((l) => {
|
||||
if (l.shouldTransform?.()) {
|
||||
l.renderLayer?.(this.context);
|
||||
const handleTransformState = (
|
||||
needsTransform: boolean,
|
||||
active: boolean,
|
||||
): boolean => {
|
||||
if (needsTransform && !active) {
|
||||
this.context.save();
|
||||
this.transformHandler.handleTransform(this.context);
|
||||
return true;
|
||||
} else if (!needsTransform && active) {
|
||||
this.context.restore();
|
||||
return false;
|
||||
}
|
||||
});
|
||||
return active;
|
||||
};
|
||||
|
||||
this.context.restore();
|
||||
let isTransformActive = false;
|
||||
|
||||
this.layers.forEach((l) => {
|
||||
if (!l.shouldTransform?.()) {
|
||||
l.renderLayer?.(this.context);
|
||||
}
|
||||
});
|
||||
for (const layer of this.layers) {
|
||||
const needsTransform = layer.shouldTransform?.() ?? false;
|
||||
isTransformActive = handleTransformState(
|
||||
needsTransform,
|
||||
isTransformActive,
|
||||
);
|
||||
layer.renderLayer?.(this.context);
|
||||
}
|
||||
handleTransformState(false, isTransformActive); // Ensure context is clean after rendering
|
||||
this.transformHandler.resetChanged();
|
||||
|
||||
requestAnimationFrame(() => this.renderGame());
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ export const CAMERA_SMOOTHING = 0.03;
|
||||
|
||||
export class TransformHandler {
|
||||
public scale: number = 1.8;
|
||||
private _boundingRect: DOMRect;
|
||||
private offsetX: number = -350;
|
||||
private offsetY: number = -200;
|
||||
private lastGoToCallTime: number | null = null;
|
||||
@@ -27,6 +28,7 @@ export class TransformHandler {
|
||||
private eventBus: EventBus,
|
||||
private canvas: HTMLCanvasElement,
|
||||
) {
|
||||
this._boundingRect = this.canvas.getBoundingClientRect();
|
||||
this.eventBus.on(ZoomEvent, (e) => this.onZoom(e));
|
||||
this.eventBus.on(DragEvent, (e) => this.onMove(e));
|
||||
this.eventBus.on(GoToPlayerEvent, (e) => this.onGoToPlayer(e));
|
||||
@@ -35,8 +37,12 @@ export class TransformHandler {
|
||||
this.eventBus.on(CenterCameraEvent, () => this.centerCamera());
|
||||
}
|
||||
|
||||
public updateCanvasBoundingRect() {
|
||||
this._boundingRect = this.canvas.getBoundingClientRect();
|
||||
}
|
||||
|
||||
boundingRect(): DOMRect {
|
||||
return this.canvas.getBoundingClientRect();
|
||||
return this._boundingRect;
|
||||
}
|
||||
|
||||
width(): number {
|
||||
@@ -45,6 +51,9 @@ export class TransformHandler {
|
||||
hasChanged(): boolean {
|
||||
return this.changed;
|
||||
}
|
||||
resetChanged() {
|
||||
this.changed = false;
|
||||
}
|
||||
|
||||
handleTransform(context: CanvasRenderingContext2D) {
|
||||
// Disable image smoothing for pixelated effect
|
||||
@@ -59,7 +68,6 @@ export class TransformHandler {
|
||||
this.game.width() / 2 - this.offsetX * this.scale,
|
||||
this.game.height() / 2 - this.offsetY * this.scale,
|
||||
);
|
||||
this.changed = false;
|
||||
}
|
||||
|
||||
worldToScreenCoordinates(cell: Cell): { x: number; y: number } {
|
||||
|
||||
@@ -5,12 +5,12 @@ import cityIcon from "../../../../resources/images/CityIconWhite.svg";
|
||||
import factoryIcon from "../../../../resources/images/FactoryIconWhite.svg";
|
||||
import goldCoinIcon from "../../../../resources/images/GoldCoinIcon.svg";
|
||||
import mirvIcon from "../../../../resources/images/MIRVIcon.svg";
|
||||
import missileSiloIcon from "../../../../resources/images/MissileSiloIconWhite.svg";
|
||||
import hydrogenBombIcon from "../../../../resources/images/MushroomCloudIconWhite.svg";
|
||||
import atomBombIcon from "../../../../resources/images/NukeIconWhite.svg";
|
||||
import portIcon from "../../../../resources/images/PortIcon.svg";
|
||||
import samlauncherIcon from "../../../../resources/images/SamLauncherIconWhite.svg";
|
||||
import shieldIcon from "../../../../resources/images/ShieldIconWhite.svg";
|
||||
import missileSiloIcon from "../../../../resources/non-commercial/svg/MissileSiloIconWhite.svg";
|
||||
import samlauncherIcon from "../../../../resources/non-commercial/svg/SamLauncherIconWhite.svg";
|
||||
import { translateText } from "../../../client/Utils";
|
||||
import { EventBus } from "../../../core/EventBus";
|
||||
import { Cell, Gold, PlayerActions, UnitType } from "../../../core/game/Game";
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
import * as PIXI from "pixi.js";
|
||||
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";
|
||||
import { Theme } from "../../../core/configuration/Config";
|
||||
import { Cell, PlayerID, UnitType } from "../../../core/game/Game";
|
||||
import { GameUpdateType } from "../../../core/game/GameUpdates";
|
||||
import { GameView, UnitView } from "../../../core/game/GameView";
|
||||
import { TransformHandler } from "../TransformHandler";
|
||||
import { Layer } from "./Layer";
|
||||
|
||||
class StructureRenderInfo {
|
||||
public isOnScreen: boolean = false;
|
||||
constructor(
|
||||
public unit: UnitView,
|
||||
public owner: PlayerID,
|
||||
public pixiSprite: PIXI.Sprite,
|
||||
) {}
|
||||
}
|
||||
const ZOOM_THRESHOLD = 2.8; // below this zoom level, structures are not rendered
|
||||
const ICON_SIZE = 24;
|
||||
const OFFSET_ZOOM_Y = 15; // offset for the y position of the icon to avoid hiding the structure beneath
|
||||
|
||||
export class StructureIconsLayer implements Layer {
|
||||
private pixicanvas: HTMLCanvasElement;
|
||||
private stage: PIXI.Container;
|
||||
private shouldRedraw: boolean = true;
|
||||
private textureCache: Map<string, PIXI.Texture> = new Map();
|
||||
private theme: Theme;
|
||||
private renderer: PIXI.Renderer;
|
||||
private renders: StructureRenderInfo[] = [];
|
||||
private seenUnits: Set<UnitView> = new Set();
|
||||
private structures: 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(
|
||||
private game: GameView,
|
||||
private transformHandler: TransformHandler,
|
||||
) {
|
||||
this.theme = game.config().theme();
|
||||
this.structures.forEach((u, unitType) => this.loadIcon(u, unitType));
|
||||
}
|
||||
|
||||
async setupRenderer() {
|
||||
this.renderer = new PIXI.WebGLRenderer();
|
||||
this.pixicanvas = document.createElement("canvas");
|
||||
this.pixicanvas.width = window.innerWidth;
|
||||
this.pixicanvas.height = window.innerHeight;
|
||||
this.stage = new PIXI.Container();
|
||||
this.stage.position.set(0, 0);
|
||||
this.stage.width = this.pixicanvas.width;
|
||||
this.stage.height = this.pixicanvas.height;
|
||||
await this.renderer.init({
|
||||
canvas: this.pixicanvas,
|
||||
resolution: 1,
|
||||
width: this.pixicanvas.width,
|
||||
height: this.pixicanvas.height,
|
||||
clearBeforeRender: true,
|
||||
backgroundAlpha: 0,
|
||||
backgroundColor: 0x00000000,
|
||||
});
|
||||
}
|
||||
|
||||
private loadIcon(
|
||||
unitInfo: {
|
||||
iconPath: string;
|
||||
image: HTMLImageElement | null;
|
||||
},
|
||||
unitType: UnitType,
|
||||
) {
|
||||
const image = new Image();
|
||||
image.src = unitInfo.iconPath;
|
||||
image.onload = () => {
|
||||
unitInfo.image = image;
|
||||
};
|
||||
image.onerror = () => {
|
||||
console.error(
|
||||
`Failed to load icon for ${unitType}: ${unitInfo.iconPath}`,
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
shouldTransform(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
async init() {
|
||||
window.addEventListener("resize", () => this.resizeCanvas());
|
||||
await this.setupRenderer();
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
resizeCanvas() {
|
||||
if (this.renderer.view) {
|
||||
this.pixicanvas.width = window.innerWidth;
|
||||
this.pixicanvas.height = window.innerHeight;
|
||||
this.renderer.resize(innerWidth, innerHeight, 1);
|
||||
this.shouldRedraw = true;
|
||||
}
|
||||
}
|
||||
|
||||
public tick() {
|
||||
this.game
|
||||
.updatesSinceLastTick()
|
||||
?.[GameUpdateType.Unit]?.map((unit) => this.game.unit(unit.id))
|
||||
?.forEach((unitView) => {
|
||||
if (unitView === undefined) return;
|
||||
|
||||
if (unitView.isActive()) {
|
||||
if (this.seenUnits.has(unitView)) {
|
||||
// check if owner has changed
|
||||
const render = this.renders.find(
|
||||
(r) => r.unit.id() === unitView.id(),
|
||||
);
|
||||
if (render) {
|
||||
this.ownerChangeCheck(render, unitView);
|
||||
}
|
||||
} else if (this.structures.has(unitView.type())) {
|
||||
// new unit, create render info
|
||||
this.seenUnits.add(unitView);
|
||||
const render = new StructureRenderInfo(
|
||||
unitView,
|
||||
unitView.owner().id(),
|
||||
this.createPixiSprite(unitView),
|
||||
);
|
||||
this.renders.push(render);
|
||||
this.computeNewLocation(render);
|
||||
this.shouldRedraw = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!unitView.isActive() && this.seenUnits.has(unitView)) {
|
||||
const render = this.renders.find(
|
||||
(r) => r.unit.id() === unitView.id(),
|
||||
);
|
||||
if (render) {
|
||||
this.deleteStructure(render);
|
||||
}
|
||||
this.shouldRedraw = true;
|
||||
return;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
redraw() {
|
||||
this.resizeCanvas();
|
||||
}
|
||||
|
||||
renderLayer(mainContext: CanvasRenderingContext2D) {
|
||||
if (!this.renderer || this.transformHandler.scale > ZOOM_THRESHOLD) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.transformHandler.hasChanged()) {
|
||||
for (const render of this.renders) {
|
||||
this.computeNewLocation(render);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.transformHandler.hasChanged() || this.shouldRedraw) {
|
||||
this.renderer.render(this.stage);
|
||||
this.shouldRedraw = false;
|
||||
}
|
||||
mainContext.drawImage(this.renderer.canvas, 0, 0);
|
||||
}
|
||||
|
||||
private ownerChangeCheck(render: StructureRenderInfo, unit: UnitView) {
|
||||
if (render.owner !== unit.owner().id()) {
|
||||
render.owner = unit.owner().id();
|
||||
render.pixiSprite?.destroy();
|
||||
render.pixiSprite = this.createPixiSprite(unit);
|
||||
this.shouldRedraw = true;
|
||||
}
|
||||
}
|
||||
|
||||
private createTexture(unit: UnitView): PIXI.Texture {
|
||||
const cacheKey = `${unit.owner().id()}-${unit.type()}`;
|
||||
if (this.textureCache.has(cacheKey)) {
|
||||
return this.textureCache.get(cacheKey)!;
|
||||
}
|
||||
const structureCanvas = document.createElement("canvas");
|
||||
structureCanvas.width = ICON_SIZE;
|
||||
structureCanvas.height = ICON_SIZE;
|
||||
const context = structureCanvas.getContext("2d")!;
|
||||
context.fillStyle = this.theme
|
||||
.territoryColor(unit.owner())
|
||||
.lighten(0.1)
|
||||
.toRgbString();
|
||||
const borderColor = this.theme
|
||||
.borderColor(unit.owner())
|
||||
.darken(0.2)
|
||||
.toRgbString();
|
||||
context.strokeStyle = borderColor;
|
||||
context.beginPath();
|
||||
context.arc(
|
||||
ICON_SIZE / 2,
|
||||
ICON_SIZE / 2,
|
||||
ICON_SIZE / 2 - 1,
|
||||
0,
|
||||
Math.PI * 2,
|
||||
);
|
||||
context.fill();
|
||||
context.lineWidth = 1;
|
||||
context.stroke();
|
||||
const structureInfo = this.structures.get(unit.type());
|
||||
if (!structureInfo?.image) {
|
||||
console.warn(`Image not loaded for unit type: ${unit.type()}`);
|
||||
return PIXI.Texture.from(structureCanvas);
|
||||
}
|
||||
context.drawImage(
|
||||
this.getImageColored(structureInfo.image, borderColor),
|
||||
4,
|
||||
4,
|
||||
);
|
||||
const texture = PIXI.Texture.from(structureCanvas);
|
||||
this.textureCache.set(cacheKey, texture);
|
||||
return texture;
|
||||
}
|
||||
|
||||
private createPixiSprite(unit: UnitView): PIXI.Sprite {
|
||||
const sprite = new PIXI.Sprite(this.createTexture(unit));
|
||||
sprite.anchor.set(0.5, 0.5);
|
||||
const tile = unit.tile();
|
||||
const worldX = this.game.x(tile);
|
||||
const worldY = this.game.y(tile);
|
||||
const screenPos = this.transformHandler.worldToScreenCoordinates(
|
||||
new Cell(worldX, worldY),
|
||||
);
|
||||
sprite.x = screenPos.x;
|
||||
sprite.y = screenPos.y - this.transformHandler.scale * OFFSET_ZOOM_Y;
|
||||
sprite.scale.set(Math.min(1, this.transformHandler.scale));
|
||||
this.stage.addChild(sprite);
|
||||
return sprite;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
private computeNewLocation(render: StructureRenderInfo) {
|
||||
const tile = render.unit.tile();
|
||||
const worldX = this.game.x(tile);
|
||||
const worldY = this.game.y(tile);
|
||||
const screenPos = this.transformHandler.worldToScreenCoordinates(
|
||||
new Cell(worldX, worldY),
|
||||
);
|
||||
screenPos.x = Math.round(screenPos.x);
|
||||
screenPos.y = Math.round(
|
||||
screenPos.y - this.transformHandler.scale * OFFSET_ZOOM_Y,
|
||||
);
|
||||
|
||||
// Check if the sprite is on screen (with margin for partial visibility)
|
||||
const margin = ICON_SIZE;
|
||||
const onScreen =
|
||||
screenPos.x + margin > 0 &&
|
||||
screenPos.x - margin < this.pixicanvas.width &&
|
||||
screenPos.y + margin > 0 &&
|
||||
screenPos.y - margin < this.pixicanvas.height;
|
||||
|
||||
if (onScreen) {
|
||||
render.pixiSprite.x = screenPos.x;
|
||||
render.pixiSprite.y = screenPos.y;
|
||||
render.pixiSprite.scale.set(Math.min(1, this.transformHandler.scale));
|
||||
}
|
||||
if (render.isOnScreen !== onScreen) {
|
||||
// prevent unnecessary updates
|
||||
render.isOnScreen = onScreen;
|
||||
render.pixiSprite.visible = onScreen;
|
||||
}
|
||||
}
|
||||
|
||||
private deleteStructure(render: StructureRenderInfo) {
|
||||
render.pixiSprite?.destroy();
|
||||
this.renders = this.renders.filter((r) => r.unit !== render.unit);
|
||||
this.seenUnits.delete(render.unit);
|
||||
}
|
||||
}
|
||||
@@ -6,26 +6,18 @@ import { TransformHandler } from "../TransformHandler";
|
||||
import { Layer } from "./Layer";
|
||||
import { UnitInfoModal } from "./UnitInfoModal";
|
||||
|
||||
import cityIcon from "../../../../resources/images/buildings/cityAlt1.png";
|
||||
import factoryIcon from "../../../../resources/images/buildings/factoryAlt1.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 cityIcon from "../../../../resources/non-commercial/images/buildings/cityAlt1.png";
|
||||
import factoryIcon from "../../../../resources/non-commercial/images/buildings/factoryAlt1.png";
|
||||
import shieldIcon from "../../../../resources/non-commercial/images/buildings/fortAlt3.png";
|
||||
import anchorIcon from "../../../../resources/non-commercial/images/buildings/port1.png";
|
||||
import missileSiloIcon from "../../../../resources/non-commercial/images/buildings/silo1.png";
|
||||
import SAMMissileIcon from "../../../../resources/non-commercial/images/buildings/silo4.png";
|
||||
import { Cell, UnitType } from "../../../core/game/Game";
|
||||
import {
|
||||
euclDistFN,
|
||||
hexDistFN,
|
||||
manhattanDistFN,
|
||||
rectDistFN,
|
||||
} from "../../../core/game/GameMap";
|
||||
import { euclDistFN, isometricDistFN } 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 });
|
||||
const selectedUnitColor = colord({ r: 0, g: 255, b: 255 });
|
||||
|
||||
// Base radius values and scaling factor for unit borders and territories
|
||||
@@ -33,20 +25,10 @@ const BASE_BORDER_RADIUS = 16.5;
|
||||
const BASE_TERRITORY_RADIUS = 13.5;
|
||||
const RADIUS_SCALE_FACTOR = 0.5;
|
||||
|
||||
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 {
|
||||
@@ -65,37 +47,31 @@ export class StructureLayer implements Layer {
|
||||
icon: anchorIcon,
|
||||
borderRadius: BASE_BORDER_RADIUS * RADIUS_SCALE_FACTOR,
|
||||
territoryRadius: BASE_TERRITORY_RADIUS * RADIUS_SCALE_FACTOR,
|
||||
borderType: UnitBorderType.Round,
|
||||
},
|
||||
[UnitType.City]: {
|
||||
icon: cityIcon,
|
||||
borderRadius: BASE_BORDER_RADIUS * RADIUS_SCALE_FACTOR,
|
||||
territoryRadius: BASE_TERRITORY_RADIUS * RADIUS_SCALE_FACTOR,
|
||||
borderType: UnitBorderType.Round,
|
||||
},
|
||||
[UnitType.Factory]: {
|
||||
icon: factoryIcon,
|
||||
borderRadius: 8.525,
|
||||
territoryRadius: 6.525,
|
||||
borderType: UnitBorderType.Round,
|
||||
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,
|
||||
borderType: UnitBorderType.Square,
|
||||
},
|
||||
[UnitType.DefensePost]: {
|
||||
icon: shieldIcon,
|
||||
borderRadius: BASE_BORDER_RADIUS * RADIUS_SCALE_FACTOR,
|
||||
territoryRadius: BASE_TERRITORY_RADIUS * RADIUS_SCALE_FACTOR,
|
||||
borderType: UnitBorderType.Hexagon,
|
||||
},
|
||||
[UnitType.SAMLauncher]: {
|
||||
icon: SAMMissileIcon,
|
||||
borderRadius: BASE_BORDER_RADIUS * RADIUS_SCALE_FACTOR,
|
||||
territoryRadius: BASE_TERRITORY_RADIUS * RADIUS_SCALE_FACTOR,
|
||||
borderType: UnitBorderType.Square,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -117,18 +93,6 @@ export class StructureLayer implements Layer {
|
||||
if (tempContext === null) throw new Error("2d context not supported");
|
||||
this.tempContext = tempContext;
|
||||
this.loadIconData();
|
||||
this.loadIcon("reloadingSam", {
|
||||
icon: SAMMissileReloadingIcon,
|
||||
borderRadius: BASE_BORDER_RADIUS * RADIUS_SCALE_FACTOR,
|
||||
territoryRadius: BASE_TERRITORY_RADIUS * RADIUS_SCALE_FACTOR,
|
||||
borderType: UnitBorderType.Square,
|
||||
});
|
||||
this.loadIcon("reloadingSilo", {
|
||||
icon: MissileSiloReloadingIcon,
|
||||
borderRadius: BASE_BORDER_RADIUS * RADIUS_SCALE_FACTOR,
|
||||
territoryRadius: BASE_TERRITORY_RADIUS * RADIUS_SCALE_FACTOR,
|
||||
borderType: UnitBorderType.Square,
|
||||
});
|
||||
}
|
||||
|
||||
private loadIcon(unitType: string, config: UnitRenderConfig) {
|
||||
@@ -204,12 +168,11 @@ export class StructureLayer implements Layer {
|
||||
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),
|
||||
isometricDistFN(unit.tile(), config.borderRadius, true),
|
||||
)) {
|
||||
this.paintCell(
|
||||
new Cell(this.game.x(tile), this.game.y(tile)),
|
||||
@@ -220,7 +183,7 @@ export class StructureLayer implements Layer {
|
||||
|
||||
for (const tile of this.game.bfs(
|
||||
unit.tile(),
|
||||
distanceFN(unit.tile(), config.territoryRadius, true),
|
||||
isometricDistFN(unit.tile(), config.territoryRadius, true),
|
||||
)) {
|
||||
this.paintCell(
|
||||
new Cell(this.game.x(tile), this.game.y(tile)),
|
||||
@@ -232,19 +195,6 @@ export class StructureLayer implements Layer {
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -255,13 +205,7 @@ export class StructureLayer implements Layer {
|
||||
let borderColor = this.theme.borderColor(unit.owner());
|
||||
|
||||
// Handle cooldown states and special icons
|
||||
if (unitType === UnitType.SAMLauncher && unit.isInCooldown()) {
|
||||
icon = this.unitIcons.get("reloadingSam");
|
||||
borderColor = reloadingColor;
|
||||
} else if (unitType === UnitType.MissileSilo && unit.isInCooldown()) {
|
||||
icon = this.unitIcons.get("reloadingSilo");
|
||||
borderColor = reloadingColor;
|
||||
} else if (unit.type() === UnitType.Construction) {
|
||||
if (unit.type() === UnitType.Construction) {
|
||||
icon = this.unitIcons.get(iconType);
|
||||
borderColor = underConstructionColor;
|
||||
} else {
|
||||
@@ -270,11 +214,10 @@ export class StructureLayer implements Layer {
|
||||
|
||||
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),
|
||||
euclDistFN(unit.tile(), config.borderRadius + 1, true),
|
||||
)) {
|
||||
this.clearCell(new Cell(this.game.x(tile), this.game.y(tile)));
|
||||
}
|
||||
@@ -284,8 +227,7 @@ export class StructureLayer implements Layer {
|
||||
if (this.selectedStructureUnit === unit) {
|
||||
borderColor = selectedUnitColor;
|
||||
}
|
||||
|
||||
this.drawBorder(unit, borderColor, config, drawFunction);
|
||||
this.drawBorder(unit, borderColor, config);
|
||||
|
||||
// Render icon at 1/2 scale for better quality
|
||||
const scaledWidth = icon.width >> 1;
|
||||
@@ -293,7 +235,7 @@ export class StructureLayer implements Layer {
|
||||
const startX = this.game.x(unit.tile()) - (scaledWidth >> 1);
|
||||
const startY = this.game.y(unit.tile()) - (scaledHeight >> 1);
|
||||
|
||||
this.renderIcon(icon, startX, startY, scaledWidth, scaledHeight, unit);
|
||||
this.renderIcon(icon, startX, startY - 4, scaledWidth, scaledHeight, unit);
|
||||
}
|
||||
|
||||
private renderIcon(
|
||||
@@ -320,11 +262,6 @@ export class StructureLayer implements Layer {
|
||||
// Draw the image at final size with high quality scaling
|
||||
this.tempContext.drawImage(image, 0, 0, width * 2, height * 2);
|
||||
|
||||
// Apply color tinting using multiply blend mode
|
||||
this.tempContext.globalCompositeOperation = "multiply";
|
||||
this.tempContext.fillStyle = color.toRgbString();
|
||||
this.tempContext.fillRect(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);
|
||||
|
||||
@@ -19,7 +19,7 @@ const COLOR_PROGRESSION = [
|
||||
"rgb(44, 239, 18)",
|
||||
];
|
||||
const HEALTHBAR_WIDTH = 11; // Width of the health bar
|
||||
const LOADINGBAR_WIDTH = 18; // Width of the loading bar
|
||||
const LOADINGBAR_WIDTH = 14; // Width of the loading bar
|
||||
const PROGRESSBAR_HEIGHT = 3; // Height of a bar
|
||||
|
||||
/**
|
||||
@@ -378,8 +378,8 @@ export class UILayer implements Layer {
|
||||
const progressBar = new ProgressBar(
|
||||
COLOR_PROGRESSION,
|
||||
this.context,
|
||||
this.game.x(unit.tile()) - 8,
|
||||
this.game.y(unit.tile()) - 10,
|
||||
this.game.x(unit.tile()) - 6,
|
||||
this.game.y(unit.tile()) + 6,
|
||||
LOADINGBAR_WIDTH,
|
||||
PROGRESSBAR_HEIGHT,
|
||||
0,
|
||||
|
||||
@@ -376,13 +376,13 @@ label.option-card:hover {
|
||||
}
|
||||
|
||||
#helpModal .missile-silo-icon {
|
||||
mask: url("../../resources/images/MissileSiloIconWhite.svg") no-repeat
|
||||
center / cover;
|
||||
mask: url("../../resources/non-commercial/svg/MissileSiloIconWhite.svg")
|
||||
no-repeat center / cover;
|
||||
}
|
||||
|
||||
#helpModal .sam-launcher-icon {
|
||||
mask: url("../../resources/images/SamLauncherIconWhite.svg") no-repeat
|
||||
center / cover;
|
||||
mask: url("../../resources/non-commercial/svg/SamLauncherIconWhite.svg")
|
||||
no-repeat center / cover;
|
||||
}
|
||||
|
||||
#helpModal .atom-bomb-icon {
|
||||
|
||||
@@ -401,6 +401,39 @@ export function rectDistFN(
|
||||
}
|
||||
}
|
||||
|
||||
function isInIsometricTile(
|
||||
center: { x: number; y: number },
|
||||
tile: { x: number; y: number },
|
||||
yOffset: number,
|
||||
distance: number,
|
||||
): boolean {
|
||||
const dx = Math.abs(tile.x - center.x);
|
||||
const dy = Math.abs(tile.y - (center.y + yOffset));
|
||||
return dx + dy * 2 <= distance + 1;
|
||||
}
|
||||
|
||||
export function isometricDistFN(
|
||||
root: TileRef,
|
||||
dist: number,
|
||||
center: boolean = false,
|
||||
): (gm: GameMap, tile: TileRef) => boolean {
|
||||
if (!center) {
|
||||
return (gm: GameMap, n: TileRef) => gm.manhattanDist(root, n) <= dist;
|
||||
} else {
|
||||
return (gm: GameMap, n: TileRef) => {
|
||||
const rootX = gm.x(root) - 0.5;
|
||||
const rootY = gm.y(root) - 0.5;
|
||||
|
||||
return isInIsometricTile(
|
||||
{ x: rootX, y: rootY },
|
||||
{ x: gm.x(n), y: gm.y(n) },
|
||||
0,
|
||||
dist,
|
||||
);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function hexDistFN(
|
||||
root: TileRef,
|
||||
dist: number,
|
||||
|
||||
Reference in New Issue
Block a user