From 200735eac6b1fed3e8193fbe33be50cee3f88429 Mon Sep 17 00:00:00 2001 From: TheApplePieGod Date: Thu, 9 Jan 2025 22:58:00 -0500 Subject: [PATCH 01/12] Fix map editor toggle display --- client/src/components/sidebar/map-editor/map-editor-field.tsx | 1 + client/src/components/toggle.tsx | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/client/src/components/sidebar/map-editor/map-editor-field.tsx b/client/src/components/sidebar/map-editor/map-editor-field.tsx index c2c8b540..b6febd8d 100644 --- a/client/src/components/sidebar/map-editor/map-editor-field.tsx +++ b/client/src/components/sidebar/map-editor/map-editor-field.tsx @@ -46,6 +46,7 @@ export const MapEditorBrushRowField: React.FC = (props: Props) => { case MapEditorBrushFieldType.TEAM: field = ( onChange: (value: any) => void flipOnRightClickCanvas?: boolean } export const Toggle: React.FC = (props: Props) => { - const [value, setValue] = React.useState(Object.values(props.options)[0].value) + const [value, setValue] = React.useState(props.initialValue ?? Object.values(props.options)[0].value) const { canvasRightClick } = GameRenderer.useCanvasClickEvents() const onClick = (val: any) => { From 9cf5bd8576bbf3cfa29bce4a723666f2bc55c24c Mon Sep 17 00:00:00 2001 From: TheApplePieGod Date: Fri, 10 Jan 2025 00:24:34 -0500 Subject: [PATCH 02/12] Actions perf improvement --- client/src/app.tsx | 8 +++++++- client/src/playback/Actions.ts | 8 +++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/client/src/app.tsx b/client/src/app.tsx index ece2c9b4..52a5d5cd 100644 --- a/client/src/app.tsx +++ b/client/src/app.tsx @@ -8,4 +8,10 @@ import '../style.css' const elem = document.getElementById('root') const root = ReactDom.createRoot(elem!) -root.render() +root.render( + + + + + +) diff --git a/client/src/playback/Actions.ts b/client/src/playback/Actions.ts index 18e562d3..d264265e 100644 --- a/client/src/playback/Actions.ts +++ b/client/src/playback/Actions.ts @@ -40,7 +40,13 @@ export default class Actions { for (let i = 0; i < this.actions.length; i++) { this.actions[i].duration-- if (this.actions[i].duration == 0) { - this.actions.splice(i, 1) + // If action render order matters, use this (slower) + //this.actions.splice(i, 1) + + // Otherwise, this is faster + this.actions[i] = this.actions[this.actions.length - 1] + this.actions.pop() + i-- } } From 0fe911f2d869fdf92303437fcea61641f5992005 Mon Sep 17 00:00:00 2001 From: TheApplePieGod Date: Fri, 10 Jan 2025 01:16:56 -0500 Subject: [PATCH 03/12] Round seeking optimization --- client/src/playback/Match.ts | 10 +++++++++- client/src/playback/Round.ts | 3 ++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/client/src/playback/Match.ts b/client/src/playback/Match.ts index 4f3bbd35..a55f0127 100644 --- a/client/src/playback/Match.ts +++ b/client/src/playback/Match.ts @@ -10,7 +10,7 @@ import * as Profiler from './Profiler' import * as Timeline from './Timeline' // Amount of rounds before a snapshot of the game state is saved for the next recalculation -const SNAPSHOT_EVERY = 50 +const SNAPSHOT_EVERY = 40 // Amount of simulation steps before the round counter is progressed const MAX_SIMULATION_STEPS = 50000 @@ -315,6 +315,12 @@ export default class Match { ? this.currentRound : closestSnapshot.copy() + // While we are jumping (forward) to a round, mark the intermediate rounds + // as transient. This way, we do not store initial state for these rounds that + // we are simply passing through, as they will never have any sort of backward + // turn stepping. + updatingRound.isTransient = true + while (updatingRound.roundNumber < roundNumber) { // Fully apply the previous round by applying each turn sequentially updatingRound.jumpToTurn(updatingRound.turnsLength) @@ -330,6 +336,8 @@ export default class Match { } } + updatingRound.isTransient = false + this.currentRound = updatingRound } diff --git a/client/src/playback/Round.ts b/client/src/playback/Round.ts index b0769141..ad61161d 100644 --- a/client/src/playback/Round.ts +++ b/client/src/playback/Round.ts @@ -10,6 +10,7 @@ import assert from 'assert' export default class Round { public turnNumber: number = 0 public lastSteppedRobotId: number | undefined = undefined + public isTransient: boolean = false private initialRoundState: Round | null = null constructor( @@ -93,7 +94,7 @@ export default class Round { const turn = this.currentDelta!.turns(this.turnNumber) assert(turn, 'Turn not found to step to') - if (!this.initialRoundState) { + if (!this.initialRoundState && !this.isTransient) { // Store the initial round state for resetting only after stepping, that way snapshots dont need to store it, halving memory usage assert(this.turnNumber === 0, 'Initial round state should only be set at turn 0') this.initialRoundState = this.copy() From 32a77eb87c67bf1746b1badd217798d7023112f6 Mon Sep 17 00:00:00 2001 From: TheApplePieGod Date: Sat, 11 Jan 2025 14:24:36 -0500 Subject: [PATCH 04/12] Runner sorting/map resizing --- client/package-lock.json | 10 ++++ client/package.json | 1 + .../src/components/sidebar/runner/runner.tsx | 50 ++++++++++++------- 3 files changed, 44 insertions(+), 17 deletions(-) diff --git a/client/package-lock.json b/client/package-lock.json index 5d18e299..562d66f4 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -19,6 +19,7 @@ "pako": "^2.1.0", "path": "^0.12.7", "process": "^0.11.10", + "re-resizable": "^6.10.3", "react": "^18.2.0", "react-color": "^2.19.3", "react-custom-scrollbars-2": "^4.5.0", @@ -6756,6 +6757,15 @@ "node": ">=0.10.0" } }, + "node_modules/re-resizable": { + "version": "6.10.3", + "resolved": "https://registry.npmjs.org/re-resizable/-/re-resizable-6.10.3.tgz", + "integrity": "sha512-zvWb7X3RJMA4cuSrqoxgs3KR+D+pEXnGrD2FAD6BMYAULnZsSF4b7AOVyG6pC3VVNVOtlagGDCDmZSwWLjjBBw==", + "peerDependencies": { + "react": "^16.13.1 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.13.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/react": { "version": "18.2.0", "license": "MIT", diff --git a/client/package.json b/client/package.json index 1e44d6c8..1c44731f 100644 --- a/client/package.json +++ b/client/package.json @@ -138,6 +138,7 @@ "pako": "^2.1.0", "path": "^0.12.7", "process": "^0.11.10", + "re-resizable": "^6.10.3", "react": "^18.2.0", "react-color": "^2.19.3", "react-custom-scrollbars-2": "^4.5.0", diff --git a/client/src/components/sidebar/runner/runner.tsx b/client/src/components/sidebar/runner/runner.tsx index 33cbb1bc..9ac9834d 100644 --- a/client/src/components/sidebar/runner/runner.tsx +++ b/client/src/components/sidebar/runner/runner.tsx @@ -13,6 +13,7 @@ import { RingBuffer } from '../../../util/ring-buffer' import { ProfilerDialog } from './profiler' import { GameRenderer } from '../../../playback/GameRenderer' import GameRunner from '../../../playback/GameRunner' +import { Resizable } from 're-resizable' type RunnerPageProps = { open: boolean @@ -286,7 +287,7 @@ const TeamSelector: React.FC = ({ teamA, teamB, options, onCh onChangeB(e)}> {teamB === undefined && } - {[...options].map((t) => ( + {[...options].sort().map((t) => ( @@ -331,21 +332,36 @@ const MapSelector: React.FC = ({ maps, availableMaps, onSelect return (
-
- {[...availableMaps].map((m) => { - const selected = maps.has(m) - return ( -
(maps.has(m) ? onDeselect(m) : onSelect(m))} - > - {m} - -
- ) - })} -
+ +
+ {[...availableMaps].sort().map((m) => { + const selected = maps.has(m) + return ( +
(maps.has(m) ? onDeselect(m) : onSelect(m))} + > + {m} + +
+ ) + })} +
+
) } From 339fd252fa4b3029a3bc34815478c2decb887e4e Mon Sep 17 00:00:00 2001 From: TheApplePieGod Date: Sat, 11 Jan 2025 14:33:07 -0500 Subject: [PATCH 05/12] Jump to robot's turn when clicking the console --- client/src/components/sidebar/runner/runner.tsx | 5 +++++ client/src/playback/GameRunner.ts | 8 ++++++++ client/src/playback/Match.ts | 13 ++++++++++++- client/src/playback/Round.ts | 15 +++++++++++++++ 4 files changed, 40 insertions(+), 1 deletion(-) diff --git a/client/src/components/sidebar/runner/runner.tsx b/client/src/components/sidebar/runner/runner.tsx index 9ac9834d..309f3071 100644 --- a/client/src/components/sidebar/runner/runner.tsx +++ b/client/src/components/sidebar/runner/runner.tsx @@ -393,6 +393,11 @@ export const Console: React.FC = ({ lines }) => { setPopout(false) GameRunner.jumpToRound(round) GameRenderer.setSelectedRobot(id) + + // If turn playback is enabled, focus the robot's exact turn as well + if (GameRunner.match?.playbackPerTurn) { + GameRunner.jumpToRobotTurn(id) + } } const ConsoleRow = (props: { index: number; style: any }) => { diff --git a/client/src/playback/GameRunner.ts b/client/src/playback/GameRunner.ts index 513a00ee..44710f9a 100644 --- a/client/src/playback/GameRunner.ts +++ b/client/src/playback/GameRunner.ts @@ -172,6 +172,14 @@ class GameRunnerClass { this._trigger(this._turnListeners) } + jumpToRobotTurn(robotId: number) { + if (!this.match) return + // explicit rerender at the end so a render doesnt occur between these two steps + this.match._jumpToRobotTurn(robotId) + GameRenderer.render() + this._trigger(this._turnListeners) + } + jumpToStart() { if (!this.match) return // explicit rerender at the end so a render doesnt occur between these two steps diff --git a/client/src/playback/Match.ts b/client/src/playback/Match.ts index a55f0127..57ea2c3d 100644 --- a/client/src/playback/Match.ts +++ b/client/src/playback/Match.ts @@ -225,7 +225,7 @@ export default class Match { } /** - * Jump to a turn within the current round's turns.R + * Jump to a turn within the current round's turns. */ public _jumpToTurn(turn: number): void { if (!this.game.playable) return @@ -235,6 +235,17 @@ export default class Match { this.currentRound.jumpToTurn(turn) } + /** + * Jump to the turn of a specific robot within the current round's turns, if it exists. + */ + public _jumpToRobotTurn(robotId: number): void { + if (!this.game.playable) return + + this._roundSimulation() + + this.currentRound.jumpToRobotTurn(robotId) + } + private _updateSimulationRoundsByTime(deltaTime: number): void { // This works because of the way floor works const deltaRounds = Math.floor(this._currentSimulationStep / MAX_SIMULATION_STEPS) diff --git a/client/src/playback/Round.ts b/client/src/playback/Round.ts index ad61161d..44232237 100644 --- a/client/src/playback/Round.ts +++ b/client/src/playback/Round.ts @@ -85,6 +85,21 @@ export default class Round { } } + /** + * Jumps to a specific robot's turn in the round, if it exists. + */ + public jumpToRobotTurn(robotId: number): void { + if (!this.currentDelta) return // Final round does not have a delta, so there is nothing to jump to + + for (let i = 0; i < this.currentDelta.turnsLength(); i++) { + const turn = this.currentDelta.turns(i)! + if (turn.robotId() === robotId) { + this.jumpToTurn(i + 1) + return + } + } + } + /** * Step the current turn within the current delta. */ From e683cf5f79e945f5ad381befa082c294f48431d1 Mon Sep 17 00:00:00 2001 From: TheApplePieGod Date: Sat, 11 Jan 2025 14:34:52 -0500 Subject: [PATCH 06/12] Bump version --- client/src/constants.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/constants.ts b/client/src/constants.ts index 49f12997..06cba4f0 100644 --- a/client/src/constants.ts +++ b/client/src/constants.ts @@ -1,4 +1,4 @@ -export const CLIENT_VERSION = '1.4.0' +export const CLIENT_VERSION = '1.4.1' export const SPEC_VERSION = '1' export const BATTLECODE_YEAR: number = 2025 export const MAP_SIZE_RANGE = { From 3d5126cf883bf2f12d91171b9b747830f0b026ac Mon Sep 17 00:00:00 2001 From: TheApplePieGod Date: Sat, 11 Jan 2025 14:42:52 -0500 Subject: [PATCH 07/12] Preserve whitespace in runner console --- client/src/components/sidebar/runner/runner.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/client/src/components/sidebar/runner/runner.tsx b/client/src/components/sidebar/runner/runner.tsx index 309f3071..5396c480 100644 --- a/client/src/components/sidebar/runner/runner.tsx +++ b/client/src/components/sidebar/runner/runner.tsx @@ -411,7 +411,7 @@ export const Console: React.FC = ({ lines }) => { const team = found[1] const id = Number(found[2]) const round = Number(found[3]) - const ogText = found[4] + const ogText = found[4].replace(/\n/g, ' ') return (
@@ -421,9 +421,7 @@ export const Console: React.FC = ({ lines }) => { > {`[Team ${team}, ID #${id}, Round ${round}]`} - - {ogText} - + {ogText}
) } From c496c40c3804984a471089f32a06b22f8eb1265b Mon Sep 17 00:00:00 2001 From: TheApplePieGod Date: Sat, 11 Jan 2025 14:53:07 -0500 Subject: [PATCH 08/12] Formatting fixes --- client/src/components/sidebar/runner/runner.tsx | 12 +++++++++--- client/src/components/sidebar/sidebar.tsx | 2 +- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/client/src/components/sidebar/runner/runner.tsx b/client/src/components/sidebar/runner/runner.tsx index 5396c480..0a0ee179 100644 --- a/client/src/components/sidebar/runner/runner.tsx +++ b/client/src/components/sidebar/runner/runner.tsx @@ -92,14 +92,20 @@ export const RunnerPage: React.FC = ({ open, scaffold }) => { const MemoConsole = React.useMemo(() => , [consoleLines.effectiveLength()]) - if (!open) return null + if (open && !nativeAPI) return <>Run the client locally to use the runner - if (!nativeAPI) return <>Run the client locally to use the runner + /* Keep the component mounted but hidden when !open so we retain any locally resized/edited elements */ const lastLogLine = consoleLines.get(consoleLines.length() - 1) const runDisabled = !teamA || !teamB || maps.size === 0 || !langVersion return ( -
+
{!setup ? ( <> {error &&
{`Setup Error: ${error}`}
} diff --git a/client/src/components/sidebar/sidebar.tsx b/client/src/components/sidebar/sidebar.tsx index 077794df..4f261a05 100644 --- a/client/src/components/sidebar/sidebar.tsx +++ b/client/src/components/sidebar/sidebar.tsx @@ -213,7 +213,7 @@ export const Sidebar: React.FC = () => {
)} -
+
From caeeaacf221792f5428511f30e35415cd59b5112 Mon Sep 17 00:00:00 2001 From: TheApplePieGod Date: Sat, 11 Jan 2025 15:32:44 -0500 Subject: [PATCH 09/12] Improve perf of panning on big maps --- client/src/playback/Bodies.ts | 137 ++++++++++------------------ client/src/playback/GameRenderer.ts | 13 ++- client/src/playback/Vector.ts | 1 + 3 files changed, 60 insertions(+), 91 deletions(-) diff --git a/client/src/playback/Bodies.ts b/client/src/playback/Bodies.ts index 8dcf63e0..4d6937cf 100644 --- a/client/src/playback/Bodies.ts +++ b/client/src/playback/Bodies.ts @@ -5,7 +5,7 @@ import Round from './Round' import * as renderUtils from '../util/RenderUtil' import { MapEditorBrush } from '../components/sidebar/map-editor/MapEditorBrush' import { StaticMap } from './Map' -import { Vector } from './Vector' +import { Vector, vectorDistSquared, vectorEq } from './Vector' import { Colors, currentColors } from '../colors' import { INDICATOR_DOT_SIZE, @@ -145,21 +145,22 @@ export default class Bodies { draw( match: Match, - ctx: CanvasRenderingContext2D, - overlayCtx: CanvasRenderingContext2D, + bodyCtx: CanvasRenderingContext2D | null, + overlayCtx: CanvasRenderingContext2D | null, config: ClientConfig, selectedBodyID?: number, hoveredTile?: Vector ): void { for (const body of this.bodies.values()) { - body.draw( - match, - ctx, - overlayCtx, - config, - body.id === selectedBodyID, - body.pos.x === hoveredTile?.x && body.pos.y === hoveredTile?.y - ) + if (bodyCtx) { + body.draw(match, bodyCtx) + } + + const selected = selectedBodyID === body.id + const hovered = !!hoveredTile && vectorEq(body.pos, hoveredTile) + if (overlayCtx) { + body.drawOverlay(match, overlayCtx, config, selected, hovered) + } } } @@ -245,40 +246,41 @@ export class Body { return this.game.robotTypeMetadata.get(this.robotType) ?? assert.fail('Robot missing metadata!') } - public draw( + public drawOverlay( match: Match, ctx: CanvasRenderingContext2D, - overlayCtx: CanvasRenderingContext2D, config: ClientConfig, selected: boolean, hovered: boolean ): void { + if (!this.game.playable) return + + // Draw various statuses + const focused = selected || hovered + if (focused) { + this.drawPath(match, ctx) + } + if (focused || config.showAllRobotRadii) { + this.drawRadii(match, ctx, !selected) + } + if (focused || config.showAllIndicators) { + this.drawIndicators(match, ctx, !selected && !config.showAllIndicators) + } + if (focused || config.showHealthBars) { + this.drawHealthBar(match, ctx) + } + if (focused || config.showPaintBars) { + this.drawPaintBar(match, ctx, focused || config.showHealthBars) + } + } + + public draw(match: Match, ctx: CanvasRenderingContext2D): void { const pos = this.getInterpolatedCoords(match) const renderCoords = renderUtils.getRenderCoords(pos.x, pos.y, match.currentRound.map.staticMap.dimension) if (this.dead) ctx.globalAlpha = 0.5 renderUtils.renderCenteredImageOrLoadingIndicator(ctx, getImageIfLoaded(this.imgPath), renderCoords, this.size) ctx.globalAlpha = 1 - - // Draw various statuses - const focused = selected || hovered - if (this.game.playable) { - if (focused) { - this.drawPath(match, overlayCtx) - } - if (focused || config.showAllRobotRadii) { - this.drawRadii(match, overlayCtx, !selected) - } - if (focused || config.showAllIndicators) { - this.drawIndicators(match, overlayCtx, !selected && !config.showAllIndicators) - } - if (focused || config.showHealthBars) { - this.drawHealthBar(match, overlayCtx) - } - if (focused || config.showPaintBars) { - this.drawPaintBar(match, overlayCtx, focused || config.showHealthBars) - } - } } private drawPath(match: Match, ctx: CanvasRenderingContext2D) { @@ -570,16 +572,8 @@ export const BODY_DEFINITIONS: Record = { this.size = 1.5 } - public draw( - match: Match, - ctx: CanvasRenderingContext2D, - overlayCtx: CanvasRenderingContext2D, - config: ClientConfig, - selected: boolean, - hovered: boolean - ): void { - super.draw(match, ctx, overlayCtx, config, selected, hovered) - super.drawLevel(match, ctx) + public draw(match: Match, ctx: CanvasRenderingContext2D): void { + super.draw(match, ctx) } }, @@ -594,16 +588,8 @@ export const BODY_DEFINITIONS: Record = { this.size = 1.5 } - public draw( - match: Match, - ctx: CanvasRenderingContext2D, - overlayCtx: CanvasRenderingContext2D, - config: ClientConfig, - selected: boolean, - hovered: boolean - ): void { - super.draw(match, ctx, overlayCtx, config, selected, hovered) - super.drawLevel(match, ctx) + public draw(match: Match, ctx: CanvasRenderingContext2D): void { + super.draw(match, ctx) } }, @@ -618,16 +604,8 @@ export const BODY_DEFINITIONS: Record = { this.size = 1.5 } - public draw( - match: Match, - ctx: CanvasRenderingContext2D, - overlayCtx: CanvasRenderingContext2D, - config: ClientConfig, - selected: boolean, - hovered: boolean - ): void { - super.draw(match, ctx, overlayCtx, config, selected, hovered) - super.drawLevel(match, ctx) + public draw(match: Match, ctx: CanvasRenderingContext2D): void { + super.draw(match, ctx) } }, @@ -641,15 +619,8 @@ export const BODY_DEFINITIONS: Record = { this.imgPath = `robots/${this.team.colorName.toLowerCase()}/mopper_64x64.png` } - public draw( - match: Match, - ctx: CanvasRenderingContext2D, - overlayCtx: CanvasRenderingContext2D, - config: ClientConfig, - selected: boolean, - hovered: boolean - ): void { - super.draw(match, ctx, overlayCtx, config, selected, hovered) + public draw(match: Match, ctx: CanvasRenderingContext2D): void { + super.draw(match, ctx) } }, @@ -663,15 +634,8 @@ export const BODY_DEFINITIONS: Record = { this.imgPath = `robots/${this.team.colorName.toLowerCase()}/soldier_64x64.png` } - public draw( - match: Match, - ctx: CanvasRenderingContext2D, - overlayCtx: CanvasRenderingContext2D, - config: ClientConfig, - selected: boolean, - hovered: boolean - ): void { - super.draw(match, ctx, overlayCtx, config, selected, hovered) + public draw(match: Match, ctx: CanvasRenderingContext2D): void { + super.draw(match, ctx) } }, @@ -685,15 +649,8 @@ export const BODY_DEFINITIONS: Record = { this.imgPath = `robots/${this.team.colorName.toLowerCase()}/splasher_64x64.png` } - public draw( - match: Match, - ctx: CanvasRenderingContext2D, - overlayCtx: CanvasRenderingContext2D, - config: ClientConfig, - selected: boolean, - hovered: boolean - ): void { - super.draw(match, ctx, overlayCtx, config, selected, hovered) + public draw(match: Match, ctx: CanvasRenderingContext2D): void { + super.draw(match, ctx) } } } diff --git a/client/src/playback/GameRenderer.ts b/client/src/playback/GameRenderer.ts index 355f30e9..847cefb7 100644 --- a/client/src/playback/GameRenderer.ts +++ b/client/src/playback/GameRenderer.ts @@ -94,6 +94,17 @@ class GameRendererClass { return this.canvas(layer).getContext('2d') } + renderOverlay() { + const ctx = this.ctx(CanvasLayers.Overlay) + const match = GameRunner.match + if (!match || !ctx) return + + const currentRound = match.currentRound + + ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height) + currentRound.bodies.draw(match, null, ctx, GameConfig.config, this.selectedBodyID, this.mouseTile) + } + render() { const ctx = this.ctx(CanvasLayers.Dynamic) const overlayCtx = this.ctx(CanvasLayers.Overlay) @@ -158,7 +169,7 @@ class GameRendererClass { if (!newTile) return if (newTile.x !== this.mouseTile?.x || newTile.y !== this.mouseTile?.y) { this.mouseTile = newTile - this.render() + this.renderOverlay() this._trigger(this._canvasHoverListeners) } } diff --git a/client/src/playback/Vector.ts b/client/src/playback/Vector.ts index a15de51d..c898c578 100644 --- a/client/src/playback/Vector.ts +++ b/client/src/playback/Vector.ts @@ -6,6 +6,7 @@ export interface Vector { export const getEmptyVector = () => ({ x: 0, y: 0 }) as Vector export const vectorLength = (a: Vector) => Math.sqrt(a.x * a.x + a.y * a.y) export const vectorLengthSquared = (a: Vector) => a.x * a.x + a.y * a.y +export const vectorEq = (a: Vector, b: Vector) => a.x === b.x && a.y === b.y export const vectorDot = (a: Vector, b: Vector) => a.x * b.x + a.y * b.y export const vectorAdd = (a: Vector, b: Vector) => ({ x: a.x + b.x, y: a.y + b.y }) export const vectorSub = (a: Vector, b: Vector) => ({ x: a.x - b.x, y: a.y - b.y }) From b467be8415fd2b79b398b17c8afe0b9f24c4ec41 Mon Sep 17 00:00:00 2001 From: TheApplePieGod Date: Sat, 11 Jan 2025 16:13:37 -0500 Subject: [PATCH 10/12] Graph visual fixes & perf improvement --- .../sidebar/game/quick-line-chart.tsx | 14 ++++-- .../sidebar/game/resource-graph.tsx | 43 ++++++++++--------- 2 files changed, 33 insertions(+), 24 deletions(-) diff --git a/client/src/components/sidebar/game/quick-line-chart.tsx b/client/src/components/sidebar/game/quick-line-chart.tsx index 6948c058..58d6aa7b 100644 --- a/client/src/components/sidebar/game/quick-line-chart.tsx +++ b/client/src/components/sidebar/game/quick-line-chart.tsx @@ -40,8 +40,14 @@ export const QuickLineChart: React.FC = ({ setCanvasResolution(canvas, width, height, resolution) - const max = Math.max(...data.map((d) => Math.max(d.team0, d.team1))) - const { xScale, yScale, innerWidth, innerHeight } = getAxes(width, height, margin, { x: data.length, y: max }) + let maxX = -9999999 + let maxY = -9999999 + for (const d of data) { + maxX = Math.max(maxX, d.round) + maxY = Math.max(maxY, Math.max(d.team0, d.team1)) + } + + const { xScale, yScale, innerWidth, innerHeight } = getAxes(width, height, margin, { x: maxX, y: maxY }) context.clearRect(0, 0, width, height) @@ -65,11 +71,11 @@ export const QuickLineChart: React.FC = ({ height, margin, { - range: { min: 0, max: data.length }, + range: { min: 0, max: maxX }, options: { textColor: 'white', lineColor: 'white' } }, { - range: { min: 0, max: max }, + range: { min: 0, max: maxY }, options: { textColor: 'white', lineColor: 'white' } } ) diff --git a/client/src/components/sidebar/game/resource-graph.tsx b/client/src/components/sidebar/game/resource-graph.tsx index 3dece35c..35f44399 100644 --- a/client/src/components/sidebar/game/resource-graph.tsx +++ b/client/src/components/sidebar/game/resource-graph.tsx @@ -1,34 +1,37 @@ import React from 'react' -import assert from 'assert' import { useRound } from '../../../playback/GameRunner' import Round from '../../../playback/Round' import { LineChartDataPoint, QuickLineChart } from './quick-line-chart' +import { TeamRoundStat } from '../../../playback/RoundStat' interface Props { active: boolean - property: string + property: keyof TeamRoundStat propertyDisplayName: string } -function hasKey(obj: O, key: PropertyKey): key is keyof O { - return key in obj -} -function getChartData(round: Round, property: string): LineChartDataPoint[] { - const values = [0, 1].map((index) => - round.match.stats.map((roundStat) => { - const teamStat = roundStat.getTeamStat(round.match.game.teams[index]) - assert(hasKey(teamStat, property), `TeamStat missing property '${property}' when rendering chart`) - return teamStat[property] +function getChartData(round: Round, property: keyof TeamRoundStat): LineChartDataPoint[] { + const teams = round.match.game.teams + + const result: LineChartDataPoint[] = [] + + // Sparser graph as datapoints increase + const interval = Math.ceil(round.roundNumber / 500) + + for (let i = 0; i < round.roundNumber; i += interval) { + const roundStat = round.match.stats[i] + + const team0Stat = roundStat.getTeamStat(teams[0]) + const team1Stat = roundStat.getTeamStat(teams[1]) + + result.push({ + round: i + 1, + team0: team0Stat[property] as number, + team1: team1Stat[property] as number }) - ) + } - return values[0].slice(0, round.roundNumber).map((value, index) => { - return { - round: index + 1, - team0: value as number, - team1: values[1][index] as number - } - }) + return result } export const ResourceGraph: React.FC = (props: Props) => { @@ -38,7 +41,7 @@ export const ResourceGraph: React.FC = (props: Props) => { return (

{props.propertyDisplayName}

- +
) } From 43e8db1cf710967b8a061205ad2cb2bc88686efe Mon Sep 17 00:00:00 2001 From: TheApplePieGod Date: Sat, 11 Jan 2025 16:25:00 -0500 Subject: [PATCH 11/12] Add paint graph --- client/src/components/sidebar/game/game.tsx | 2 ++ client/src/components/sidebar/game/team-table.tsx | 11 ++++++++++- client/src/playback/RoundStat.ts | 9 ++++++++- 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/client/src/components/sidebar/game/game.tsx b/client/src/components/sidebar/game/game.tsx index fce0fe32..20892242 100644 --- a/client/src/components/sidebar/game/game.tsx +++ b/client/src/components/sidebar/game/game.tsx @@ -122,6 +122,8 @@ export const GamePage: React.FC = React.memo((props) => {
+
+
) : (
Select a game to see stats
diff --git a/client/src/components/sidebar/game/team-table.tsx b/client/src/components/sidebar/game/team-table.tsx index 4f5b5dfd..bc7aca68 100644 --- a/client/src/components/sidebar/game/team-table.tsx +++ b/client/src/components/sidebar/game/team-table.tsx @@ -110,7 +110,10 @@ export const UnitsTable: React.FC = ({ teamStat, teamIdx }) => ['Mopper', ] ] - let data: [string, number[]][] = [['Count', [0, 0, 0, 0, 0, 0]]] + let data: [string, number[]][] = [ + ['Count', [0, 0, 0, 0, 0, 0]], + ['Paint', [0, 0, 0, 0, 0, 0]] + ] if (teamStat) { data = [ [ @@ -118,6 +121,12 @@ export const UnitsTable: React.FC = ({ teamStat, teamIdx }) => Object.values(schema.RobotType) .filter((k) => typeof k === 'number' && k !== schema.RobotType.NONE) .map((k) => teamStat.robotCounts[k as schema.RobotType]) + ], + [ + 'Paint', + Object.values(schema.RobotType) + .filter((k) => typeof k === 'number' && k !== schema.RobotType.NONE) + .map((k) => teamStat.robotPaints[k as schema.RobotType]) ] ] } diff --git a/client/src/playback/RoundStat.ts b/client/src/playback/RoundStat.ts index 6a18981d..14b0d56f 100644 --- a/client/src/playback/RoundStat.ts +++ b/client/src/playback/RoundStat.ts @@ -15,7 +15,9 @@ const EMPTY_ROBOT_COUNTS: Record = { export class TeamRoundStat { robotCounts: Record = { ...EMPTY_ROBOT_COUNTS } + robotPaints: Record = { ...EMPTY_ROBOT_COUNTS } moneyAmount: number = 0 + totalPaint: number = 0 paintPercent: number = 0 resourcePatterns: number = 0 @@ -24,6 +26,7 @@ export class TeamRoundStat { // Copy any internal objects here newStat.robotCounts = { ...this.robotCounts } + newStat.robotPaints = { ...this.robotPaints } return newStat } @@ -98,9 +101,11 @@ export default class RoundStat { } } - // Clear robot counts for recomputing + // Clear values for recomputing for (const stat of this.teams.values()) { + stat.totalPaint = 0 stat.robotCounts = { ...EMPTY_ROBOT_COUNTS } + stat.robotPaints = { ...EMPTY_ROBOT_COUNTS } } // Compute total robot counts @@ -111,6 +116,8 @@ export default class RoundStat { if (body.dead) continue teamStat.robotCounts[body.robotType]++ + teamStat.robotPaints[body.robotType] += body.paint + teamStat.totalPaint += body.paint } const timems = Date.now() - time From 32a36a11116512794b552e1659b9829be2436e65 Mon Sep 17 00:00:00 2001 From: TheApplePieGod Date: Sat, 11 Jan 2025 16:31:36 -0500 Subject: [PATCH 12/12] Update specs --- specs/specs.pdf | Bin 423457 -> 424495 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/specs/specs.pdf b/specs/specs.pdf index dcd204d0b22bfb4b44e3a6d8790bc5676a137d1d..f8d634e3aa3f7209f927f2b17bc1b4118093bacc 100644 GIT binary patch delta 10122 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 Vn3o261|ADCHa9a0B_%~qMhdgwgu?&;