perf(ui): switch UI layers to wall-time tick intervals (#3025)

## 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
This commit is contained in:
scamiv
2026-01-26 05:14:55 +01:00
committed by GitHub
parent de3794313d
commit 0bfad91c04
9 changed files with 90 additions and 46 deletions
+23 -1
View File
@@ -305,6 +305,7 @@ export function createRenderer(
export class GameRenderer {
private context: CanvasRenderingContext2D;
private layerTickState = new Map<Layer, { lastTickAtMs: number }>();
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 {
+5 -3
View File
@@ -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();
+26 -17
View File
@@ -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;
}
}
+3
View File
@@ -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;
+5 -3
View File
@@ -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") {
+14 -12
View File
@@ -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) {
+4 -4
View File
@@ -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);
+5 -3
View File
@@ -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) {
+5 -3
View File
@@ -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() {