diff --git a/src/client/graphics/layers/StructureIconsLayer.ts b/src/client/graphics/layers/StructureIconsLayer.ts index 9055e47e3..6c5432f9b 100644 --- a/src/client/graphics/layers/StructureIconsLayer.ts +++ b/src/client/graphics/layers/StructureIconsLayer.ts @@ -77,8 +77,10 @@ export class StructureIconsLayer implements Layer { private readonly theme: Theme; private renderer: PIXI.Renderer | null = null; private rendererInitialized: boolean = false; - private renders: StructureRenderInfo[] = []; - private readonly seenUnits: Set = new Set(); + private readonly rendersByUnitId: Map = + new Map(); + private readonly seenUnitIds: Set = new Set(); + private readonly connectedAllySmallIds: Set = new Set(); private readonly mousePos = { x: 0, y: 0 }; private renderSprites = true; private factory: SpriteFactory; @@ -91,6 +93,8 @@ export class StructureIconsLayer implements Layer { [UnitType.SAMLauncher, { visible: true }], ]); private lastGhostQueryAt: number; + private visibilityStateDirty = true; + private hasHiddenStructure = false; potentialUpgrade: StructureRenderInfo | undefined; constructor( @@ -185,18 +189,22 @@ export class StructureIconsLayer implements Layer { } tick() { - this.game - .updatesSinceLastTick() - ?.[GameUpdateType.Unit]?.map((unit) => this.game.unit(unit.id)) - ?.forEach((unitView) => { - if (unitView === undefined) return; + const unitUpdates = this.game.updatesSinceLastTick()?.[GameUpdateType.Unit]; + if (unitUpdates) { + for (let i = 0, len = unitUpdates.length; i < len; i++) { + const unitView = this.game.unit(unitUpdates[i].id); + if (unitView === undefined) { + continue; + } + const unitId = unitView.id(); if (unitView.isActive()) { this.handleActiveUnit(unitView); - } else if (this.seenUnits.has(unitView)) { + } else if (this.seenUnitIds.has(unitId)) { this.handleInactiveUnit(unitView); } - }); + } + } this.renderSprites = this.game.config().userSettings()?.structureSprites() ?? true; } @@ -224,7 +232,7 @@ export class StructureIconsLayer implements Layer { this.renderGhost(); if (this.transformHandler.hasChanged()) { - for (const render of this.renders) { + for (const render of this.rendersByUnitId.values()) { this.computeNewLocation(render); } } @@ -269,13 +277,21 @@ export class StructureIconsLayer implements Layer { (nukeType === UnitType.AtomBomb || nukeType === UnitType.HydrogenBomb) ) { // Only check connected allies - nuking disconnected allies doesn't cause a traitor debuff - const allies = myPlayer.allies().filter((a) => !a.isDisconnected()); - if (allies.length > 0) { + this.connectedAllySmallIds.clear(); + const allies = myPlayer.allies(); + for (let i = 0; i < allies.length; i++) { + const ally = allies[i]; + if (!ally.isDisconnected()) { + this.connectedAllySmallIds.add(ally.smallID()); + } + } + + if (this.connectedAllySmallIds.size > 0) { targetingAlly = wouldNukeBreakAlliance({ game: this.game, targetTile: tileRef, magnitude: this.game.config().nukeMagnitudes(nukeType), - allySmallIds: new Set(allies.map((a) => a.smallID())), + allySmallIds: this.connectedAllySmallIds, threshold: this.game.config().nukeAllianceBreakThreshold(), }); } @@ -318,11 +334,14 @@ export class StructureIconsLayer implements Layer { this.updateGhostRange(targetLevel, targetingAlly); if (unit.canUpgrade) { - this.potentialUpgrade = this.renders.find( - (r) => - r.unit.id() === unit.canUpgrade && - r.unit.owner().id() === this.game.myPlayer()?.id(), - ); + this.potentialUpgrade = this.rendersByUnitId.get(unit.canUpgrade); + if ( + this.potentialUpgrade && + this.potentialUpgrade.unit.owner().id() !== + this.game.myPlayer()?.id() + ) { + this.potentialUpgrade = undefined; + } if (this.potentialUpgrade) { this.potentialUpgrade.iconContainer.filters = [ new OutlineFilter({ thickness: 2, color: "rgba(0, 255, 0, 1)" }), @@ -546,19 +565,36 @@ export class StructureIconsLayer implements Layer { toggleStructureType?.indexOf(structureType) !== -1 || toggleStructureType === null; } - for (const render of this.renders) { + this.visibilityStateDirty = true; + for (const render of this.rendersByUnitId.values()) { this.modifyVisibility(render); } } + private refreshVisibilityStateCache() { + if (!this.visibilityStateDirty) { + return; + } + + this.hasHiddenStructure = false; + for (const infos of this.structures.values()) { + if (infos.visible === false) { + this.hasHiddenStructure = true; + break; + } + } + + this.visibilityStateDirty = false; + } + private findRenderByUnit( unitView: UnitView, ): StructureRenderInfo | undefined { - return this.renders.find((render) => render.unit.id() === unitView.id()); + return this.rendersByUnitId.get(unitView.id()); } private handleActiveUnit(unitView: UnitView) { - if (this.seenUnits.has(unitView)) { + if (this.seenUnitIds.has(unitView.id())) { const render = this.findRenderByUnit(unitView); if (render) { this.checkForConstructionState(render, unitView); @@ -572,6 +608,10 @@ export class StructureIconsLayer implements Layer { } private handleInactiveUnit(unitView: UnitView) { + if (!this.seenUnitIds.has(unitView.id())) { + return; + } + const render = this.findRenderByUnit(unitView); if (render) { this.deleteStructure(render); @@ -579,20 +619,15 @@ export class StructureIconsLayer implements Layer { } private modifyVisibility(render: StructureRenderInfo) { + this.refreshVisibilityStateCache(); + const structureType = 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; render.dotContainer.alpha = structureInfos.visible ? 1 : 0.3; - if (structureInfos.visible && focusStructure) { + if (structureInfos.visible && this.hasHiddenStructure) { render.iconContainer.filters = [ new OutlineFilter({ thickness: 2, color: "rgb(255, 255, 255)" }), ]; @@ -714,7 +749,7 @@ export class StructureIconsLayer implements Layer { } private addNewStructure(unitView: UnitView) { - this.seenUnits.add(unitView); + this.seenUnitIds.add(unitView.id()); const render = new StructureRenderInfo( unitView, unitView.owner().id(), @@ -724,7 +759,7 @@ export class StructureIconsLayer implements Layer { unitView.level(), unitView.isUnderConstruction(), ); - this.renders.push(render); + this.rendersByUnitId.set(unitView.id(), render); this.computeNewLocation(render); this.modifyVisibility(render); } @@ -754,7 +789,11 @@ export class StructureIconsLayer implements Layer { render.iconContainer?.destroy(); render.levelContainer?.destroy(); render.dotContainer?.destroy(); - this.renders = this.renders.filter((r) => r.unit !== render.unit); - this.seenUnits.delete(render.unit); + const unitId = render.unit.id(); + this.rendersByUnitId.delete(unitId); + this.seenUnitIds.delete(unitId); + if (this.potentialUpgrade?.unit.id() === unitId) { + this.potentialUpgrade = undefined; + } } } diff --git a/tests/perf/StructureIconsLayerLookupPerf.ts b/tests/perf/StructureIconsLayerLookupPerf.ts new file mode 100644 index 000000000..05f740eac --- /dev/null +++ b/tests/perf/StructureIconsLayerLookupPerf.ts @@ -0,0 +1,109 @@ +import Benchmark from "benchmark"; + +const STRUCTURE_COUNT = 50000; +const LOOKUP_COUNT = 50000; +const UPGRADE_LOOKUP_COUNT = 5000; + +interface StructureRenderSample { + unitId: number; + ownerId: number; + level: number; +} + +const rendersArray: StructureRenderSample[] = Array.from( + { length: STRUCTURE_COUNT }, + (_, index) => ({ + unitId: index + 1, + ownerId: (index % 5) + 1, + level: (index % 4) + 1, + }), +); + +const rendersMap = new Map(); +for (const render of rendersArray) { + rendersMap.set(render.unitId, render); +} + +const activeLookupIds = Array.from( + { length: LOOKUP_COUNT }, + (_, index) => ((index * 97) % STRUCTURE_COUNT) + 1, +); + +const inactiveLookupIds = Array.from( + { length: LOOKUP_COUNT }, + (_, index) => ((index * 193) % STRUCTURE_COUNT) + 1, +); + +const canUpgradeIds = Array.from( + { length: UPGRADE_LOOKUP_COUNT }, + (_, index) => ((index * 389) % STRUCTURE_COUNT) + 1, +); + +const myOwnerId = 3; +const results: string[] = []; + +new Benchmark.Suite() + .add("StructureIconsLayer BEFORE (array O(n) lookup/delete)", () => { + const localRenders = rendersArray.map((render) => ({ ...render })); + + for (const unitId of activeLookupIds) { + const render = localRenders.find((entry) => entry.unitId === unitId); + if (render) { + render.level = render.level + 1; + } + } + + for (const canUpgradeId of canUpgradeIds) { + const potentialUpgrade = localRenders.find( + (entry) => entry.unitId === canUpgradeId && entry.ownerId === myOwnerId, + ); + if (potentialUpgrade) { + potentialUpgrade.level = potentialUpgrade.level + 1; + } + } + + for (const unitId of inactiveLookupIds) { + const index = localRenders.findIndex((entry) => entry.unitId === unitId); + if (index !== -1) { + localRenders.splice(index, 1); + } + } + }) + .add("StructureIconsLayer AFTER (unit-id map O(1) lookup/delete)", () => { + const localRenders = new Map(); + for (const [unitId, render] of rendersMap) { + localRenders.set(unitId, { ...render }); + } + + for (const unitId of activeLookupIds) { + const render = localRenders.get(unitId); + if (render) { + render.level = render.level + 1; + } + } + + for (const canUpgradeId of canUpgradeIds) { + const potentialUpgrade = localRenders.get(canUpgradeId); + if (potentialUpgrade && potentialUpgrade.ownerId === myOwnerId) { + potentialUpgrade.level = potentialUpgrade.level + 1; + } + } + + for (const unitId of inactiveLookupIds) { + localRenders.delete(unitId); + } + }) + .on("cycle", (event: Benchmark.Event) => { + results.push(String(event.target)); + }) + .on("complete", function () { + console.log("\n=== StructureIconsLayer Lookup Benchmark Results ==="); + + for (const result of results) { + console.log(result); + } + + const fastest = this.filter("fastest").map("name"); + console.log(`\nFastest implementation: ${fastest.join(", ")}`); + }) + .run({ async: true });