diff --git a/client/src/components/toggle.tsx b/client/src/components/toggle.tsx
index d5d4da21..384ae6a0 100644
--- a/client/src/components/toggle.tsx
+++ b/client/src/components/toggle.tsx
@@ -8,13 +8,14 @@ interface OptionProp {
}
interface Props {
+ initialValue?: string
options: Record
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) => {
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 = {
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--
}
}
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/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 4f3bbd35..57ea2c3d 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
@@ -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)
@@ -315,6 +326,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 +347,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..44232237 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(
@@ -84,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.
*/
@@ -93,7 +109,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()
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
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 })
diff --git a/specs/specs.pdf b/specs/specs.pdf
index dcd204d0..f8d634e3 100644
Binary files a/specs/specs.pdf and b/specs/specs.pdf differ