From 105286ed29ebb41b4004cc591378289d329995b4 Mon Sep 17 00:00:00 2001 From: Vivacious Box Date: Mon, 7 Jul 2025 18:36:55 +0200 Subject: [PATCH] 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 --- package-lock.json | 19 ++ package.json | 1 + resources/images/ShieldIcon.png | Bin 238 -> 224 bytes src/client/InputHandler.ts | 5 + src/client/graphics/GameRenderer.ts | 2 +- src/client/graphics/layers/GameTopBar.ts | 87 +++++++- .../graphics/layers/StructureIconsLayer.ts | 205 ++++++++++++++---- src/client/graphics/layers/StructureLayer.ts | 2 +- 8 files changed, 274 insertions(+), 47 deletions(-) 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 5720eb7ec54142c8fd0d7789574d19874d542473..ac58abdec501cb31e9f91d5341c730bb3c480eef 100644 GIT binary patch delta 183 zcmV;o07(Dt0pJ0UF@Ks#L_t(IjqQ^=4udcdL?2X$3m}>loFm6d$6+`QHJ6~IM`k*TJ{*yn8jlBzyQ2}0|?@w+@Km4;lj7#)2uDC0@uP8=yC#U!KBsZ z>pK_Z;Vv! ll2d`~aD=K|ZKbocsU)002ovPDHLkV1j3ZQ@j8G delta 197 zcmV;$06PER0qy~iF@LB@L_t(IjqQ`M4Z|Q5L?5Xv7O>o;@FH0%U53dlsZwVNO81s* zlje*F;wPl{NnZj!+#TY8?|7=3F#scQ1r{Kx7Ojoili;#(EXX$CqVWSb6@6FClUfh> ziaW1ftcWFo&~VL=6O^|8#zF8&5P`1YOlLjgE6ag}v-VWu?LHLQO|i|1df=wsTub2& z4@^>-mHQ$47t;@i$;n-6d#!R{DqBQBWgdJ3O+6{(hpC_t00000NkvXXu0mjflw4ZH 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 {
-
+
gold ${renderNumber(this._cities)}
-
+
gold ${renderNumber(this._factories)}
-
+
gold ${renderNumber(this._port)}
-
+
gold ${renderNumber(this._defensePost)}
-
+
gold ${renderNumber(this._missileSilo)}
-
+
gold> = { + [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;