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 @@ - - + + + + - \ No newline at end of file + 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,