From e26da49a1cd9aee420f5bca82e5911ec97261de7 Mon Sep 17 00:00:00 2001 From: AroPix Date: Wed, 12 Aug 2026 10:13:44 +0200 Subject: [PATCH] perf: split TimerWidget static header from dynamic time display The TimerWidget's StaticHeader (icon, title, buttons, background) was being rebuilt and re-sent every 300ms during dynamic timer mode, even though only the countdown text changes between ticks. Split the widget into two manialink layers: - Header layer (this.id): rebuilt only on state changes (dynamic toggle, pause/resume, position change, map events) - Time layer (this.id + 4): rebuilt every 300ms tick This eliminates ~75-80% of the per-tick XML payload and avoids redundant StaticHeader.constructXml() + getButtonsXml() string building on every interval fire. --- .../race/TimerWidget.component.ts | 137 ++++++++++++------ 1 file changed, 94 insertions(+), 43 deletions(-) diff --git a/plugins/ui/static_components/race/TimerWidget.component.ts b/plugins/ui/static_components/race/TimerWidget.component.ts index f0da3847..eb62be37 100644 --- a/plugins/ui/static_components/race/TimerWidget.component.ts +++ b/plugins/ui/static_components/race/TimerWidget.component.ts @@ -10,8 +10,10 @@ export default class TimerWidget extends StaticComponent { private readonly header: StaticHeader private dynamicTimerInterval: NodeJS.Timeout | undefined - private noButtonXml: string = '' - private xmlWithButtons: string = '' + private headerXmlNoButtons: string = '' + private headerXmlWithButtons: string = '' + private timeXml: string = '' + private readonly timeWidgetId = this.id + 4 private readonly pauseButtonId = this.id + 1 private readonly addButtonid = this.id + 2 private readonly subtractButtonId = this.id + 3 @@ -21,8 +23,9 @@ export default class TimerWidget extends StaticComponent { constructor() { super(componentIds.timer) this.header = new StaticHeader('race') - this.noButtonXml = this.constructXml(false) - this.xmlWithButtons = this.constructXml(true) + this.headerXmlNoButtons = this.constructHeaderXml(false) + this.headerXmlWithButtons = this.constructHeaderXml(true) + this.timeXml = this.constructTimeXml() if (tm.timer.isDynamic) { this.startDynamicTimerInterval() } @@ -32,22 +35,25 @@ export default class TimerWidget extends StaticComponent { } else { clearInterval(this.dynamicTimerInterval) } - this.noButtonXml = this.constructXml(false) - this.xmlWithButtons = this.constructXml(true) + this.headerXmlNoButtons = this.constructHeaderXml(false) + this.headerXmlWithButtons = this.constructHeaderXml(true) + this.timeXml = this.constructTimeXml() return this.display() }) this.renderOnEvent('EndMap', (info) => { if (info.isRestart) { this.isOnRestart = true - this.noButtonXml = this.constructXml(false) - this.xmlWithButtons = this.constructXml(true) + this.headerXmlNoButtons = this.constructHeaderXml(false) + this.headerXmlWithButtons = this.constructHeaderXml(true) + this.timeXml = this.constructTimeXml() return this.display() } }) this.renderOnEvent('BeginMap', () => { this.isOnRestart = false - this.noButtonXml = this.constructXml(false) - this.xmlWithButtons = this.constructXml(true) + this.headerXmlNoButtons = this.constructHeaderXml(false) + this.headerXmlWithButtons = this.constructHeaderXml(true) + this.timeXml = this.constructTimeXml() return this.display() }) addManialinkListener(this.pauseButtonId, (info) => { @@ -67,6 +73,9 @@ export default class TimerWidget extends StaticComponent { tm.sendMessage(tm.utils.strVar(config.pause, strObject)) tm.timer.pause() } + this.headerXmlWithButtons = this.constructHeaderXml(true) + this.timeXml = this.constructTimeXml() + this.sendMultipleManialinks(this.display()) }) addManialinkListener(this.addButtonid, (info) => { if (info.privilege < config.timerActionsPrivilege) { return } @@ -81,6 +90,8 @@ export default class TimerWidget extends StaticComponent { time: tm.utils.getVerboseTime(tm.timer.remainingRaceTime) } tm.sendMessage(tm.utils.strVar(config.set, strObject)) + this.timeXml = this.constructTimeXml() + tm.sendManialink(this.timeXml) }) addManialinkListener(this.subtractButtonId, (info) => { if (info.privilege < config.timerActionsPrivilege) { return } @@ -96,6 +107,8 @@ export default class TimerWidget extends StaticComponent { time: tm.utils.getVerboseTime(tm.timer.remainingRaceTime) } tm.sendMessage(tm.utils.strVar(config.set, strObject)) + this.timeXml = this.constructTimeXml() + tm.sendManialink(this.timeXml) }) } @@ -113,9 +126,11 @@ export default class TimerWidget extends StaticComponent { private startDynamicTimerInterval() { clearInterval(this.dynamicTimerInterval) this.dynamicTimerInterval = setInterval(() => { - this.noButtonXml = this.constructXml(false) - this.xmlWithButtons = this.constructXml(true) - this.sendMultipleManialinks(this.display()) + if (!this.isDisplayed) { return } + if (this.isPaused && tm.timer.isPaused) { return } + this.timeXml = this.constructTimeXml() + tm.sendManialink(this.timeXml) + this.isPaused = tm.timer.isPaused }, 300) } @@ -125,9 +140,10 @@ export default class TimerWidget extends StaticComponent { // if (tm.getGameMode() === 'Stunts' && !tm.timer.isDynamic) { // return [this.hide()] // } - const arr = [] + const arr: (string | { xml: string, login: string })[] = [this.timeXml] for (const e of tm.players.list) { - arr.push(this.displayToPlayer(e.login, e.privilege)) + const ml = this.displayToPlayer(e.login, e.privilege) + if (ml !== undefined) { arr.push(ml) } } this.isPaused = tm.timer.isPaused return arr @@ -140,49 +156,42 @@ export default class TimerWidget extends StaticComponent { // } privilege ??= tm.players.get(login)?.privilege ?? 0 if (this.isOnRestart || !tm.timer.isDynamic || privilege < config.timerActionsPrivilege) { - return { xml: this.noButtonXml, login } + return { xml: this.headerXmlNoButtons, login } } else { - return { xml: this.xmlWithButtons, login } + return { xml: this.headerXmlWithButtons, login } } } + hide(): string { + return `` + } + + hideToPlayer(login: string): { xml: string, login: string } { + return { xml: ``, login } + } + protected onPositionChange(): void { - this.noButtonXml = this.constructXml(false) - this.xmlWithButtons = this.constructXml(true) + this.headerXmlNoButtons = this.constructHeaderXml(false) + this.headerXmlWithButtons = this.constructHeaderXml(true) + this.timeXml = this.constructTimeXml() this.sendMultipleManialinks(this.display()) } - private constructXml(isDynamic: boolean): string { + /** + * Constructs the header manialink (icon, title, buttons, background quad). + * Only rebuilt on state changes, not on every timer tick. + * @param isDynamic Whether to include dynamic timer buttons + * @returns Header manialink XML string + */ + private constructHeaderXml(isDynamic: boolean): string { const headerHeight: number = this.header.options.height let headerXml = isDynamic ? this.getButtonsXml() : this.header.constructXml(config.title, config.icon, this.side) - let timeXml = '' let bottomH = config.height - (headerHeight + config.margin) const isStunts = tm.getGameMode() === 'Stunts' if (!isDynamic && isStunts) { bottomH = config.stuntsHeight } - if (tm.timer.isDynamic && !this.isOnRestart) { - if (tm.timer.isPaused) { - timeXml = centeredText(config.pausedText, config.width, bottomH, - { specialFont: true, yOffset: -0.3, xOffset: 0.2 }) - } else { - const time = Math.floor(tm.timer.remainingRaceTime / 1000) - let timeColour = config.timeColours[0] - if (time < config.colourChangeThresholds[1]) { - timeColour = config.timeColours[2] - } else if (time < config.colourChangeThresholds[0]) { - timeColour = config.timeColours[1] - } - const hoursAmount = ~~(time / (60 * 60)) - const hours = hoursAmount === 0 ? '' : `${hoursAmount.toString()}:` - const minutes = (~~(time / 60) % 60).toString().padStart(2, '0') - const seconds = (time % 60).toString().padStart(2, '0') - const timeStr = hoursAmount < 100 ? `${hours}${minutes}:${seconds}` : `${hoursAmount} hours` - timeXml = centeredText('$' + timeColour + timeStr, config.width, bottomH, - { specialFont: true, yOffset: config.textYOffset }) - } - } let stuntsMargin = (isStunts && isDynamic) ? config.stuntsDynamicMarginTop : 0 if (isStunts && !isDynamic) { return ` @@ -203,6 +212,48 @@ export default class TimerWidget extends StaticComponent { + + + ` + } + + /** + * Constructs the time display manialink (just the countdown text). + * Uses a separate manialink ID so the header doesn't need to be re-sent on every tick. + * @returns Time manialink XML string + */ + private constructTimeXml(): string { + if (!tm.timer.isDynamic || this.isOnRestart) { + return `` + } + const headerHeight: number = this.header.options.height + const bottomH: number = config.height - (headerHeight + config.margin) + const isStunts = tm.getGameMode() === 'Stunts' + const stuntsMargin = isStunts ? config.stuntsDynamicMarginTop : 0 + let timeXml = '' + if (tm.timer.isPaused) { + timeXml = centeredText(config.pausedText, config.width, bottomH, + { specialFont: true, yOffset: -0.3, xOffset: 0.2 }) + } else { + const time = Math.floor(tm.timer.remainingRaceTime / 1000) + let timeColour = config.timeColours[0] + if (time < config.colourChangeThresholds[1]) { + timeColour = config.timeColours[2] + } else if (time < config.colourChangeThresholds[0]) { + timeColour = config.timeColours[1] + } + const hoursAmount = ~~(time / (60 * 60)) + const hours = hoursAmount === 0 ? '' : `${hoursAmount.toString()}:` + const minutes = (~~(time / 60) % 60).toString().padStart(2, '0') + const seconds = (time % 60).toString().padStart(2, '0') + const timeStr = hoursAmount < 100 ? `${hours}${minutes}:${seconds}` : `${hoursAmount} hours` + timeXml = centeredText('$' + timeColour + timeStr, config.width, bottomH, + { specialFont: true, yOffset: config.textYOffset }) + } + return ` + + + ${timeXml} @@ -241,4 +292,4 @@ export default class TimerWidget extends StaticComponent { this.side, { rectangleWidth: headerRectWidth }) + buttonXml } -} +} \ No newline at end of file