diff --git a/src/client/graphics/layers/StructureIconsLayer.ts b/src/client/graphics/layers/StructureIconsLayer.ts index a3018d5420..f232836125 100644 --- a/src/client/graphics/layers/StructureIconsLayer.ts +++ b/src/client/graphics/layers/StructureIconsLayer.ts @@ -166,6 +166,15 @@ export class StructureIconsLayer implements Layer { this.renderer = renderer; this.rendererInitialized = true; + this.pixicanvas.addEventListener("webglcontextlost", (event) => { + // Prevent the browser's default context-loss handling so Pixi can restore + // the WebGL context and we can rebuild structure renders from game state. + event.preventDefault(); + }); + this.pixicanvas.addEventListener("webglcontextrestored", () => { + this.resizeCanvas(); + this.rebuildAllStructuresFromState(); + }); } shouldTransform(): boolean { @@ -195,6 +204,12 @@ export class StructureIconsLayer implements Layer { this.pixicanvas.width = window.innerWidth; this.pixicanvas.height = window.innerHeight; this.renderer.resize(innerWidth, innerHeight, 1); + this.resizeStages(); + // Canvas size changes affect screen-space culling and icon placement, so + // recompute every tracked structure location after a resize/reset. + for (const render of this.rendersByUnitId.values()) { + this.computeNewLocation(render); + } } } @@ -221,6 +236,20 @@ export class StructureIconsLayer implements Layer { redraw() { this.resizeCanvas(); + this.rebuildAllStructuresFromState(); + } + + rebuildAllStructuresFromState() { + this.clearAllStructureRenders(); + + for (const unitView of this.game.units()) { + if ( + unitView.isActive() && + this.structures.has(unitView.type() as PlayerBuildableUnitType) + ) { + this.addNewStructure(unitView); + } + } } renderLayer(mainContext: CanvasRenderingContext2D) { @@ -852,4 +881,38 @@ export class StructureIconsLayer implements Layer { this.potentialUpgrade = undefined; } } + + private clearAllStructureRenders() { + for (const render of Array.from(this.rendersByUnitId.values())) { + this.deleteStructure(render); + } + this.clearStageChildren(this.iconsStage); + this.clearStageChildren(this.levelsStage); + this.clearStageChildren(this.dotsStage); + this.rendersByUnitId.clear(); + this.seenUnitIds.clear(); + } + + /** + * Deep-clears a Pixi stage after renderer disruption or before a full + * state-sourced rebuild. Callers must repopulate the stage immediately after + * this cleanup because all child display objects are destroyed recursively. + */ + private clearStageChildren(stage?: PIXI.Container) { + if (!stage) { + return; + } + + for (const child of stage.removeChildren()) { + child.destroy({ children: true }); + } + } + + private resizeStages() { + this.iconsStage?.setSize(this.pixicanvas.width, this.pixicanvas.height); + this.ghostStage?.setSize(this.pixicanvas.width, this.pixicanvas.height); + this.levelsStage?.setSize(this.pixicanvas.width, this.pixicanvas.height); + this.dotsStage?.setSize(this.pixicanvas.width, this.pixicanvas.height); + this.rootStage?.setSize(this.pixicanvas.width, this.pixicanvas.height); + } } diff --git a/src/client/graphics/layers/UnitLayer.ts b/src/client/graphics/layers/UnitLayer.ts index e5d72f0e8f..4ec7e64109 100644 --- a/src/client/graphics/layers/UnitLayer.ts +++ b/src/client/graphics/layers/UnitLayer.ts @@ -273,11 +273,12 @@ export class UnitLayer implements Layer { } private updateUnitsSprites(unitIds: number[]) { - const unitsToUpdate = unitIds - ?.map((id) => this.game.unit(id)) - .filter((unit) => unit !== undefined); + const unitsToUpdate = + unitIds + ?.map((id) => this.game.unit(id)) + .filter((unit) => unit !== undefined) ?? []; - if (unitsToUpdate) { + if (unitsToUpdate.length > 0) { // the clearing and drawing of unit sprites need to be done in 2 passes // otherwise the sprite of a unit can be drawn on top of another unit this.clearUnitsCells(unitsToUpdate); diff --git a/tests/client/graphics/layers/StructureIconsLayer.test.ts b/tests/client/graphics/layers/StructureIconsLayer.test.ts index 7cb8b557cc..439b57db71 100644 --- a/tests/client/graphics/layers/StructureIconsLayer.test.ts +++ b/tests/client/graphics/layers/StructureIconsLayer.test.ts @@ -1,6 +1,65 @@ -import { describe, expect, test } from "vitest"; -import { shouldPreserveGhostAfterBuild } from "../../../../src/client/graphics/layers/StructureIconsLayer"; +import { describe, expect, test, vi } from "vitest"; +import { + shouldPreserveGhostAfterBuild, + StructureIconsLayer, +} from "../../../../src/client/graphics/layers/StructureIconsLayer"; import { UnitType } from "../../../../src/core/game/Game"; +import { EventBus } from "../../../../src/core/EventBus"; + +type MockStructureUnit = { + id(): number; + type(): UnitType; + isActive(): boolean; +}; + +type StructureIconsLayerTestInternals = { + seenUnitIds: Set; + rendersByUnitId: Map; + iconsStage: { children: unknown[]; removeChildren(): unknown[] }; + levelsStage: { children: unknown[]; removeChildren(): unknown[] }; + dotsStage: { children: unknown[]; removeChildren(): unknown[] }; + addNewStructure(unit: { id(): number }): void; + rebuildAllStructuresFromState(): void; +}; + +function createStructureIconsLayerWithMockGame(units: MockStructureUnit[]) { + const game = { + config: () => ({ + theme: () => ({}), + userSettings: () => ({ structureSprites: () => true }), + }), + units: () => units, + }; + const transformHandler = { + scale: 1, + worldToScreenCoordinates: (cell: { x: number; y: number }) => cell, + }; + return new StructureIconsLayer( + game as never, + new EventBus(), + { + attackRatio: 20, + ghostStructure: null, + overlappingRailroads: [], + ghostRailPaths: [], + rocketDirectionUp: true, + }, + transformHandler as never, + ); +} + +function createMockRender(destroy: ReturnType, unitId: number) { + return { + unit: { + id() { + return unitId; + }, + }, + iconContainer: { destroy }, + levelContainer: { destroy }, + dotContainer: { destroy }, + }; +} /** * Tests for StructureIconsLayer edge cases mentioned in comments: @@ -35,4 +94,94 @@ describe("StructureIconsLayer ghost preservation (locked nuke / Enter confirm)", expect(shouldPreserveGhostAfterBuild(UnitType.MIRV)).toBe(false); }); }); + + test("redraw resizes the canvas and rebuilds structures from authoritative state", () => { + const layer = createStructureIconsLayerWithMockGame([]); + const resizeSpy = vi + .spyOn(layer, "resizeCanvas") + .mockImplementation(() => undefined); + const rebuildSpy = vi + .spyOn(layer, "rebuildAllStructuresFromState") + .mockImplementation(() => undefined); + + layer.redraw(); + + expect(resizeSpy).toHaveBeenCalledOnce(); + expect(rebuildSpy).toHaveBeenCalledOnce(); + }); + + test("rebuildAllStructuresFromState removes inactive renders and re-adds active structures", () => { + const activeCity = { + id() { + return 101; + }, + type() { + return UnitType.City; + }, + isActive() { + return true; + }, + }; + const inactiveCity = { + id() { + return 102; + }, + type() { + return UnitType.City; + }, + isActive() { + return false; + }, + }; + const activeTransportShip = { + id() { + return 103; + }, + type() { + return UnitType.TransportShip; + }, + isActive() { + return true; + }, + }; + const layer = createStructureIconsLayerWithMockGame([ + activeCity, + inactiveCity, + activeTransportShip, + ]); + const layerInternals = layer as unknown as StructureIconsLayerTestInternals; + const staleDestroy = vi.fn(); + const inactiveDestroy = vi.fn(); + const staleRender = createMockRender(staleDestroy, 7); + const inactiveRender = createMockRender(inactiveDestroy, 102); + const { seenUnitIds, rendersByUnitId } = layerInternals; + seenUnitIds.add(7); + seenUnitIds.add(102); + rendersByUnitId.set(7, staleRender); + rendersByUnitId.set(102, inactiveRender); + layerInternals.iconsStage = { + children: [], + removeChildren: vi.fn(() => []), + }; + layerInternals.levelsStage = { + children: [], + removeChildren: vi.fn(() => []), + }; + layerInternals.dotsStage = { children: [], removeChildren: vi.fn(() => []) }; + const addNewStructureSpy = vi + .spyOn(layerInternals, "addNewStructure") + .mockImplementation((unit: { id(): number }) => { + seenUnitIds.add(unit.id()); + rendersByUnitId.set(unit.id(), { unit }); + }); + + layerInternals.rebuildAllStructuresFromState(); + + expect(staleDestroy).toHaveBeenCalledTimes(3); + expect(inactiveDestroy).toHaveBeenCalledTimes(3); + expect(addNewStructureSpy).toHaveBeenCalledTimes(1); + expect(addNewStructureSpy).toHaveBeenCalledWith(activeCity); + expect(Array.from(rendersByUnitId.keys())).toEqual([101]); + expect(Array.from(seenUnitIds)).toEqual([101]); + }); });