diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index c8e092bdb8..c38e98abd6 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -518,6 +518,7 @@ export class ClientGameRunner { if (!this.isActive) return; this.isActive = false; + this.renderer.dispose(); this.worker.cleanup(); this.transport.leaveGame(); if (this.connectionCheckInterval) { diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index 1937b4a3e6..16ce86831a 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -335,6 +335,23 @@ export class GameRenderer { private layerTickState = new Map(); private renderFramesSinceLastTick: number = 0; private renderLayerDurationsSinceLastTick: Record = {}; + private animationFrameId: number | null = null; + private disposed = false; + private readonly onRedrawGraphics = () => this.redraw(); + private readonly onResize = () => this.resizeCanvas(); + private readonly onContextLost = () => { + if (this.animationFrameId !== null) { + cancelAnimationFrame(this.animationFrameId); + this.animationFrameId = null; + } + }; + private readonly onContextRestored = () => { + if (this.disposed) { + return; + } + this.redraw(); + this.animationFrameId = requestAnimationFrame(() => this.renderGame()); + }; constructor( private game: GameView, @@ -351,7 +368,7 @@ export class GameRenderer { } initialize() { - this.eventBus.on(RedrawGraphicsEvent, () => this.redraw()); + this.eventBus.on(RedrawGraphicsEvent, this.onRedrawGraphics); this.layers.forEach((l) => l.init?.()); // only append the canvas if it's not already in the document to avoid reparenting side-effects @@ -359,20 +376,32 @@ export class GameRenderer { document.body.appendChild(this.canvas); } - window.addEventListener("resize", () => this.resizeCanvas()); + window.addEventListener("resize", this.onResize); this.resizeCanvas(); //show whole map on startup this.transformHandler.centerAll(0.9); - let rafId = requestAnimationFrame(() => this.renderGame()); - this.canvas.addEventListener("contextlost", () => { - cancelAnimationFrame(rafId); - }); - this.canvas.addEventListener("contextrestored", () => { - this.redraw(); - rafId = requestAnimationFrame(() => this.renderGame()); - }); + this.animationFrameId = requestAnimationFrame(() => this.renderGame()); + this.canvas.addEventListener("contextlost", this.onContextLost); + this.canvas.addEventListener("contextrestored", this.onContextRestored); + } + + dispose() { + if (this.disposed) { + return; + } + + this.disposed = true; + this.eventBus.off(RedrawGraphicsEvent, this.onRedrawGraphics); + window.removeEventListener("resize", this.onResize); + this.canvas.removeEventListener("contextlost", this.onContextLost); + this.canvas.removeEventListener("contextrestored", this.onContextRestored); + if (this.animationFrameId !== null) { + cancelAnimationFrame(this.animationFrameId); + this.animationFrameId = null; + } + this.layers.forEach((layer) => layer.dispose?.()); } resizeCanvas() { @@ -391,6 +420,10 @@ export class GameRenderer { } renderGame() { + if (this.disposed) { + return; + } + const shouldProfileFrame = FrameProfiler.isEnabled(); if (shouldProfileFrame) { FrameProfiler.clear(); @@ -442,7 +475,7 @@ export class GameRenderer { handleTransformState(false, isTransformActive); // Ensure context is clean after rendering this.transformHandler.resetChanged(); - requestAnimationFrame(() => this.renderGame()); + this.animationFrameId = requestAnimationFrame(() => this.renderGame()); const duration = performance.now() - start; if (shouldProfileFrame) { diff --git a/src/client/graphics/layers/Layer.ts b/src/client/graphics/layers/Layer.ts index 456648f794..3ee79111d8 100644 --- a/src/client/graphics/layers/Layer.ts +++ b/src/client/graphics/layers/Layer.ts @@ -7,4 +7,5 @@ export interface Layer { renderLayer?: (context: CanvasRenderingContext2D) => void; shouldTransform?: () => boolean; redraw?: () => void; + dispose?: () => void; } diff --git a/src/client/graphics/layers/StructureIconsLayer.ts b/src/client/graphics/layers/StructureIconsLayer.ts index a3018d5420..d0d7af3947 100644 --- a/src/client/graphics/layers/StructureIconsLayer.ts +++ b/src/client/graphics/layers/StructureIconsLayer.ts @@ -100,6 +100,18 @@ export class StructureIconsLayer implements Layer { private visibilityStateDirty = true; private pendingConfirm: MouseUpEvent | null = null; private hasHiddenStructure = false; + private onWebGLContextLost: ((event: Event) => void) | null = null; + private onWebGLContextRestored: (() => void) | null = null; + private readonly onResize = () => this.resizeCanvas(); + private readonly onToggleStructures = (e: ToggleStructuresEvent) => + this.toggleStructures(e.structureTypes); + private readonly onMouseMove = (e: MouseMoveEvent) => this.moveGhost(e); + private readonly onMouseUp = (e: MouseUpEvent) => + this.requestConfirmStructure(e); + private readonly onConfirmGhostStructure = () => + this.requestConfirmStructure( + new MouseUpEvent(this.mousePos.x, this.mousePos.y), + ); potentialUpgrade: StructureRenderInfo | undefined; constructor( @@ -166,6 +178,22 @@ export class StructureIconsLayer implements Layer { this.renderer = renderer; this.rendererInitialized = true; + this.onWebGLContextLost = (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.onWebGLContextRestored = () => { + this.redraw(); + }; + this.pixicanvas.addEventListener( + "webglcontextlost", + this.onWebGLContextLost, + ); + this.pixicanvas.addEventListener( + "webglcontextrestored", + this.onWebGLContextRestored, + ); } shouldTransform(): boolean { @@ -173,28 +201,54 @@ export class StructureIconsLayer implements Layer { } async init() { - this.eventBus.on(ToggleStructuresEvent, (e) => - this.toggleStructures(e.structureTypes), - ); - this.eventBus.on(MouseMoveEvent, (e) => this.moveGhost(e)); + this.eventBus.on(ToggleStructuresEvent, this.onToggleStructures); + this.eventBus.on(MouseMoveEvent, this.onMouseMove); + this.eventBus.on(MouseUpEvent, this.onMouseUp); + this.eventBus.on(ConfirmGhostStructureEvent, this.onConfirmGhostStructure); - this.eventBus.on(MouseUpEvent, (e) => this.requestConfirmStructure(e)); - this.eventBus.on(ConfirmGhostStructureEvent, () => - this.requestConfirmStructure( - new MouseUpEvent(this.mousePos.x, this.mousePos.y), - ), - ); - - window.addEventListener("resize", () => this.resizeCanvas()); + window.addEventListener("resize", this.onResize); await this.setupRenderer(); this.redraw(); } + dispose() { + this.eventBus.off(ToggleStructuresEvent, this.onToggleStructures); + this.eventBus.off(MouseMoveEvent, this.onMouseMove); + this.eventBus.off(MouseUpEvent, this.onMouseUp); + this.eventBus.off(ConfirmGhostStructureEvent, this.onConfirmGhostStructure); + window.removeEventListener("resize", this.onResize); + + if (this.onWebGLContextLost) { + this.pixicanvas?.removeEventListener( + "webglcontextlost", + this.onWebGLContextLost, + ); + } + if (this.onWebGLContextRestored) { + this.pixicanvas?.removeEventListener( + "webglcontextrestored", + this.onWebGLContextRestored, + ); + } + + this.onWebGLContextLost = null; + this.onWebGLContextRestored = null; + } + resizeCanvas() { if (this.renderer) { 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); + } + if (this.ghostUnit) { + this.moveGhost(new MouseMoveEvent(this.mousePos.x, this.mousePos.y)); + } } } @@ -221,6 +275,24 @@ export class StructureIconsLayer implements Layer { redraw() { this.resizeCanvas(); + this.rebuildAllStructuresFromState(); + } + + rebuildAllStructuresFromState() { + if (!this.rendererInitialized || !this.renderer) { + return; + } + + 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 +924,36 @@ export class StructureIconsLayer implements Layer { this.potentialUpgrade = undefined; } } + + private clearAllStructureRenders() { + this.clearStageChildren(this.iconsStage); + this.clearStageChildren(this.levelsStage); + this.clearStageChildren(this.dotsStage); + this.rendersByUnitId.clear(); + this.seenUnitIds.clear(); + this.potentialUpgrade = undefined; + } + + /** + * 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..30bc7ecf73 100644 --- a/src/client/graphics/layers/UnitLayer.ts +++ b/src/client/graphics/layers/UnitLayer.ts @@ -274,10 +274,10 @@ export class UnitLayer implements Layer { private updateUnitsSprites(unitIds: number[]) { const unitsToUpdate = unitIds - ?.map((id) => this.game.unit(id)) + .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..7f34c92e22 100644 --- a/tests/client/graphics/layers/StructureIconsLayer.test.ts +++ b/tests/client/graphics/layers/StructureIconsLayer.test.ts @@ -1,6 +1,99 @@ -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 type { TransformHandler } from "../../../../src/client/graphics/TransformHandler"; +import { EventBus } from "../../../../src/core/EventBus"; import { UnitType } from "../../../../src/core/game/Game"; +import type { GameView } from "../../../../src/core/game/GameView"; + +type MockStructureUnit = { + id(): number; + type(): UnitType; + isActive(): boolean; +}; + +type MockGame = { + config(): { + theme(): object; + userSettings(): { + structureSprites(): boolean; + }; + }; + units(): MockStructureUnit[]; +}; + +type MockTransformHandler = { + scale: number; + worldToScreenCoordinates(cell: { x: number; y: number }): { + x: number; + y: number; + }; +}; + +type StructureIconsLayerTestInternals = { + pixicanvas: { + height?: number; + removeEventListener: ReturnType; + width?: number; + }; + onWebGLContextLost: ((event: Event) => void) | null; + onWebGLContextRestored: (() => void) | null; + ghostUnit?: object | null; + renderer: object | null; + rendererInitialized: boolean; + 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: MockGame = { + config: () => ({ + theme: () => ({}), + userSettings: () => ({ structureSprites: () => true }), + }), + units: () => units, + }; + const transformHandler: MockTransformHandler = { + scale: 1, + worldToScreenCoordinates: (cell: { x: number; y: number }) => cell, + }; + return new StructureIconsLayer( + game as unknown as GameView, + new EventBus(), + { + attackRatio: 20, + ghostStructure: null, + overlappingRailroads: [], + ghostRailPaths: [], + rocketDirectionUp: true, + }, + transformHandler as unknown as TransformHandler, + ); +} + +function createMockRender(destroy: ReturnType, unitId: number) { + return { + unit: { + id() { + return unitId; + }, + }, + iconContainer: { destroy }, + levelContainer: { destroy }, + dotContainer: { destroy }, + }; +} + +function createMockStageChild() { + return { destroy: vi.fn() }; +} /** * Tests for StructureIconsLayer edge cases mentioned in comments: @@ -35,4 +128,181 @@ 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", () => { + // Arrange + const layer = createStructureIconsLayerWithMockGame([]); + const resizeSpy = vi + .spyOn(layer, "resizeCanvas") + .mockImplementation(() => undefined); + const rebuildSpy = vi + .spyOn(layer, "rebuildAllStructuresFromState") + .mockImplementation(() => undefined); + + // Act + layer.redraw(); + + // Assert + expect(resizeSpy).toHaveBeenCalledOnce(); + expect(rebuildSpy).toHaveBeenCalledOnce(); + }); + + test("rebuildAllStructuresFromState is a no-op before the renderer is initialized", () => { + const layer = createStructureIconsLayerWithMockGame([ + { + id() { + return 101; + }, + type() { + return UnitType.City; + }, + isActive() { + return true; + }, + }, + ]); + const layerInternals = layer as unknown as StructureIconsLayerTestInternals; + const addNewStructureSpy = vi.spyOn(layerInternals, "addNewStructure"); + + expect(() => layer.rebuildAllStructuresFromState()).not.toThrow(); + expect(addNewStructureSpy).not.toHaveBeenCalled(); + }); + + test("resizeCanvas repositions the active ghost after resizing", () => { + const layer = createStructureIconsLayerWithMockGame([]); + const layerInternals = layer as unknown as StructureIconsLayerTestInternals; + const resize = vi.fn(); + const moveGhostSpy = vi + .spyOn( + layer as unknown as { moveGhost(event: unknown): void }, + "moveGhost", + ) + .mockImplementation(() => undefined); + + layerInternals.renderer = { resize }; + layerInternals.ghostUnit = {}; + layerInternals.pixicanvas = { + width: 0, + height: 0, + removeEventListener: vi.fn(), + }; + + layer.resizeCanvas(); + + expect(resize).toHaveBeenCalledOnce(); + expect(moveGhostSpy).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 staleRender = createMockRender(vi.fn(), 7); + const inactiveRender = createMockRender(vi.fn(), 102); + const iconChildA = createMockStageChild(); + const iconChildB = createMockStageChild(); + const levelChildA = createMockStageChild(); + const levelChildB = createMockStageChild(); + const dotChildA = createMockStageChild(); + const dotChildB = createMockStageChild(); + const { seenUnitIds, rendersByUnitId } = layerInternals; + layerInternals.renderer = {}; + layerInternals.rendererInitialized = true; + seenUnitIds.add(7); + seenUnitIds.add(102); + rendersByUnitId.set(7, staleRender); + rendersByUnitId.set(102, inactiveRender); + layerInternals.iconsStage = { + children: [], + removeChildren: vi.fn(() => [iconChildA, iconChildB]), + }; + layerInternals.levelsStage = { + children: [], + removeChildren: vi.fn(() => [levelChildA, levelChildB]), + }; + layerInternals.dotsStage = { + children: [], + removeChildren: vi.fn(() => [dotChildA, dotChildB]), + }; + const addNewStructureSpy = vi + .spyOn(layerInternals, "addNewStructure") + .mockImplementation((unit: { id(): number }) => { + seenUnitIds.add(unit.id()); + rendersByUnitId.set(unit.id(), { unit }); + }); + + layerInternals.rebuildAllStructuresFromState(); + + expect(iconChildA.destroy).toHaveBeenCalledWith({ children: true }); + expect(iconChildB.destroy).toHaveBeenCalledWith({ children: true }); + expect(levelChildA.destroy).toHaveBeenCalledWith({ children: true }); + expect(levelChildB.destroy).toHaveBeenCalledWith({ children: true }); + expect(dotChildA.destroy).toHaveBeenCalledWith({ children: true }); + expect(dotChildB.destroy).toHaveBeenCalledWith({ children: true }); + expect(addNewStructureSpy).toHaveBeenCalledTimes(1); + expect(addNewStructureSpy).toHaveBeenCalledWith(activeCity); + expect(Array.from(rendersByUnitId.keys())).toEqual([101]); + expect(Array.from(seenUnitIds)).toEqual([101]); + }); + + test("dispose removes WebGL canvas listeners and clears handler references", () => { + const layer = createStructureIconsLayerWithMockGame([]); + const layerInternals = layer as unknown as StructureIconsLayerTestInternals; + const removeEventListener = vi.fn(); + const lostHandler = vi.fn(); + const restoredHandler = vi.fn(); + + layerInternals.pixicanvas = { removeEventListener }; + layerInternals.onWebGLContextLost = lostHandler; + layerInternals.onWebGLContextRestored = restoredHandler; + + layer.dispose(); + + expect(removeEventListener).toHaveBeenCalledWith( + "webglcontextlost", + lostHandler, + ); + expect(removeEventListener).toHaveBeenCalledWith( + "webglcontextrestored", + restoredHandler, + ); + expect(layerInternals.onWebGLContextLost).toBeNull(); + expect(layerInternals.onWebGLContextRestored).toBeNull(); + }); });