diff --git a/package-lock.json b/package-lock.json
index 57c67fd60..d053d614d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -49,6 +49,7 @@
"nanoid": "^3.3.6",
"obscenity": "^0.4.3",
"pg": "^8.13.3",
+ "pixi-filters": "^6.1.3",
"pixi.js": "^8.10.1",
"prom-client": "^15.1.3",
"protobufjs": "^7.3.2",
@@ -9371,6 +9372,12 @@
"integrity": "sha512-40um9QqwHjRS92qnOaDpL7RmDK15NuZYo9HihiJRbYkMQZlWnuH8AdvbMy8/o6lgLmKbDUKa+OALCltHdbOTpQ==",
"license": "MIT"
},
+ "node_modules/@types/gradient-parser": {
+ "version": "0.1.5",
+ "resolved": "https://registry.npmjs.org/@types/gradient-parser/-/gradient-parser-0.1.5.tgz",
+ "integrity": "sha512-r7K3NkJz3A95WkVVmjs0NcchhHstC2C/VIYNX4JC6tieviUNo774FFeOHjThr3Vw/WCeMP9kAT77MKbIRlO/4w==",
+ "license": "MIT"
+ },
"node_modules/@types/hammerjs": {
"version": "2.0.46",
"resolved": "https://registry.npmjs.org/@types/hammerjs/-/hammerjs-2.0.46.tgz",
@@ -19609,6 +19616,18 @@
"node": ">=4.0.0"
}
},
+ "node_modules/pixi-filters": {
+ "version": "6.1.3",
+ "resolved": "https://registry.npmjs.org/pixi-filters/-/pixi-filters-6.1.3.tgz",
+ "integrity": "sha512-bmdI2Ytz+z/NcADkjew2phKq300aQ9p9nVx9OfkMNuoYEl4gW99ZDNQZfsF834V/jj3CKTsIV4jxA+BI45UYOQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/gradient-parser": "^0.1.2"
+ },
+ "peerDependencies": {
+ "pixi.js": ">=8.0.0-0"
+ }
+ },
"node_modules/pixi.js": {
"version": "8.10.1",
"resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-8.10.1.tgz",
diff --git a/package.json b/package.json
index 03ef20e3a..2e54e7aec 100644
--- a/package.json
+++ b/package.json
@@ -123,6 +123,7 @@
"nanoid": "^3.3.6",
"obscenity": "^0.4.3",
"pg": "^8.13.3",
+ "pixi-filters": "^6.1.3",
"pixi.js": "^8.10.1",
"prom-client": "^15.1.3",
"protobufjs": "^7.3.2",
diff --git a/resources/images/ShieldIcon.png b/resources/images/ShieldIcon.png
index 5720eb7ec..ac58abdec 100644
Binary files a/resources/images/ShieldIcon.png and b/resources/images/ShieldIcon.png differ
diff --git a/src/client/InputHandler.ts b/src/client/InputHandler.ts
index 367676364..4646734b0 100644
--- a/src/client/InputHandler.ts
+++ b/src/client/InputHandler.ts
@@ -1,4 +1,5 @@
import { EventBus, GameEvent } from "../core/EventBus";
+import { UnitType } from "../core/game/Game";
import { UnitView } from "../core/game/GameView";
import { UserSettings } from "../core/game/UserSettings";
import { ReplaySpeedMultiplier } from "./utilities/ReplaySpeedMultiplier";
@@ -64,6 +65,10 @@ export class CloseViewEvent implements GameEvent {}
export class RefreshGraphicsEvent implements GameEvent {}
+export class ToggleStructureEvent implements GameEvent {
+ constructor(public readonly structureType: UnitType | null) {}
+}
+
export class ShowBuildMenuEvent implements GameEvent {
constructor(
public readonly x: number,
diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts
index aa8ddbe4a..75a3e8e69 100644
--- a/src/client/graphics/GameRenderer.ts
+++ b/src/client/graphics/GameRenderer.ts
@@ -217,7 +217,7 @@ export function createRenderer(
new TerritoryLayer(game, eventBus, transformHandler, userSettings),
new RailroadLayer(game),
structureLayer,
- new StructureIconsLayer(game, transformHandler),
+ new StructureIconsLayer(game, eventBus, transformHandler),
new UnitLayer(game, eventBus, transformHandler),
new FxLayer(game),
new UILayer(game, eventBus, transformHandler),
diff --git a/src/client/graphics/layers/GameTopBar.ts b/src/client/graphics/layers/GameTopBar.ts
index 0b2109ac7..0e98c31bc 100644
--- a/src/client/graphics/layers/GameTopBar.ts
+++ b/src/client/graphics/layers/GameTopBar.ts
@@ -24,7 +24,11 @@ import { UnitType } from "../../../core/game/Game";
import { GameUpdateType } from "../../../core/game/GameUpdates";
import { GameView } from "../../../core/game/GameView";
import { UserSettings } from "../../../core/game/UserSettings";
-import { AlternateViewEvent, RefreshGraphicsEvent } from "../../InputHandler";
+import {
+ AlternateViewEvent,
+ RefreshGraphicsEvent,
+ ToggleStructureEvent,
+} from "../../InputHandler";
import { renderNumber, renderTroops } from "../../Utils";
import { Layer } from "./Layer";
@@ -33,6 +37,7 @@ export class GameTopBar extends LitElement implements Layer {
public game: GameView;
public eventBus: EventBus;
private _userSettings: UserSettings = new UserSettings();
+ private _selectedStructure: UnitType | null = null;
private _population = 0;
private _troops = 0;
private _cities = 0;
@@ -140,6 +145,12 @@ export class GameTopBar extends LitElement implements Layer {
this.eventBus.emit(new RefreshGraphicsEvent());
}
+ private onToggleStructureClick(structureType: UnitType) {
+ this._selectedStructure =
+ this._selectedStructure === structureType ? null : structureType;
+ this.eventBus.emit(new ToggleStructureEvent(this._selectedStructure));
+ }
+
private onToggleRandomNameModeButtonClick() {
this._userSettings.toggleRandomName();
}
@@ -280,9 +291,19 @@ export class GameTopBar extends LitElement implements Layer {
-
+
+ this.onToggleStructureClick(UnitType.City)}"
+ @mouseleave="${() =>
+ this.onToggleStructureClick(UnitType.City)}"
+ >

${renderNumber(this._cities)}
-
+
+ this.onToggleStructureClick(UnitType.Factory)}"
+ @mouseleave="${() =>
+ this.onToggleStructureClick(UnitType.Factory)}"
+ >

${renderNumber(this._factories)}
-
+
+ this.onToggleStructureClick(UnitType.Port)}"
+ @mouseleave="${() =>
+ this.onToggleStructureClick(UnitType.Port)}"
+ >

${renderNumber(this._port)}
-
+
+ this.onToggleStructureClick(UnitType.DefensePost)}"
+ @mouseleave="${() =>
+ this.onToggleStructureClick(UnitType.DefensePost)}"
+ >

${renderNumber(this._defensePost)}
-
+
+ this.onToggleStructureClick(UnitType.MissileSilo)}"
+ @mouseleave="${() =>
+ this.onToggleStructureClick(UnitType.MissileSilo)}"
+ >

${renderNumber(this._missileSilo)}
-
+
+ this.onToggleStructureClick(UnitType.SAMLauncher)}"
+ @mouseleave="${() =>
+ this.onToggleStructureClick(UnitType.SAMLauncher)}"
+ >

> = {
+ [UnitType.City]: "circle",
+ [UnitType.Port]: "circle",
+ [UnitType.Factory]: "circle",
+ [UnitType.DefensePost]: "octagon",
+ [UnitType.SAMLauncher]: "square",
+ [UnitType.MissileSilo]: "triangle",
+};
+const ZOOM_THRESHOLD = 3.5;
const ICON_SIZE = 24;
const OFFSET_ZOOM_Y = 5; // offset for the y position of the icon to avoid hiding the structure beneath
@@ -40,18 +54,28 @@ export class StructureIconsLayer implements Layer {
private seenUnits: Set
= new Set();
private structures: Map<
UnitType,
- { iconPath: string; image: HTMLImageElement | null }
+ { visible: boolean; 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 }],
+ [UnitType.City, { visible: true, iconPath: cityIcon, image: null }],
+ [UnitType.Factory, { visible: true, iconPath: factoryIcon, image: null }],
+ [
+ UnitType.DefensePost,
+ { visible: true, iconPath: shieldIcon, image: null },
+ ],
+ [UnitType.Port, { visible: true, iconPath: anchorIcon, image: null }],
+ [
+ UnitType.MissileSilo,
+ { visible: true, iconPath: missileSiloIcon, image: null },
+ ],
+ [
+ UnitType.SAMLauncher,
+ { visible: true, iconPath: SAMMissileIcon, image: null },
+ ],
]);
constructor(
private game: GameView,
+ private eventBus: EventBus,
private transformHandler: TransformHandler,
) {
this.theme = game.config().theme();
@@ -114,6 +138,9 @@ export class StructureIconsLayer implements Layer {
}
async init() {
+ this.eventBus.on(ToggleStructureEvent, (e) =>
+ this.toggleStructure(e.structureType),
+ );
window.addEventListener("resize", () => this.resizeCanvas());
await this.setupRenderer();
this.redraw();
@@ -143,6 +170,17 @@ export class StructureIconsLayer implements Layer {
});
}
+ private toggleStructure(toggleStructureType: UnitType | null): void {
+ for (const [structureType, infos] of this.structures) {
+ infos.visible =
+ structureType === toggleStructureType || toggleStructureType === null;
+ }
+ for (const render of this.renders) {
+ this.modifyVisibility(render);
+ }
+ this.shouldRedraw = true;
+ }
+
private findRenderByUnit(
unitView: UnitView,
): StructureRenderInfo | undefined {
@@ -173,6 +211,32 @@ export class StructureIconsLayer implements Layer {
}
}
+ private modifyVisibility(render: StructureRenderInfo) {
+ const structureType =
+ render.unit.type() === UnitType.Construction
+ ? render.unit.constructionType()!
+ : render.unit.type();
+ const structureInfos = this.structures.get(structureType);
+
+ let focusStructure = false;
+ for (const infos of this.structures.values()) {
+ if (infos.visible === false) {
+ focusStructure = true;
+ break;
+ }
+ }
+ if (structureInfos) {
+ render.iconContainer.alpha = structureInfos.visible ? 1 : 0.3;
+ if (structureInfos.visible && focusStructure) {
+ render.iconContainer.filters = [
+ new OutlineFilter({ thickness: 2, color: "rgb(255, 255, 255)" }),
+ ];
+ } else {
+ render.iconContainer.filters = [];
+ }
+ }
+ }
+
private checkForConstructionState(
render: StructureRenderInfo,
unit: UnitView,
@@ -184,6 +248,7 @@ export class StructureIconsLayer implements Layer {
render.underConstruction = false;
render.iconContainer?.destroy();
render.iconContainer = this.createIconSprite(unit);
+ this.modifyVisibility(render);
this.shouldRedraw = true;
}
}
@@ -193,6 +258,7 @@ export class StructureIconsLayer implements Layer {
render.owner = unit.owner().id();
render.iconContainer?.destroy();
render.iconContainer = this.createIconSprite(unit);
+ this.modifyVisibility(render);
this.shouldRedraw = true;
}
}
@@ -204,6 +270,7 @@ export class StructureIconsLayer implements Layer {
render.levelContainer?.destroy();
render.iconContainer = this.createIconSprite(unit);
render.levelContainer = this.createLevelSprite(unit);
+ this.modifyVisibility(render);
this.shouldRedraw = true;
}
}
@@ -251,6 +318,21 @@ export class StructureIconsLayer implements Layer {
return this.textureCache.get(cacheKey)!;
}
+ const shape = STRUCTURE_SHAPES[structureType];
+ const texture = shape
+ ? this.createIcon(unit.owner(), structureType, isConstruction, shape)
+ : PIXI.Texture.EMPTY;
+
+ this.textureCache.set(cacheKey, texture);
+ return texture;
+ }
+
+ private createIcon(
+ owner: PlayerView,
+ structureType: UnitType,
+ isConstruction: boolean,
+ shape: "triangle" | "square" | "octagon" | "circle",
+ ) {
const structureCanvas = document.createElement("canvas");
structureCanvas.width = ICON_SIZE;
structureCanvas.height = ICON_SIZE;
@@ -262,39 +344,93 @@ export class StructureIconsLayer implements Layer {
borderColor = "rgb(128, 127, 127)";
} else {
context.fillStyle = this.theme
- .territoryColor(unit.owner())
+ .territoryColor(owner)
.lighten(0.06)
.toRgbString();
- borderColor = this.theme
- .borderColor(unit.owner())
- .darken(0.08)
- .toRgbString();
+ borderColor = this.theme.borderColor(owner).darken(0.08).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();
+
+ switch (shape) {
+ case "triangle":
+ context.beginPath();
+ context.moveTo(ICON_SIZE / 2, 0); // Top
+ context.lineTo(ICON_SIZE, ICON_SIZE); // Bottom right
+ context.lineTo(0, ICON_SIZE); // Bottom left
+ context.closePath();
+ context.fill();
+ context.stroke();
+ break;
+
+ case "square":
+ context.fillRect(0, 0, ICON_SIZE - 2, ICON_SIZE - 2);
+ context.strokeRect(0.5, 0.5, ICON_SIZE - 3, ICON_SIZE - 3);
+ break;
+
+ case "octagon":
+ {
+ const cx = ICON_SIZE / 2;
+ const cy = ICON_SIZE / 2;
+ const r = ICON_SIZE / 2 - 1;
+ const step = (Math.PI * 2) / 8;
+
+ context.beginPath();
+ for (let i = 0; i < 8; i++) {
+ const angle = step * i - Math.PI / 8; // slight rotation for flat top
+ const x = cx + r * Math.cos(angle);
+ const y = cy + r * Math.sin(angle);
+ if (i === 0) {
+ context.moveTo(x, y);
+ } else {
+ context.lineTo(x, y);
+ }
+ }
+ context.closePath();
+ context.fill();
+ context.stroke();
+ }
+ break;
+
+ case "circle":
+ context.beginPath();
+ context.arc(
+ ICON_SIZE / 2,
+ ICON_SIZE / 2,
+ ICON_SIZE / 2 - 1,
+ 0,
+ Math.PI * 2,
+ );
+ context.fill();
+ context.stroke();
+ break;
+
+ default:
+ throw new Error(`Unknown shape: ${shape}`);
+ }
+
const structureInfo = this.structures.get(structureType);
if (!structureInfo?.image) {
console.warn(`Image not loaded for unit type: ${structureType}`);
return PIXI.Texture.from(structureCanvas);
}
+
+ const SHAPE_OFFSETS = {
+ triangle: [4, 8],
+ square: [3, 3],
+ octagon: [4, 4],
+ circle: [4, 4],
+ };
+ const [offsetX, offsetY] = SHAPE_OFFSETS[shape] || [0, 0];
+
context.drawImage(
this.getImageColored(structureInfo.image, borderColor),
- 4,
- 4,
+ offsetX,
+ offsetY,
);
- const texture = PIXI.Texture.from(structureCanvas);
- this.textureCache.set(cacheKey, texture);
- return texture;
+
+ return PIXI.Texture.from(structureCanvas);
}
private createLevelSprite(unit: UnitView): PIXI.Container {
@@ -423,19 +559,14 @@ export class StructureIconsLayer implements Layer {
const render = new StructureRenderInfo(
unitView,
unitView.owner().id(),
- this.createUnitContainer(unitView, {
- addIcon: true,
- stage: this.iconsStage,
- }),
- this.createUnitContainer(unitView, {
- addIcon: false,
- stage: this.levelsStage,
- }),
+ this.createIconSprite(unitView),
+ this.createLevelSprite(unitView),
unitView.level(),
unitView.type() === UnitType.Construction,
);
this.renders.push(render);
this.computeNewLocation(render);
+ this.modifyVisibility(render);
this.shouldRedraw = true;
}
diff --git a/src/client/graphics/layers/StructureLayer.ts b/src/client/graphics/layers/StructureLayer.ts
index ee576cc67..a225d67cc 100644
--- a/src/client/graphics/layers/StructureLayer.ts
+++ b/src/client/graphics/layers/StructureLayer.ts
@@ -21,7 +21,7 @@ const underConstructionColor = colord({ r: 150, g: 150, b: 150 });
const BASE_BORDER_RADIUS = 16.5;
const BASE_TERRITORY_RADIUS = 13.5;
const RADIUS_SCALE_FACTOR = 0.5;
-const ZOOM_THRESHOLD = 2.5; // below this zoom level, structures are not rendered
+const ZOOM_THRESHOLD = 3.5; // below this zoom level, structures are not rendered
interface UnitRenderConfig {
icon: string;