diff --git a/package-lock.json b/package-lock.json
index 7a86eb0af..319f78472 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -48,6 +48,7 @@
"nanoid": "^3.3.6",
"obscenity": "^0.4.3",
"pg": "^8.13.3",
+ "pixi.js": "^8.10.1",
"prom-client": "^15.1.3",
"protobufjs": "^7.3.2",
"pureimage": "^0.4.13",
@@ -7944,6 +7945,12 @@
"node": "^18.19.0 || >=20.6.0"
}
},
+ "node_modules/@pixi/colord": {
+ "version": "2.9.6",
+ "resolved": "https://registry.npmjs.org/@pixi/colord/-/colord-2.9.6.tgz",
+ "integrity": "sha512-nezytU2pw587fQstUu1AsJZDVEynjskwOL+kibwcdxsMBFqPsFFNA7xl0ii/gXuDi6M0xj3mfRJj8pBSc2jCfA==",
+ "license": "MIT"
+ },
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@@ -8995,6 +9002,12 @@
"@types/node": "*"
}
},
+ "node_modules/@types/css-font-loading-module": {
+ "version": "0.0.12",
+ "resolved": "https://registry.npmjs.org/@types/css-font-loading-module/-/css-font-loading-module-0.0.12.tgz",
+ "integrity": "sha512-x2tZZYkSxXqWvTDgveSynfjq/T2HyiZHXb00j/+gy19yp70PHCizM48XFdjBCWH7eHBD0R5i/pw9yMBP/BH5uA==",
+ "license": "MIT"
+ },
"node_modules/@types/d3": {
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
@@ -9279,6 +9292,12 @@
"@types/d3-selection": "*"
}
},
+ "node_modules/@types/earcut": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/@types/earcut/-/earcut-3.0.0.tgz",
+ "integrity": "sha512-k/9fOUGO39yd2sCjrbAJvGDEQvRwRnQIZlBz43roGwUZo5SHAmyVvSFyaVVZkicRVCaDXPKlbxrUcBuJoSWunQ==",
+ "license": "MIT"
+ },
"node_modules/@types/eslint": {
"version": "9.6.1",
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
@@ -10388,6 +10407,12 @@
"@xtuc/long": "4.2.2"
}
},
+ "node_modules/@webgpu/types": {
+ "version": "0.1.61",
+ "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.61.tgz",
+ "integrity": "sha512-w2HbBvH+qO19SB5pJOJFKs533CdZqxl3fcGonqL321VHkW7W/iBo6H8bjDy6pr/+pbMwIu5dnuaAxH7NxBqUrQ==",
+ "license": "BSD-3-Clause"
+ },
"node_modules/@webpack-cli/configtest": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@webpack-cli/configtest/-/configtest-3.0.1.tgz",
@@ -10432,6 +10457,15 @@
}
}
},
+ "node_modules/@xmldom/xmldom": {
+ "version": "0.8.10",
+ "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz",
+ "integrity": "sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
"node_modules/@xtuc/ieee754": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz",
@@ -13026,6 +13060,12 @@
"node": ">= 6"
}
},
+ "node_modules/earcut": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.1.tgz",
+ "integrity": "sha512-0l1/0gOjESMeQyYaK5IDiPNvFeu93Z/cO0TjZh9eZ1vyCtZnA7KMZ8rQggpsJHIbGSdrqYq9OhuveadOVHCshw==",
+ "license": "ISC"
+ },
"node_modules/eastasianwidth": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
@@ -13697,7 +13737,6 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
- "dev": true,
"license": "MIT"
},
"node_modules/events": {
@@ -14572,6 +14611,15 @@
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
+ "node_modules/gifuct-js": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/gifuct-js/-/gifuct-js-2.1.2.tgz",
+ "integrity": "sha512-rI2asw77u0mGgwhV3qA+OEgYqaDn5UNqgs+Bx0FGwSpuqfYn+Ir6RQY5ENNQ8SbIiG/m5gVa7CD5RriO4f4Lsg==",
+ "license": "MIT",
+ "dependencies": {
+ "js-binary-schema-parser": "^2.0.3"
+ }
+ },
"node_modules/gifwrap": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/gifwrap/-/gifwrap-0.10.1.tgz",
@@ -15647,6 +15695,12 @@
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"license": "ISC"
},
+ "node_modules/ismobilejs": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ismobilejs/-/ismobilejs-1.1.1.tgz",
+ "integrity": "sha512-VaFW53yt8QO61k2WJui0dHf4SlL8lxBofUuUmwBo0ljPk0Drz2TiuDW4jo3wDcv41qy/SxrJ+VAzJ/qYqsmzRw==",
+ "license": "MIT"
+ },
"node_modules/isobject": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz",
@@ -17555,6 +17609,12 @@
"integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==",
"license": "BSD-3-Clause"
},
+ "node_modules/js-binary-schema-parser": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/js-binary-schema-parser/-/js-binary-schema-parser-2.0.3.tgz",
+ "integrity": "sha512-xezGJmOb4lk/M1ZZLTR/jaBHQ4gG/lqQnJqdIv4721DMggsa1bDVlHXNeHYogaIEHD9vCRv0fcL4hMA+Coarkg==",
+ "license": "MIT"
+ },
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -19228,6 +19288,12 @@
"integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==",
"license": "MIT"
},
+ "node_modules/parse-svg-path": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.1.2.tgz",
+ "integrity": "sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==",
+ "license": "MIT"
+ },
"node_modules/parse5": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
@@ -19533,6 +19599,28 @@
"node": ">=4.0.0"
}
},
+ "node_modules/pixi.js": {
+ "version": "8.10.1",
+ "resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-8.10.1.tgz",
+ "integrity": "sha512-wjKJXawhTUxuyKIuwE3jK05eBh5I4GKy+YrRVniURFRkK7pYEvRvnV41dEqz6owSXav/YMXdG5783YDJeamiow==",
+ "license": "MIT",
+ "dependencies": {
+ "@pixi/colord": "^2.9.6",
+ "@types/css-font-loading-module": "^0.0.12",
+ "@types/earcut": "^3.0.0",
+ "@webgpu/types": "^0.1.40",
+ "@xmldom/xmldom": "^0.8.10",
+ "earcut": "^3.0.1",
+ "eventemitter3": "^5.0.1",
+ "gifuct-js": "^2.1.2",
+ "ismobilejs": "^1.1.1",
+ "parse-svg-path": "^0.1.2"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/pixijs"
+ }
+ },
"node_modules/pkg-dir": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz",
diff --git a/package.json b/package.json
index 3f40007b1..049023cb2 100644
--- a/package.json
+++ b/package.json
@@ -122,6 +122,7 @@
"nanoid": "^3.3.6",
"obscenity": "^0.4.3",
"pg": "^8.13.3",
+ "pixi.js": "^8.10.1",
"prom-client": "^15.1.3",
"protobufjs": "^7.3.2",
"pureimage": "^0.4.13",
diff --git a/resources/images/AnchorIcon.png b/resources/images/AnchorIcon.png
index 78bdfe73e..cd0582fc7 100644
Binary files a/resources/images/AnchorIcon.png and b/resources/images/AnchorIcon.png differ
diff --git a/resources/images/CityIcon.png b/resources/images/CityIcon.png
index a690bfa9e..c46f819c0 100644
Binary files a/resources/images/CityIcon.png and b/resources/images/CityIcon.png differ
diff --git a/resources/images/CityIconWhite.svg b/resources/images/CityIconWhite.svg
index 0c55a2d06..4591cfb7c 100644
--- a/resources/images/CityIconWhite.svg
+++ b/resources/images/CityIconWhite.svg
@@ -3,8 +3,6 @@
diff --git a/resources/images/FactoryUnit.png b/resources/images/FactoryUnit.png
new file mode 100644
index 000000000..ce25ced1d
Binary files /dev/null and b/resources/images/FactoryUnit.png differ
diff --git a/resources/images/MissileSiloIconWhite.svg b/resources/images/MissileSiloIconWhite.svg
deleted file mode 100644
index 071fbf245..000000000
--- a/resources/images/MissileSiloIconWhite.svg
+++ /dev/null
@@ -1,86 +0,0 @@
-
-
-
-
-
-
-
-
-
-
diff --git a/resources/images/MissileSiloUnit.png b/resources/images/MissileSiloUnit.png
index bc94a1894..81b56b959 100644
Binary files a/resources/images/MissileSiloUnit.png and b/resources/images/MissileSiloUnit.png differ
diff --git a/resources/images/SamLauncherIconWhite.svg b/resources/images/SamLauncherIconWhite.svg
deleted file mode 100644
index 242c55ccb..000000000
--- a/resources/images/SamLauncherIconWhite.svg
+++ /dev/null
@@ -1,32 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/resources/images/SamLauncherUnit.png b/resources/images/SamLauncherUnit.png
new file mode 100644
index 000000000..ab29b9617
Binary files /dev/null and b/resources/images/SamLauncherUnit.png differ
diff --git a/resources/images/ShieldIcon.png b/resources/images/ShieldIcon.png
index e1db2168e..5720eb7ec 100644
Binary files a/resources/images/ShieldIcon.png and b/resources/images/ShieldIcon.png differ
diff --git a/resources/images/ShieldIconWhite.svg b/resources/images/ShieldIconWhite.svg
index 495260851..28373f308 100644
--- a/resources/images/ShieldIconWhite.svg
+++ b/resources/images/ShieldIconWhite.svg
@@ -1,7 +1,5 @@
+
+
+
+
+
diff --git a/resources/non-commercial/images/svg/SamLauncherIconWhite.svg b/resources/non-commercial/images/svg/SamLauncherIconWhite.svg
new file mode 100644
index 000000000..7d127b47d
--- /dev/null
+++ b/resources/non-commercial/images/svg/SamLauncherIconWhite.svg
@@ -0,0 +1,221 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/resources/non-commercial/svg/MissileSiloIconWhite.svg b/resources/non-commercial/svg/MissileSiloIconWhite.svg
new file mode 100644
index 000000000..d55a9d6d8
--- /dev/null
+++ b/resources/non-commercial/svg/MissileSiloIconWhite.svg
@@ -0,0 +1,172 @@
+
+
+
+
+
+
diff --git a/resources/non-commercial/svg/SamLauncherIconWhite.svg b/resources/non-commercial/svg/SamLauncherIconWhite.svg
new file mode 100644
index 000000000..7d127b47d
--- /dev/null
+++ b/resources/non-commercial/svg/SamLauncherIconWhite.svg
@@ -0,0 +1,221 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts
index a72aaf6e4..f61de2d5a 100644
--- a/src/client/graphics/GameRenderer.ts
+++ b/src/client/graphics/GameRenderer.ts
@@ -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());
diff --git a/src/client/graphics/TransformHandler.ts b/src/client/graphics/TransformHandler.ts
index 40de3fa3e..47b569d5a 100644
--- a/src/client/graphics/TransformHandler.ts
+++ b/src/client/graphics/TransformHandler.ts
@@ -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 } {
diff --git a/src/client/graphics/layers/BuildMenu.ts b/src/client/graphics/layers/BuildMenu.ts
index 580fdcae6..f1cfe17b8 100644
--- a/src/client/graphics/layers/BuildMenu.ts
+++ b/src/client/graphics/layers/BuildMenu.ts
@@ -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";
diff --git a/src/client/graphics/layers/StructureIconsLayer.ts b/src/client/graphics/layers/StructureIconsLayer.ts
new file mode 100644
index 000000000..ffdf680a5
--- /dev/null
+++ b/src/client/graphics/layers/StructureIconsLayer.ts
@@ -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 = new Map();
+ private theme: Theme;
+ private renderer: PIXI.Renderer;
+ private renders: StructureRenderInfo[] = [];
+ private seenUnits: Set = 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);
+ }
+}
diff --git a/src/client/graphics/layers/StructureLayer.ts b/src/client/graphics/layers/StructureLayer.ts
index c80b44f19..4c68f222b 100644
--- a/src/client/graphics/layers/StructureLayer.ts
+++ b/src/client/graphics/layers/StructureLayer.ts
@@ -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);
diff --git a/src/client/graphics/layers/UILayer.ts b/src/client/graphics/layers/UILayer.ts
index 804eed21f..a743c5ae7 100644
--- a/src/client/graphics/layers/UILayer.ts
+++ b/src/client/graphics/layers/UILayer.ts
@@ -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,
diff --git a/src/client/styles.css b/src/client/styles.css
index 05240ef5b..dba13d5d7 100644
--- a/src/client/styles.css
+++ b/src/client/styles.css
@@ -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 {
diff --git a/src/core/game/GameMap.ts b/src/core/game/GameMap.ts
index d05d8d499..7a3bd8e6d 100644
--- a/src/core/game/GameMap.ts
+++ b/src/core/game/GameMap.ts
@@ -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,