Add new icon shapes and filter for filtering icons on the layer (#1348)

## Description:

Add triangle shape for missile silos, square for sam, octagon for
defense posts, and add a filter in the topbar to highlight structures


![image](https://github.com/user-attachments/assets/d0986037-d4d7-41c6-b353-2a69b1eeb7c4)

![highlight](https://github.com/user-attachments/assets/0018e68a-31d4-478f-be57-56c3f71ee32a)


## 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
This commit is contained in:
Vivacious Box
2025-07-07 18:36:55 +02:00
committed by GitHub
parent e661c8b773
commit 105286ed29
8 changed files with 274 additions and 47 deletions
+19
View File
@@ -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",
+1
View File
@@ -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",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 238 B

After

Width:  |  Height:  |  Size: 224 B

+5
View File
@@ -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,
+1 -1
View File
@@ -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),
+79 -8
View File
@@ -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 {
</div>
</div>
<div
class="grid grid-rows-1 auto-cols-max grid-flow-col gap-1 bg-slate-800/20 border border-slate-400 p-0.5 md:px-1 lg:px-2 md:gap-2"
class="grid grid-rows-1 auto-cols-max grid-flow-col bg-slate-800/20 border border-slate-400 p-0.5 md:px-1 lg:px-2"
>
<div class="flex items-center gap-2">
<div
class="md:px-2 px-1 flex items-center gap-2"
style="background: ${this._selectedStructure ===
UnitType.City
? "#ffffff2e"
: "none"}"
@mouseenter="${() =>
this.onToggleStructureClick(UnitType.City)}"
@mouseleave="${() =>
this.onToggleStructureClick(UnitType.City)}"
>
<img
src=${cityIcon}
alt="gold"
@@ -292,7 +313,17 @@ export class GameTopBar extends LitElement implements Layer {
/>
${renderNumber(this._cities)}
</div>
<div class="flex items-center gap-2">
<div
class="md:px-2 px-1 flex items-center gap-2"
style="background: ${this._selectedStructure ===
UnitType.Factory
? "#ffffff2e"
: "none"}"
@mouseenter="${() =>
this.onToggleStructureClick(UnitType.Factory)}"
@mouseleave="${() =>
this.onToggleStructureClick(UnitType.Factory)}"
>
<img
src=${factoryIcon}
alt="gold"
@@ -302,7 +333,17 @@ export class GameTopBar extends LitElement implements Layer {
/>
${renderNumber(this._factories)}
</div>
<div class="flex items-center gap-2">
<div
class="md:px-2 px-1 flex items-center gap-2"
style="background: ${this._selectedStructure ===
UnitType.Port
? "#ffffff2e"
: "none"}"
@mouseenter="${() =>
this.onToggleStructureClick(UnitType.Port)}"
@mouseleave="${() =>
this.onToggleStructureClick(UnitType.Port)}"
>
<img
src=${portIcon}
alt="gold"
@@ -312,7 +353,17 @@ export class GameTopBar extends LitElement implements Layer {
/>
${renderNumber(this._port)}
</div>
<div class="flex items-center gap-2">
<div
class="md:px-2 px-1 flex items-center gap-2"
style="background: ${this._selectedStructure ===
UnitType.DefensePost
? "#ffffff2e"
: "none"}"
@mouseenter="${() =>
this.onToggleStructureClick(UnitType.DefensePost)}"
@mouseleave="${() =>
this.onToggleStructureClick(UnitType.DefensePost)}"
>
<img
src=${defensePostIcon}
alt="gold"
@@ -322,7 +373,17 @@ export class GameTopBar extends LitElement implements Layer {
/>
${renderNumber(this._defensePost)}
</div>
<div class="flex items-center gap-2">
<div
class="md:px-2 px-1 flex items-center gap-2"
style="background: ${this._selectedStructure ===
UnitType.MissileSilo
? "#ffffff2e"
: "none"}"
@mouseenter="${() =>
this.onToggleStructureClick(UnitType.MissileSilo)}"
@mouseleave="${() =>
this.onToggleStructureClick(UnitType.MissileSilo)}"
>
<img
src=${missileSiloIcon}
alt="gold"
@@ -332,7 +393,17 @@ export class GameTopBar extends LitElement implements Layer {
/>
${renderNumber(this._missileSilo)}
</div>
<div class="flex items-center gap-2">
<div
class="md:px-2 px-1 flex items-center gap-2"
style="background: ${this._selectedStructure ===
UnitType.SAMLauncher
? "#ffffff2e"
: "none"}"
@mouseenter="${() =>
this.onToggleStructureClick(UnitType.SAMLauncher)}"
@mouseleave="${() =>
this.onToggleStructureClick(UnitType.SAMLauncher)}"
>
<img
src=${samLauncherIcon}
alt="gold"
+168 -37
View File
@@ -1,3 +1,4 @@
import { OutlineFilter } from "pixi-filters";
import * as PIXI from "pixi.js";
import bitmapFont from "../../../../resources/fonts/round_6x6_modified.xml";
import anchorIcon from "../../../../resources/images/AnchorIcon.png";
@@ -7,12 +8,16 @@ 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 { EventBus } from "../../../core/EventBus";
import { Cell, PlayerID, UnitType } from "../../../core/game/Game";
import { GameUpdateType } from "../../../core/game/GameUpdates";
import { GameView, UnitView } from "../../../core/game/GameView";
import { GameView, PlayerView, UnitView } from "../../../core/game/GameView";
import { ToggleStructureEvent } from "../../InputHandler";
import { TransformHandler } from "../TransformHandler";
import { Layer } from "./Layer";
type ShapeType = "triangle" | "square" | "octagon" | "circle";
class StructureRenderInfo {
public isOnScreen: boolean = false;
constructor(
@@ -24,7 +29,16 @@ class StructureRenderInfo {
public underConstruction: boolean = true,
) {}
}
const ZOOM_THRESHOLD = 2.5;
const STRUCTURE_SHAPES: Partial<Record<UnitType, ShapeType>> = {
[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<UnitView> = 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;
}
+1 -1
View File
@@ -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;