Skip to content
Open
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
1 change: 1 addition & 0 deletions src/client/ClientGameRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
55 changes: 44 additions & 11 deletions src/client/graphics/GameRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,23 @@ export class GameRenderer {
private layerTickState = new Map<Layer, { lastTickAtMs: number }>();
private renderFramesSinceLastTick: number = 0;
private renderLayerDurationsSinceLastTick: Record<string, number> = {};
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,
Expand All @@ -351,28 +368,40 @@ 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
if (!document.body.contains(this.canvas)) {
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() {
Expand All @@ -391,6 +420,10 @@ export class GameRenderer {
}

renderGame() {
if (this.disposed) {
return;
}

const shouldProfileFrame = FrameProfiler.isEnabled();
if (shouldProfileFrame) {
FrameProfiler.clear();
Expand Down Expand Up @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions src/client/graphics/layers/Layer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ export interface Layer {
renderLayer?: (context: CanvasRenderingContext2D) => void;
shouldTransform?: () => boolean;
redraw?: () => void;
dispose?: () => void;
}
128 changes: 116 additions & 12 deletions src/client/graphics/layers/StructureIconsLayer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -166,35 +178,77 @@ 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 {
return false;
}

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));
}
Comment on lines +243 to +251
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this related the the canvas redraw?

Copy link
Copy Markdown
Contributor

@VariableVince VariableVince Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could fit well with my attempt at fixing the "icons displaced on zoom" in #3453. Which is currently waiting for testers.

If this code isn't accepted as part of this PR because it is outside its scope, it may well fit within the scope of my PR 3453 or as a seperate PR. Since resize isn't covered by TransformHandler, currently icon locations are indeed not recalculated in all cases it seems.

(also @Skigim )

}
}

Expand All @@ -221,6 +275,24 @@ export class StructureIconsLayer implements Layer {

redraw() {
this.resizeCanvas();
this.rebuildAllStructuresFromState();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Must say i do like that, as a by-effect, Alt+R will work on the structure icons with this. Whereas currently only some of the graphics are rebuild if the user tries Alt+R.

}

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) {
Expand Down Expand Up @@ -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);
}
}
4 changes: 2 additions & 2 deletions src/client/graphics/layers/UnitLayer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading
Loading