Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions src/client/graphics/layers/StructureIconsLayer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
}
}
}

Expand All @@ -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) {
Expand Down Expand Up @@ -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);
}
}
9 changes: 5 additions & 4 deletions src/client/graphics/layers/UnitLayer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
153 changes: 151 additions & 2 deletions tests/client/graphics/layers/StructureIconsLayer.test.ts
Original file line number Diff line number Diff line change
@@ -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<number>;
rendersByUnitId: Map<number, unknown>;
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<typeof vi.fn>, unitId: number) {
return {
unit: {
id() {
return unitId;
},
},
iconContainer: { destroy },
levelContainer: { destroy },
dotContainer: { destroy },
};
}

/**
* Tests for StructureIconsLayer edge cases mentioned in comments:
Expand Down Expand Up @@ -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]);
});
});