From 0bfad91c047fe7695933d3f8a779752f675daa37 Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Mon, 26 Jan 2026 05:14:55 +0100 Subject: [PATCH] perf(ui): switch UI layers to wall-time tick intervals (#3025) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: Preparatory change for the upcoming “unbounded worker” work: decouple expensive UI layer updates from game tick frequency by moving UI ticking to wall-clock intervals. This reduces redundant UI work when the simulation runs faster than real time (notably replays / singleplayer at speed > 1) while keeping the UI responsive and predictable. ## Changes: - Add optional `Layer.getTickIntervalMs()` and enforce it in `GameRenderer.tick()` using wall-clock time. - Convert key UI layers from tick-modulus gating to fixed intervals: - `ControlPanel`: 100ms - `GameRightSidebar`: 250ms - `MainRadialMenu`: 500ms - `Leaderboard`, `NameLayer`, `ReplayPanel`, `TeamStats`: 1000ms ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: DISCORD_USERNAME --- src/client/graphics/GameRenderer.ts | 24 ++++++++++- src/client/graphics/layers/ControlPanel.ts | 8 ++-- .../graphics/layers/GameRightSidebar.ts | 43 +++++++++++-------- src/client/graphics/layers/Layer.ts | 3 ++ src/client/graphics/layers/Leaderboard.ts | 8 ++-- src/client/graphics/layers/MainRadialMenu.ts | 26 +++++------ src/client/graphics/layers/NameLayer.ts | 8 ++-- src/client/graphics/layers/ReplayPanel.ts | 8 ++-- src/client/graphics/layers/TeamStats.ts | 8 ++-- 9 files changed, 90 insertions(+), 46 deletions(-) diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index b4cd3eb38..466ce19e7 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -305,6 +305,7 @@ export function createRenderer( export class GameRenderer { private context: CanvasRenderingContext2D; + private layerTickState = new Map(); constructor( private game: GameView, @@ -416,7 +417,28 @@ export class GameRenderer { } tick() { - this.layers.forEach((l) => l.tick?.()); + const nowMs = performance.now(); + + for (const layer of this.layers) { + if (!layer.tick) { + continue; + } + + const state = this.layerTickState.get(layer) ?? { + lastTickAtMs: -Infinity, + }; + + const intervalMs = layer.getTickIntervalMs?.() ?? 0; + if (intervalMs > 0 && nowMs - state.lastTickAtMs < intervalMs) { + this.layerTickState.set(layer, state); + continue; + } + + state.lastTickAtMs = nowMs; + this.layerTickState.set(layer, state); + + layer.tick(); + } } resize(width: number, height: number): void { diff --git a/src/client/graphics/layers/ControlPanel.ts b/src/client/graphics/layers/ControlPanel.ts index f1c183e58..a6b03abad 100644 --- a/src/client/graphics/layers/ControlPanel.ts +++ b/src/client/graphics/layers/ControlPanel.ts @@ -39,6 +39,10 @@ export class ControlPanel extends LitElement implements Layer { private _lastTroopIncreaseRate: number; + getTickIntervalMs() { + return 100; + } + init() { this.attackRatio = Number( localStorage.getItem("settings.attackRatio") ?? "0.2", @@ -81,9 +85,7 @@ export class ControlPanel extends LitElement implements Layer { return; } - if (this.game.ticks() % 5 === 0) { - this.updateTroopIncrease(); - } + this.updateTroopIncrease(); this._maxTroops = this.game.config().maxTroops(player); this._gold = player.gold(); diff --git a/src/client/graphics/layers/GameRightSidebar.ts b/src/client/graphics/layers/GameRightSidebar.ts index e86317a7c..58880a992 100644 --- a/src/client/graphics/layers/GameRightSidebar.ts +++ b/src/client/graphics/layers/GameRightSidebar.ts @@ -2,10 +2,9 @@ import { html, LitElement } from "lit"; import { customElement, state } from "lit/decorators.js"; import { EventBus } from "../../../core/EventBus"; import { GameType } from "../../../core/game/Game"; -import { GameUpdateType } from "../../../core/game/GameUpdates"; import { GameView } from "../../../core/game/GameView"; import { crazyGamesSDK } from "../../CrazyGamesSDK"; -import { PauseGameIntentEvent } from "../../Transport"; +import { PauseGameIntentEvent, SendWinnerEvent } from "../../Transport"; import { translateText } from "../../Utils"; import { Layer } from "./Layer"; import { ShowReplayPanelEvent } from "./ReplayPanel"; @@ -50,16 +49,20 @@ export class GameRightSidebar extends LitElement implements Layer { this._isVisible = true; this.game.inSpawnPhase(); + this.eventBus.on(SendWinnerEvent, () => { + this.hasWinner = true; + this.requestUpdate(); + }); + this.requestUpdate(); } + getTickIntervalMs() { + return 250; + } + tick() { // Timer logic - const updates = this.game.updatesSinceLastTick(); - if (updates) { - this.hasWinner = this.hasWinner || updates[GameUpdateType.Win].length > 0; - } - // Check if the player is the lobby creator if (!this.isLobbyCreator && this.game.myPlayer()?.isLobbyCreator()) { this.isLobbyCreator = true; @@ -67,18 +70,24 @@ export class GameRightSidebar extends LitElement implements Layer { } const maxTimerValue = this.game.config().gameConfig().maxTimerValue; + const spawnPhaseTurns = this.game.config().numSpawnPhaseTurns(); + const ticks = this.game.ticks(); + const gameTicks = Math.max(0, ticks - spawnPhaseTurns); + const elapsedSeconds = Math.floor(gameTicks / 10); // 10 ticks per second + + if (this.game.inSpawnPhase()) { + this.timer = maxTimerValue !== undefined ? maxTimerValue * 60 : 0; + return; + } + + if (this.hasWinner) { + return; + } + if (maxTimerValue !== undefined) { - if (this.game.inSpawnPhase()) { - this.timer = maxTimerValue * 60; - } else if (!this.hasWinner && this.game.ticks() % 10 === 0) { - this.timer = Math.max(0, this.timer - 1); - } + this.timer = Math.max(0, maxTimerValue * 60 - elapsedSeconds); } else { - if (this.game.inSpawnPhase()) { - this.timer = 0; - } else if (!this.hasWinner && this.game.ticks() % 10 === 0) { - this.timer++; - } + this.timer = elapsedSeconds; } } diff --git a/src/client/graphics/layers/Layer.ts b/src/client/graphics/layers/Layer.ts index 239937435..456648f79 100644 --- a/src/client/graphics/layers/Layer.ts +++ b/src/client/graphics/layers/Layer.ts @@ -1,6 +1,9 @@ export interface Layer { init?: () => void; tick?: () => void; + // Optional hint to throttle expensive ticks by wall-clock. + // If omitted or <= 0, the layer ticks whenever GameRenderer ticks. + getTickIntervalMs?: () => number; renderLayer?: (context: CanvasRenderingContext2D) => void; shouldTransform?: () => boolean; redraw?: () => void; diff --git a/src/client/graphics/layers/Leaderboard.ts b/src/client/graphics/layers/Leaderboard.ts index 5dd8793f3..19aec6643 100644 --- a/src/client/graphics/layers/Leaderboard.ts +++ b/src/client/graphics/layers/Leaderboard.ts @@ -55,12 +55,14 @@ export class Leaderboard extends LitElement implements Layer { init() {} + getTickIntervalMs() { + return 1000; + } + tick() { if (this.game === null) throw new Error("Not initialized"); if (!this.visible) return; - if (this.game.ticks() % 10 === 0) { - this.updateLeaderboard(); - } + this.updateLeaderboard(); } private setSort(key: "tiles" | "gold" | "maxtroops") { diff --git a/src/client/graphics/layers/MainRadialMenu.ts b/src/client/graphics/layers/MainRadialMenu.ts index c0b707577..989b5aa79 100644 --- a/src/client/graphics/layers/MainRadialMenu.ts +++ b/src/client/graphics/layers/MainRadialMenu.ts @@ -33,6 +33,10 @@ export class MainRadialMenu extends LitElement implements Layer { private clickedTile: TileRef | null = null; + getTickIntervalMs() { + return 500; + } + constructor( private eventBus: EventBus, private game: GameView, @@ -156,18 +160,16 @@ export class MainRadialMenu extends LitElement implements Layer { async tick() { if (!this.radialMenu.isMenuVisible() || this.clickedTile === null) return; - if (this.game.ticks() % 5 === 0) { - this.game - .myPlayer()! - .actions(this.clickedTile) - .then((actions) => { - this.updatePlayerActions( - this.game.myPlayer()!, - actions, - this.clickedTile!, - ); - }); - } + this.game + .myPlayer()! + .actions(this.clickedTile) + .then((actions) => { + this.updatePlayerActions( + this.game.myPlayer()!, + actions, + this.clickedTile!, + ); + }); } renderLayer(context: CanvasRenderingContext2D) { diff --git a/src/client/graphics/layers/NameLayer.ts b/src/client/graphics/layers/NameLayer.ts index 1c0b94a22..e23d4d609 100644 --- a/src/client/graphics/layers/NameLayer.ts +++ b/src/client/graphics/layers/NameLayer.ts @@ -133,11 +133,11 @@ export class NameLayer implements Layer { } } - public tick() { - if (this.game.ticks() % 10 !== 0) { - return; - } + getTickIntervalMs() { + return 1000; + } + public tick() { // Precompute the first-place player for performance this.firstPlace = getFirstPlacePlayer(this.game); diff --git a/src/client/graphics/layers/ReplayPanel.ts b/src/client/graphics/layers/ReplayPanel.ts index eb5cf4f1a..7b2b43eea 100644 --- a/src/client/graphics/layers/ReplayPanel.ts +++ b/src/client/graphics/layers/ReplayPanel.ts @@ -44,11 +44,13 @@ export class ReplayPanel extends LitElement implements Layer { } } + getTickIntervalMs() { + return 1000; + } + tick() { if (!this.visible) return; - if (this.game!.ticks() % 10 === 0) { - this.requestUpdate(); - } + this.requestUpdate(); } onReplaySpeedChange(value: ReplaySpeedMultiplier) { diff --git a/src/client/graphics/layers/TeamStats.ts b/src/client/graphics/layers/TeamStats.ts index 1841a0636..5cf322e12 100644 --- a/src/client/graphics/layers/TeamStats.ts +++ b/src/client/graphics/layers/TeamStats.ts @@ -42,6 +42,10 @@ export class TeamStats extends LitElement implements Layer { init() {} + getTickIntervalMs() { + return 1000; + } + tick() { if (this.game.config().gameConfig().gameMode !== GameMode.Team) return; @@ -52,9 +56,7 @@ export class TeamStats extends LitElement implements Layer { if (!this.visible) return; - if (this.game.ticks() % 10 === 0) { - this.updateTeamStats(); - } + this.updateTeamStats(); } private updateTeamStats() {