Skip to content

Commit 0bfad91

Browse files
authored
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
1 parent de37943 commit 0bfad91

File tree

9 files changed

+90
-46
lines changed

9 files changed

+90
-46
lines changed

src/client/graphics/GameRenderer.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,7 @@ export function createRenderer(
305305

306306
export class GameRenderer {
307307
private context: CanvasRenderingContext2D;
308+
private layerTickState = new Map<Layer, { lastTickAtMs: number }>();
308309

309310
constructor(
310311
private game: GameView,
@@ -416,7 +417,28 @@ export class GameRenderer {
416417
}
417418

418419
tick() {
419-
this.layers.forEach((l) => l.tick?.());
420+
const nowMs = performance.now();
421+
422+
for (const layer of this.layers) {
423+
if (!layer.tick) {
424+
continue;
425+
}
426+
427+
const state = this.layerTickState.get(layer) ?? {
428+
lastTickAtMs: -Infinity,
429+
};
430+
431+
const intervalMs = layer.getTickIntervalMs?.() ?? 0;
432+
if (intervalMs > 0 && nowMs - state.lastTickAtMs < intervalMs) {
433+
this.layerTickState.set(layer, state);
434+
continue;
435+
}
436+
437+
state.lastTickAtMs = nowMs;
438+
this.layerTickState.set(layer, state);
439+
440+
layer.tick();
441+
}
420442
}
421443

422444
resize(width: number, height: number): void {

src/client/graphics/layers/ControlPanel.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ export class ControlPanel extends LitElement implements Layer {
3939

4040
private _lastTroopIncreaseRate: number;
4141

42+
getTickIntervalMs() {
43+
return 100;
44+
}
45+
4246
init() {
4347
this.attackRatio = Number(
4448
localStorage.getItem("settings.attackRatio") ?? "0.2",
@@ -81,9 +85,7 @@ export class ControlPanel extends LitElement implements Layer {
8185
return;
8286
}
8387

84-
if (this.game.ticks() % 5 === 0) {
85-
this.updateTroopIncrease();
86-
}
88+
this.updateTroopIncrease();
8789

8890
this._maxTroops = this.game.config().maxTroops(player);
8991
this._gold = player.gold();

src/client/graphics/layers/GameRightSidebar.ts

Lines changed: 26 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,9 @@ import { html, LitElement } from "lit";
22
import { customElement, state } from "lit/decorators.js";
33
import { EventBus } from "../../../core/EventBus";
44
import { GameType } from "../../../core/game/Game";
5-
import { GameUpdateType } from "../../../core/game/GameUpdates";
65
import { GameView } from "../../../core/game/GameView";
76
import { crazyGamesSDK } from "../../CrazyGamesSDK";
8-
import { PauseGameIntentEvent } from "../../Transport";
7+
import { PauseGameIntentEvent, SendWinnerEvent } from "../../Transport";
98
import { translateText } from "../../Utils";
109
import { Layer } from "./Layer";
1110
import { ShowReplayPanelEvent } from "./ReplayPanel";
@@ -50,35 +49,45 @@ export class GameRightSidebar extends LitElement implements Layer {
5049
this._isVisible = true;
5150
this.game.inSpawnPhase();
5251

52+
this.eventBus.on(SendWinnerEvent, () => {
53+
this.hasWinner = true;
54+
this.requestUpdate();
55+
});
56+
5357
this.requestUpdate();
5458
}
5559

60+
getTickIntervalMs() {
61+
return 250;
62+
}
63+
5664
tick() {
5765
// Timer logic
58-
const updates = this.game.updatesSinceLastTick();
59-
if (updates) {
60-
this.hasWinner = this.hasWinner || updates[GameUpdateType.Win].length > 0;
61-
}
62-
6366
// Check if the player is the lobby creator
6467
if (!this.isLobbyCreator && this.game.myPlayer()?.isLobbyCreator()) {
6568
this.isLobbyCreator = true;
6669
this.requestUpdate();
6770
}
6871

6972
const maxTimerValue = this.game.config().gameConfig().maxTimerValue;
73+
const spawnPhaseTurns = this.game.config().numSpawnPhaseTurns();
74+
const ticks = this.game.ticks();
75+
const gameTicks = Math.max(0, ticks - spawnPhaseTurns);
76+
const elapsedSeconds = Math.floor(gameTicks / 10); // 10 ticks per second
77+
78+
if (this.game.inSpawnPhase()) {
79+
this.timer = maxTimerValue !== undefined ? maxTimerValue * 60 : 0;
80+
return;
81+
}
82+
83+
if (this.hasWinner) {
84+
return;
85+
}
86+
7087
if (maxTimerValue !== undefined) {
71-
if (this.game.inSpawnPhase()) {
72-
this.timer = maxTimerValue * 60;
73-
} else if (!this.hasWinner && this.game.ticks() % 10 === 0) {
74-
this.timer = Math.max(0, this.timer - 1);
75-
}
88+
this.timer = Math.max(0, maxTimerValue * 60 - elapsedSeconds);
7689
} else {
77-
if (this.game.inSpawnPhase()) {
78-
this.timer = 0;
79-
} else if (!this.hasWinner && this.game.ticks() % 10 === 0) {
80-
this.timer++;
81-
}
90+
this.timer = elapsedSeconds;
8291
}
8392
}
8493

src/client/graphics/layers/Layer.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
export interface Layer {
22
init?: () => void;
33
tick?: () => void;
4+
// Optional hint to throttle expensive ticks by wall-clock.
5+
// If omitted or <= 0, the layer ticks whenever GameRenderer ticks.
6+
getTickIntervalMs?: () => number;
47
renderLayer?: (context: CanvasRenderingContext2D) => void;
58
shouldTransform?: () => boolean;
69
redraw?: () => void;

src/client/graphics/layers/Leaderboard.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,12 +55,14 @@ export class Leaderboard extends LitElement implements Layer {
5555

5656
init() {}
5757

58+
getTickIntervalMs() {
59+
return 1000;
60+
}
61+
5862
tick() {
5963
if (this.game === null) throw new Error("Not initialized");
6064
if (!this.visible) return;
61-
if (this.game.ticks() % 10 === 0) {
62-
this.updateLeaderboard();
63-
}
65+
this.updateLeaderboard();
6466
}
6567

6668
private setSort(key: "tiles" | "gold" | "maxtroops") {

src/client/graphics/layers/MainRadialMenu.ts

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ export class MainRadialMenu extends LitElement implements Layer {
3333

3434
private clickedTile: TileRef | null = null;
3535

36+
getTickIntervalMs() {
37+
return 500;
38+
}
39+
3640
constructor(
3741
private eventBus: EventBus,
3842
private game: GameView,
@@ -156,18 +160,16 @@ export class MainRadialMenu extends LitElement implements Layer {
156160

157161
async tick() {
158162
if (!this.radialMenu.isMenuVisible() || this.clickedTile === null) return;
159-
if (this.game.ticks() % 5 === 0) {
160-
this.game
161-
.myPlayer()!
162-
.actions(this.clickedTile)
163-
.then((actions) => {
164-
this.updatePlayerActions(
165-
this.game.myPlayer()!,
166-
actions,
167-
this.clickedTile!,
168-
);
169-
});
170-
}
163+
this.game
164+
.myPlayer()!
165+
.actions(this.clickedTile)
166+
.then((actions) => {
167+
this.updatePlayerActions(
168+
this.game.myPlayer()!,
169+
actions,
170+
this.clickedTile!,
171+
);
172+
});
171173
}
172174

173175
renderLayer(context: CanvasRenderingContext2D) {

src/client/graphics/layers/NameLayer.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -133,11 +133,11 @@ export class NameLayer implements Layer {
133133
}
134134
}
135135

136-
public tick() {
137-
if (this.game.ticks() % 10 !== 0) {
138-
return;
139-
}
136+
getTickIntervalMs() {
137+
return 1000;
138+
}
140139

140+
public tick() {
141141
// Precompute the first-place player for performance
142142
this.firstPlace = getFirstPlacePlayer(this.game);
143143

src/client/graphics/layers/ReplayPanel.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,13 @@ export class ReplayPanel extends LitElement implements Layer {
4444
}
4545
}
4646

47+
getTickIntervalMs() {
48+
return 1000;
49+
}
50+
4751
tick() {
4852
if (!this.visible) return;
49-
if (this.game!.ticks() % 10 === 0) {
50-
this.requestUpdate();
51-
}
53+
this.requestUpdate();
5254
}
5355

5456
onReplaySpeedChange(value: ReplaySpeedMultiplier) {

src/client/graphics/layers/TeamStats.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@ export class TeamStats extends LitElement implements Layer {
4242

4343
init() {}
4444

45+
getTickIntervalMs() {
46+
return 1000;
47+
}
48+
4549
tick() {
4650
if (this.game.config().gameConfig().gameMode !== GameMode.Team) return;
4751

@@ -52,9 +56,7 @@ export class TeamStats extends LitElement implements Layer {
5256

5357
if (!this.visible) return;
5458

55-
if (this.game.ticks() % 10 === 0) {
56-
this.updateTeamStats();
57-
}
59+
this.updateTeamStats();
5860
}
5961

6062
private updateTeamStats() {

0 commit comments

Comments
 (0)