From 5909ce75431ed3e6c0eccf846ff06fddf0c18770 Mon Sep 17 00:00:00 2001 From: Aidan Blum Levine Date: Tue, 10 Sep 2024 20:04:56 -0400 Subject: [PATCH 1/5] basics --- client/src/app-context.tsx | 4 - .../controls-bar/controls-bar-timeline.tsx | 14 +-- .../components/controls-bar/controls-bar.tsx | 31 +++---- client/src/components/game/game-area.tsx | 4 +- client/src/components/game/game-renderer.tsx | 33 ++++--- client/src/components/game/tooltip.tsx | 27 ++---- client/src/components/sidebar/game/game.tsx | 30 +++---- .../src/components/sidebar/game/histogram.tsx | 20 ++--- .../sidebar/game/resource-graph.tsx | 28 ++---- .../components/sidebar/game/team-table.tsx | 12 +-- .../sidebar/map-editor/map-editor.tsx | 10 +-- .../components/sidebar/queue/queue-game.tsx | 16 ++-- client/src/playback/Bodies.ts | 2 +- client/src/playback/GameRunner.ts | 90 +++++++++++++++++++ 14 files changed, 187 insertions(+), 134 deletions(-) create mode 100644 client/src/playback/GameRunner.ts diff --git a/client/src/app-context.tsx b/client/src/app-context.tsx index cea23d2f..f3a1379f 100644 --- a/client/src/app-context.tsx +++ b/client/src/app-context.tsx @@ -6,8 +6,6 @@ import { ClientConfig, getDefaultConfig } from './client-config' export interface AppState { queue: Game[] - activeGame: Game | undefined - activeMatch: Match | undefined tournament: Tournament | undefined tournamentState: TournamentState loadingRemoteContent: string @@ -19,8 +17,6 @@ export interface AppState { const DEFAULT_APP_STATE: AppState = { queue: [], - activeGame: undefined, - activeMatch: undefined, tournament: undefined, tournamentState: DEFAULT_TOURNAMENT_STATE, loadingRemoteContent: '', diff --git a/client/src/components/controls-bar/controls-bar-timeline.tsx b/client/src/components/controls-bar/controls-bar-timeline.tsx index c1e8583d..e86bd3ca 100644 --- a/client/src/components/controls-bar/controls-bar-timeline.tsx +++ b/client/src/components/controls-bar/controls-bar-timeline.tsx @@ -1,5 +1,6 @@ import React, { useRef } from 'react' import { useAppContext } from '../../app-context' +import { useMatch } from '../../playback/GameRunner' const TIMELINE_WIDTH = 350 interface Props { @@ -8,6 +9,7 @@ interface Props { export const ControlsBarTimeline: React.FC = ({ currentUPS }) => { const appContext = useAppContext() + const match = useMatch() let down = useRef(false) const timelineHover = (e: React.MouseEvent) => { @@ -34,25 +36,25 @@ export const ControlsBarTimeline: React.FC = ({ currentUPS }) => { const rect = e.currentTarget.getBoundingClientRect() const x = e.clientX - rect.left if (x <= 0) { - appContext.state.activeGame!.currentMatch!.jumpToTurn(0) + match!.jumpToTurn(0) } else if (x >= rect.width) { - appContext.state.activeGame!.currentMatch!.jumpToEnd() + match!.jumpToEnd() } } timelineUp(e) } // TODO: should have a defined constant somewhere else - const maxTurn = appContext.state.tournament ? 2000 : appContext.state.activeGame!.currentMatch!.maxTurn + const maxTurn = appContext.state.tournament ? 2000 : match!.maxTurn const timelineClick = (e: React.MouseEvent) => { const rect = e.currentTarget.getBoundingClientRect() const x = e.clientX - rect.left const turn = Math.floor((x / TIMELINE_WIDTH) * maxTurn) - appContext.state.activeGame!.currentMatch!.jumpToTurn(turn) + match!.jumpToTurn(turn) } - if (!appContext.state.activeGame || !appContext.state.activeGame.currentMatch) + if (!match) return (

@@ -62,7 +64,7 @@ export const ControlsBarTimeline: React.FC = ({ currentUPS }) => {

) - const turn = appContext.state.activeGame!.currentMatch!.currentTurn.turnNumber + const turn = match!.currentTurn.turnNumber const turnPercentage = () => (1 - turn / maxTurn) * 100 + '%' return (
diff --git a/client/src/components/controls-bar/controls-bar.tsx b/client/src/components/controls-bar/controls-bar.tsx index 07a1a039..87dcbd53 100644 --- a/client/src/components/controls-bar/controls-bar.tsx +++ b/client/src/components/controls-bar/controls-bar.tsx @@ -8,21 +8,23 @@ import { EventType, useListenEvent } from '../../app-events' import { useForceUpdate } from '../../util/react-util' import Tooltip from '../tooltip' import { PageType, usePage } from '../../app-search-params' +import gameRunner, { useGame, useTurn } from '../../playback/GameRunner' const SIMULATION_UPDATE_INTERVAL_MS = 17 // About 60 fps export const ControlsBar: React.FC = () => { const { state: appState, setState: setAppState } = useAppContext() + const game = useGame() + const turn = useTurn() const [minimized, setMinimized] = React.useState(false) const keyboard = useKeyboard() const [page, setPage] = usePage() const currentUPSBuffer = React.useRef([]) - const currentMatch = appState.activeGame?.currentMatch - const isPlayable = appState.activeGame && appState.activeGame.playable && currentMatch - const hasNextMatch = - currentMatch && appState.activeGame!.matches.indexOf(currentMatch!) + 1 < appState.activeGame!.matches.length + const currentMatch = turn?.match + const isPlayable = !!turn && !!game && !!currentMatch + const hasNextMatch = currentMatch && game!.matches.indexOf(currentMatch!) + 1 < game!.matches.length const changePaused = (paused: boolean) => { if (!currentMatch) return @@ -69,7 +71,6 @@ export const ControlsBar: React.FC = () => { const nextMatch = () => { if (!isPlayable) return - const game = appState.activeGame! const prevMatch = game.currentMatch! const prevMatchIndex = game.matches.indexOf(prevMatch) if (prevMatchIndex + 1 == game.matches.length) { @@ -77,20 +78,17 @@ export const ControlsBar: React.FC = () => { return } - game.currentMatch = game.matches[prevMatchIndex + 1] setAppState((prevState) => ({ - ...prevState, - activeGame: game, - activeMatch: game.currentMatch + ...prevState })) + gameRunner.selectMatch(game.matches[prevMatchIndex + 1]) } const closeGame = () => { setAppState((prevState) => ({ - ...prevState, - activeGame: undefined, - activeMatch: undefined + ...prevState })) + gameRunner.setGame(undefined) if (appState.tournament) setPage(PageType.TOURNAMENT) } @@ -129,7 +127,7 @@ export const ControlsBar: React.FC = () => { return () => { clearInterval(stepInterval) } - }, [appState.updatesPerSecond, appState.activeGame, currentMatch, appState.paused]) + }, [appState.updatesPerSecond, game, currentMatch, appState.paused]) useEffect(() => { if (appState.disableHotkeys) return @@ -170,13 +168,10 @@ export const ControlsBar: React.FC = () => { } }, [keyboard.keyCode]) - const forceUpdate = useForceUpdate() - useListenEvent(EventType.TURN_PROGRESS, forceUpdate) - if (!isPlayable) return null - const atStart = currentMatch.currentTurn.turnNumber == 0 - const atEnd = currentMatch.currentTurn.turnNumber == currentMatch.maxTurn + const atStart = turn.turnNumber == 0 + const atEnd = turn.turnNumber == currentMatch.maxTurn return (
{ const appContext = useAppContext() + const game = useGame() if (appContext.state.loadingRemoteContent) { return ( @@ -14,7 +16,7 @@ export const GameArea: React.FC = () => { ) } - if (!appContext.state.activeGame && appContext.state.tournament) { + if (!game && appContext.state.tournament) { return } diff --git a/client/src/components/game/game-renderer.tsx b/client/src/components/game/game-renderer.tsx index c3017f8b..b8ee6961 100644 --- a/client/src/components/game/game-renderer.tsx +++ b/client/src/components/game/game-renderer.tsx @@ -6,6 +6,7 @@ import assert from 'assert' import { Tooltip } from './tooltip' import { TILE_RESOLUTION } from '../../constants' import { CurrentMap } from '../../playback/Map' +import { useMatch, useTurn } from '../../playback/GameRunner' export const GameRenderer: React.FC = () => { const wrapperRef = useRef(null) @@ -14,7 +15,8 @@ export const GameRenderer: React.FC = () => { const overlayCanvas = useRef(null) const appContext = useAppContext() - const { activeGame, activeMatch } = appContext.state + const match = useMatch() + const turn = useTurn() const [selectedBodyID, setSelectedBodyID] = useState(undefined) const [hoveredTile, setHoveredTile] = useState(undefined) @@ -22,7 +24,6 @@ export const GameRenderer: React.FC = () => { const [hoveredBodyID, setHoveredBodyID] = useState(undefined) const calculateHoveredBodyID = () => { if (!hoveredTile) return setHoveredBodyID(undefined) - const match = appContext.state.activeMatch if (!match) return const hoveredBodyIDFound = match.currentTurn.bodies.getBodyAtLocation(hoveredTile.x, hoveredTile.y)?.id setHoveredBodyID(hoveredBodyIDFound) @@ -30,28 +31,26 @@ export const GameRenderer: React.FC = () => { // always clear this so the selection is cleared when you move setSelectedSquare(undefined) } - useEffect(calculateHoveredBodyID, [hoveredTile]) - useListenEvent(EventType.TURN_PROGRESS, calculateHoveredBodyID) + useEffect(calculateHoveredBodyID, [turn?.turnNumber, hoveredTile]) // watch turn number, not turn object because it doesnt change const render = () => { const ctx = dynamicCanvas.current?.getContext('2d') const overlayCtx = overlayCanvas.current?.getContext('2d') - if (!activeMatch || !ctx || !overlayCtx) return + if (!match || !ctx || !overlayCtx) return - const currentTurn = activeMatch.currentTurn + const currentTurn = match.currentTurn const map = currentTurn.map ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height) overlayCtx.clearRect(0, 0, overlayCtx.canvas.width, overlayCtx.canvas.height) - map.draw(activeMatch, ctx, appContext.state.config, selectedBodyID, hoveredBodyID) - currentTurn.bodies.draw(activeMatch, ctx, overlayCtx, appContext.state.config, selectedBodyID, hoveredBodyID) - currentTurn.actions.draw(activeMatch, ctx) + map.draw(match, ctx, appContext.state.config, selectedBodyID, hoveredBodyID) + currentTurn.bodies.draw(match, ctx, overlayCtx, appContext.state.config, selectedBodyID, hoveredBodyID) + currentTurn.actions.draw(match, ctx) } useEffect(render, [hoveredBodyID, selectedBodyID]) useListenEvent(EventType.RENDER, render, [render]) const fullRender = () => { - const match = appContext.state.activeMatch const ctx = backgroundCanvas.current?.getContext('2d') if (!match || !ctx) return match.currentTurn.map.staticMap.draw(ctx) @@ -65,8 +64,8 @@ export const GameRenderer: React.FC = () => { canvas.height = dims.y * TILE_RESOLUTION canvas.getContext('2d')?.scale(TILE_RESOLUTION, TILE_RESOLUTION) } + useEffect(() => { - const match = appContext.state.activeMatch if (!match) return const { width, height } = match.currentTurn.map updateCanvasDimensions(backgroundCanvas.current, { x: width, y: height }) @@ -77,12 +76,12 @@ export const GameRenderer: React.FC = () => { setHoveredTile(undefined) setHoveredBodyID(undefined) publishEvent(EventType.INITIAL_RENDER, {}) - }, [appContext.state.activeMatch, backgroundCanvas.current, dynamicCanvas.current, overlayCanvas.current]) + }, [match, backgroundCanvas.current, dynamicCanvas.current, overlayCanvas.current]) const eventToPoint = (e: React.MouseEvent) => { const canvas = e.target as HTMLCanvasElement const rect = canvas.getBoundingClientRect() - const map = activeGame!.currentMatch!.currentTurn!.map ?? assert.fail('map is null in onclick') + const map = match!.currentTurn!.map ?? assert.fail('map is null in onclick') let x = Math.floor(((e.clientX - rect.left) / rect.width) * map.width) let y = Math.floor((1 - (e.clientY - rect.top) / rect.height) * map.height) x = Math.max(0, Math.min(x, map.width - 1)) @@ -118,9 +117,9 @@ export const GameRenderer: React.FC = () => { } const onCanvasClick = (e: React.MouseEvent) => { const point = eventToPoint(e) - const clickedBody = activeGame?.currentMatch?.currentTurn?.bodies.getBodyAtLocation(point.x, point.y) + const clickedBody = match?.currentTurn?.bodies.getBodyAtLocation(point.x, point.y) setSelectedBodyID(clickedBody ? clickedBody.id : undefined) - setSelectedSquare(clickedBody || !activeMatch?.game.playable ? undefined : point) + setSelectedSquare(clickedBody || !match?.game.playable ? undefined : point) publishEvent(EventType.TILE_CLICK, point) } const onCanvasDrag = (e: React.MouseEvent) => { @@ -137,7 +136,7 @@ export const GameRenderer: React.FC = () => { style={{ WebkitUserSelect: 'none', userSelect: 'none' }} ref={wrapperRef} > - {!activeMatch ? ( + {!match ? (

Select a game from the queue

) : ( <> @@ -192,7 +191,7 @@ export const GameRenderer: React.FC = () => { /> diff --git a/client/src/components/game/tooltip.tsx b/client/src/components/game/tooltip.tsx index 726fc6c7..2fc4dbe2 100644 --- a/client/src/components/game/tooltip.tsx +++ b/client/src/components/game/tooltip.tsx @@ -1,10 +1,9 @@ -import React, { MutableRefObject, useEffect } from 'react' +import React, { useEffect } from 'react' import { useAppContext } from '../../app-context' -import { useListenEvent, EventType } from '../../app-events' -import { useForceUpdate } from '../../util/react-util' import { ThreeBarsIcon } from '../../icons/three-bars' import { getRenderCoords } from '../../util/RenderUtil' import { Vector } from '../../playback/Vector' +import { useTurn } from '../../playback/GameRunner' type TooltipProps = { overlayCanvas: HTMLCanvasElement | null @@ -24,18 +23,10 @@ export const Tooltip = ({ wrapperRef }: TooltipProps) => { const appContext = useAppContext() - const forceUpdate = useForceUpdate() - useListenEvent(EventType.TURN_PROGRESS, forceUpdate) - useListenEvent(EventType.INITIAL_RENDER, forceUpdate) - - const selectedBody = - selectedBodyID !== undefined - ? appContext.state.activeMatch?.currentTurn.bodies.bodies.get(selectedBodyID) - : undefined - const hoveredBody = - hoveredBodyID !== undefined - ? appContext.state.activeMatch?.currentTurn.bodies.bodies.get(hoveredBodyID) - : undefined + const turn = useTurn() + + const selectedBody = selectedBodyID !== undefined ? turn?.bodies.bodies.get(selectedBodyID) : undefined + const hoveredBody = hoveredBodyID !== undefined ? turn?.bodies.bodies.get(hoveredBodyID) : undefined const tooltipRef = React.useRef(null) const [tooltipSize, setTooltipSize] = React.useState({ width: 0, height: 0 }) @@ -52,7 +43,7 @@ export const Tooltip = ({ } }, [hoveredBody, hoveredSquare]) - const map = appContext.state.activeMatch?.currentTurn.map + const map = turn?.map if (!overlayCanvas || !wrapperRef || !map) return <> const wrapperRect = wrapperRef.getBoundingClientRect() @@ -100,8 +91,8 @@ export const Tooltip = ({ const tooltipContent = hoveredBody ? hoveredBody.onHoverInfo() : hoveredSquare - ? map.getTooltipInfo(hoveredSquare, appContext.state.activeMatch!) - : [] + ? map.getTooltipInfo(hoveredSquare, turn!.match) + : [] if (tooltipContent.length === 0) showFloatingTooltip = false diff --git a/client/src/components/sidebar/game/game.tsx b/client/src/components/sidebar/game/game.tsx index 35517a9f..d9c05722 100644 --- a/client/src/components/sidebar/game/game.tsx +++ b/client/src/components/sidebar/game/game.tsx @@ -12,6 +12,7 @@ import Tooltip from '../../tooltip' import { useForceUpdate } from '../../../util/react-util' import Match from '../../../playback/Match' import { Team } from '../../../playback/Game' +import { useGame, useMatch, useTurn } from '../../../playback/GameRunner' const NO_GAME_TEAM_NAME = '?????' @@ -21,44 +22,41 @@ interface Props { export const GamePage: React.FC = React.memo((props) => { const context = useAppContext() - const activeGame = context.state.activeGame + const game = useGame() + const turn = useTurn() const [showStats, setShowStats] = useSearchParamBool('showStats', true) - const forceUpdate = useForceUpdate() - useListenEvent(EventType.TURN_PROGRESS, forceUpdate) - if (!props.open) return null const getWinCount = (team: Team) => { // Only return up to the current match if tournament mode is enabled - if (!activeGame) return 0 + if (!game) return 0 let stopCounting = false const isWinner = (match: Match) => { if (context.state.tournament && stopCounting) return 0 - if (match == activeGame.currentMatch) { + if (match == game.currentMatch) { stopCounting = true // Dont include this match if we aren't at the end yet if (context.state.tournament && !match.currentTurn.isEnd()) return 0 } return match.winner?.id === team.id ? 1 : 0 } - return activeGame.matches.reduce((val, match) => val + isWinner(match), 0) + return game.matches.reduce((val, match) => val + isWinner(match), 0) } const teamBox = (teamIdx: number) => { - const winCount = activeGame ? getWinCount(activeGame.teams[teamIdx]) : 0 - const isEndOfMatch = context.state.activeMatch && context.state.activeMatch.currentTurn.isEnd() + const winCount = game ? getWinCount(game.teams[teamIdx]) : 0 + const isEndOfMatch = turn?.isEnd() let showMatchWinner = !context.state.tournament || isEndOfMatch - showMatchWinner = - showMatchWinner && !!activeGame && activeGame.currentMatch?.winner === activeGame.teams[teamIdx] + showMatchWinner = showMatchWinner && !!game && game.currentMatch?.winner === game.teams[teamIdx] let showGameWinner = !context.state.tournament || (showMatchWinner && winCount >= 3) - showGameWinner = showGameWinner && !!activeGame && activeGame.winner === activeGame.teams[teamIdx] + showGameWinner = showGameWinner && !!game && game.winner === game.teams[teamIdx] return (
-
{activeGame?.teams[teamIdx].name ?? NO_GAME_TEAM_NAME}
+
{game?.teams[teamIdx].name ?? NO_GAME_TEAM_NAME}
{showMatchWinner && ( @@ -90,8 +88,8 @@ export const GamePage: React.FC = React.memo((props) => { return (
- {activeGame && activeGame.currentMatch && ( -
{activeGame.currentMatch.map.name}
+ {game && game.currentMatch && ( +
{game.currentMatch.map.name}
)}
{teamBox(0)} @@ -109,7 +107,7 @@ export const GamePage: React.FC = React.memo((props) => { containerClassName="mt-2" titleClassName="py-2" > - {activeGame ? ( + {game ? ( <> {/* Note: to keep animation smooth, we should still keep the elements rendered, but we pass showStats into them so that they don't render any data (since we're likely hiding stats to prevent lag) */} diff --git a/client/src/components/sidebar/game/histogram.tsx b/client/src/components/sidebar/game/histogram.tsx index ea192586..25ede95b 100644 --- a/client/src/components/sidebar/game/histogram.tsx +++ b/client/src/components/sidebar/game/histogram.tsx @@ -4,19 +4,16 @@ import { useListenEvent, EventType } from '../../../app-events' import { useForceUpdate } from '../../../util/react-util' import { CanvasHistogram } from './quick-histogram' import { ATTACK_COLOR, SPECIALTY_COLORS, TEAM_COLORS } from '../../../constants' +import { useTurn } from '../../../playback/GameRunner' +import Turn from '../../../playback/Turn' -function getChartData(appContext: AppContext): number[][][] { - const match = appContext.state.activeMatch - if (match === undefined) { - return [] - } - +function getChartData(turn: Turn): number[][][] { const emptyHist = Array(7).fill(0) const totals = [ [[...emptyHist], [...emptyHist], [...emptyHist]], [[...emptyHist], [...emptyHist], [...emptyHist]] ] - for (const [id, body] of match.currentTurn.bodies.bodies) { + for (const [id, body] of turn.bodies.bodies) { const teamIdx = body.team.id - 1 totals[teamIdx][0][body.attackLevel] += 1 totals[teamIdx][1][body.buildLevel] += 1 @@ -31,13 +28,8 @@ interface SpecialtyHistogramProps { } export const SpecialtyHistogram: React.FC = (props) => { - const appContext = useAppContext() - const forceUpdate = useForceUpdate() - useListenEvent(EventType.TURN_PROGRESS, () => { - if (props.active) forceUpdate() - }) - - const data = getChartData(appContext) + const turn = useTurn() + const data = props.active && turn ? getChartData(turn) : [] const getData = (team: number, specialty: number) => { return data.length === 0 ? [] : data[team][specialty] diff --git a/client/src/components/sidebar/game/resource-graph.tsx b/client/src/components/sidebar/game/resource-graph.tsx index d0e777c2..4793c3d9 100644 --- a/client/src/components/sidebar/game/resource-graph.tsx +++ b/client/src/components/sidebar/game/resource-graph.tsx @@ -1,9 +1,8 @@ import React from 'react' -import { AppContext, useAppContext } from '../../../app-context' -import { useListenEvent, EventType } from '../../../app-events' -import { useForceUpdate } from '../../../util/react-util' import { D3LineChart, LineChartDataPoint } from './d3-line-chart' import assert from 'assert' +import { useTurn } from '../../../playback/GameRunner' +import Turn from '../../../playback/Turn' interface Props { active: boolean @@ -14,21 +13,16 @@ function hasKey(obj: O, key: PropertyKey): key is keyof O { return key in obj } -function getChartData(appContext: AppContext, property: string): LineChartDataPoint[] { - const match = appContext.state.activeMatch - if (match === undefined) { - return [] - } - +function getChartData(turn: Turn, property: string): LineChartDataPoint[] { const values = [0, 1].map((index) => - match.stats.map((turnStat) => { - const teamStat = turnStat.getTeamStat(match.game.teams[index]) + turn.match.stats.map((turnStat) => { + const teamStat = turnStat.getTeamStat(turn.match.game.teams[index]) assert(hasKey(teamStat, property), `TeamStat missing property '${property}' when rendering chart`) return teamStat[property] }) ) - return values[0].slice(0, match.currentTurn.turnNumber).map((value, index) => { + return values[0].slice(0, turn.turnNumber).map((value, index) => { return { turn: index + 1, white: value as number, @@ -38,18 +32,14 @@ function getChartData(appContext: AppContext, property: string): LineChartDataPo } export const ResourceGraph: React.FC = (props: Props) => { - const appContext = useAppContext() - const forceUpdate = useForceUpdate() - - useListenEvent(EventType.TURN_PROGRESS, () => { - if (props.active) forceUpdate() - }) + const turn = useTurn() + const data = props.active && turn ? getChartData(turn, props.property) : [] return (

{props.propertyDisplayName}

= (props: TeamTableProps) => { - const context = useAppContext() - const forceUpdate = useForceUpdate() - useListenEvent(EventType.TURN_PROGRESS, forceUpdate) - - const match = context.state.activeMatch - const teamStat = match?.currentTurn?.stat.getTeamStat(match.game.teams[props.teamIdx]) - - const map = match?.currentTurn?.map + const turn = useTurn() + const teamStat = turn?.stat.getTeamStat(turn?.match.game.teams[props.teamIdx]) + const map = turn?.map return (
diff --git a/client/src/components/sidebar/map-editor/map-editor.tsx b/client/src/components/sidebar/map-editor/map-editor.tsx index 2f6d9d70..87cdc9ea 100644 --- a/client/src/components/sidebar/map-editor/map-editor.tsx +++ b/client/src/components/sidebar/map-editor/map-editor.tsx @@ -13,6 +13,7 @@ import { exportMap, loadFileAsMap } from './MapGenerator' import { MAP_SIZE_RANGE } from '../../../constants' import { InputDialog } from '../../input-dialog' import { ConfirmDialog } from '../../confirm-dialog' +import { useTurn } from '../../../playback/GameRunner' type MapParams = { width: number @@ -27,6 +28,7 @@ interface Props { export const MapEditorPage: React.FC = (props) => { const context = useAppContext() + const turn = useTurn() const [cleared, setCleared] = React.useState(true) const [mapParams, setMapParams] = React.useState({ width: 30, height: 30, symmetry: 0 }) const [brushes, setBrushes] = React.useState([]) @@ -43,9 +45,7 @@ export const MapEditorPage: React.FC = (props) => { setBrushes(brushes.map((b) => b.opened(b === brush))) } - const mapEmpty = () => - !context.state.activeMatch?.currentTurn || - (context.state.activeMatch.currentTurn.map.isEmpty() && context.state.activeMatch.currentTurn.bodies.isEmpty()) + const mapEmpty = () => !turn || (turn.map.isEmpty() && turn.bodies.isEmpty()) const applyBrush = (point: { x: number; y: number }) => { if (!openBrush) return @@ -181,7 +181,7 @@ export const MapEditorPage: React.FC = (props) => {
{ - if (!context.state.activeMatch?.currentTurn) return + if (!turn) return setMapNameOpen(true) }} > @@ -199,7 +199,7 @@ export const MapEditorPage: React.FC = (props) => { setMapNameOpen(false) return } - const error = exportMap(context.state.activeMatch!.currentTurn, name) + const error = exportMap(turn!, name) setMapError(error) if (!error) setMapNameOpen(false) }} diff --git a/client/src/components/sidebar/queue/queue-game.tsx b/client/src/components/sidebar/queue/queue-game.tsx index c0710b85..ff63ca18 100644 --- a/client/src/components/sidebar/queue/queue-game.tsx +++ b/client/src/components/sidebar/queue/queue-game.tsx @@ -5,6 +5,8 @@ import { useAppContext } from '../../../app-context' import { IconContext } from 'react-icons' import { IoCloseCircle, IoCloseCircleOutline } from 'react-icons/io5' import { schema } from 'battlecode-schema' +import gameRunner, { useGame } from '../../../playback/GameRunner' +import { useMatch } from '../../../playback/GameRunner' interface Props { game: Game @@ -12,26 +14,26 @@ interface Props { export const QueuedGame: React.FC = (props) => { const context = useAppContext() + const activeMatch = useMatch() const isTournamentMode = context.state.tournament !== undefined const [hoveredClose, setHoveredClose] = useState(false) const setMatch = (match: Match) => { match.jumpToTurn(0) - props.game.currentMatch = match context.setState((prevState) => ({ ...prevState, - activeGame: match.game, - activeMatch: match })) + + gameRunner.selectMatch(match) } const close = () => { context.setState((prevState) => ({ ...prevState, - queue: context.state.queue.filter((v) => v !== props.game), - activeGame: context.state.activeGame === props.game ? undefined : context.state.activeGame, - activeMatch: context.state.activeGame === props.game ? undefined : context.state.activeMatch + queue: context.state.queue.filter((v) => v !== props.game) })) + + if (gameRunner.game === props.game) gameRunner.setGame(undefined) } const getWinText = (winType: schema.WinType) => { @@ -66,7 +68,7 @@ export const QueuedGame: React.FC = (props) => { className={ 'leading-4 rounded-sm border-gray-500 border my-1.5 py-1 px-2 ' + 'bg-light hover:bg-lightHighlight cursor-pointer ' + - (context.state.activeMatch === match ? 'bg-lightHighlight hover:bg-medHighlight' : '') + (activeMatch === match ? 'bg-lightHighlight hover:bg-medHighlight' : '') } onClick={() => setMatch(match)} > diff --git a/client/src/playback/Bodies.ts b/client/src/playback/Bodies.ts index f0c09da9..2d4394df 100644 --- a/client/src/playback/Bodies.ts +++ b/client/src/playback/Bodies.ts @@ -566,7 +566,7 @@ export class Body { assert(this.attackLevel >= 0 && this.attackLevel <= 6, 'Attack level out of bounds') assert(this.healLevel >= 0 && this.healLevel <= 6, 'Heal level out of bounds') assert(this.buildLevel >= 0 && this.buildLevel <= 6, 'Build level out of bounds') - assert([this.attackLevel, this.healLevel, this.buildLevel].sort()[1] <= 3, 'Specialization level too high') + // assert([this.attackLevel, this.healLevel, this.buildLevel].sort()[1] <= 3, 'Specialization level too high') if (this.attackLevel > 3) return { idx: 1, name: 'attack' } if (this.buildLevel > 3) return { idx: 2, name: 'build' } if (this.healLevel > 3) return { idx: 3, name: 'heal' } diff --git a/client/src/playback/GameRunner.ts b/client/src/playback/GameRunner.ts new file mode 100644 index 00000000..e244e149 --- /dev/null +++ b/client/src/playback/GameRunner.ts @@ -0,0 +1,90 @@ +import React from 'react' +import Game from './Game' +import Match from './Match' +import Turn from './Turn' +import { EventType } from '../app-events' + +class GameRunner { + game: Game | undefined = undefined + _gameListeners: ((game: Game | undefined) => void)[] = [] + match: Match | undefined = undefined + _matchListeners: ((match: Match | undefined) => void)[] = [] + _turnListeners: ((turn: Turn | undefined) => void)[] = [] + + constructor() { + document.addEventListener(EventType.TURN_PROGRESS as string, () => this._updateTurnListeners()) + } + + _updateTurnListeners(): void { + this._turnListeners.forEach((listener) => listener(this.match?.currentTurn)) + } + + setGame(game: Game | undefined): void { + this.game = game + this._gameListeners.forEach((listener) => listener(game)) + if (!game && this.match) { + this.setMatch(undefined) + } + this._updateTurnListeners() + } + + setMatch(match: Match | undefined): void { + this.match = match + this._matchListeners.forEach((listener) => listener(match)) + if (!this.game && match) { + this.setGame(match.game) + } + this._updateTurnListeners() + } + + selectMatch(match: Match): void { + match.game.currentMatch = match + this.setMatch(match) + } +} + +const gameRunner = new GameRunner() + +function useGame(): Game | undefined { + const [game, setGame] = React.useState(gameRunner.game) + React.useEffect(() => { + const listener = (game: Game | undefined) => setGame(game) + gameRunner._gameListeners.push(listener) + return () => { + gameRunner._gameListeners = gameRunner._gameListeners.filter((l) => l !== listener) + } + }, []) + return game +} + +function useMatch(): Match | undefined { + const [match, setMatch] = React.useState(gameRunner.match) + React.useEffect(() => { + const listener = (match: Match | undefined) => setMatch(match) + gameRunner._matchListeners.push(listener) + return () => { + gameRunner._matchListeners = gameRunner._matchListeners.filter((l) => l !== listener) + } + }, []) + return match +} + +function useTurn(): Turn | undefined { + const [turn, setTurn] = React.useState(gameRunner.match?.currentTurn) + // since turn objects are reused, we need to update when the turn number changes to force a re-render + const [turnNumber, setTurnNumber] = React.useState(gameRunner.match?.currentTurn?.turnNumber) + React.useEffect(() => { + const listener = (turn: Turn | undefined) => { + setTurn(turn) + setTurnNumber(turn?.turnNumber) + } + gameRunner._turnListeners.push(listener) + return () => { + gameRunner._turnListeners = gameRunner._turnListeners.filter((l) => l !== listener) + } + }, []) + return turn +} + +export default gameRunner +export { useGame, useMatch, useTurn } From 068d5dcb8f47b7db27862532a16e191d04d344d1 Mon Sep 17 00:00:00 2001 From: Aidan Blum Levine Date: Tue, 10 Sep 2024 22:03:27 -0400 Subject: [PATCH 2/5] move ups and paused out --- client/src/app-context.tsx | 4 - .../controls-bar/controls-bar-timeline.tsx | 6 +- .../components/controls-bar/controls-bar.tsx | 175 ++++-------------- client/src/playback/GameRunner.ts | 136 +++++++++++++- 4 files changed, 171 insertions(+), 150 deletions(-) diff --git a/client/src/app-context.tsx b/client/src/app-context.tsx index f3a1379f..d40acbfc 100644 --- a/client/src/app-context.tsx +++ b/client/src/app-context.tsx @@ -9,8 +9,6 @@ export interface AppState { tournament: Tournament | undefined tournamentState: TournamentState loadingRemoteContent: string - updatesPerSecond: number - paused: boolean disableHotkeys: boolean config: ClientConfig } @@ -20,8 +18,6 @@ const DEFAULT_APP_STATE: AppState = { tournament: undefined, tournamentState: DEFAULT_TOURNAMENT_STATE, loadingRemoteContent: '', - updatesPerSecond: 1, - paused: true, disableHotkeys: false, config: getDefaultConfig() } diff --git a/client/src/components/controls-bar/controls-bar-timeline.tsx b/client/src/components/controls-bar/controls-bar-timeline.tsx index e86bd3ca..96a0a4ea 100644 --- a/client/src/components/controls-bar/controls-bar-timeline.tsx +++ b/client/src/components/controls-bar/controls-bar-timeline.tsx @@ -5,9 +5,10 @@ import { useMatch } from '../../playback/GameRunner' const TIMELINE_WIDTH = 350 interface Props { currentUPS: number + targetUPS: number } -export const ControlsBarTimeline: React.FC = ({ currentUPS }) => { +export const ControlsBarTimeline: React.FC = ({ currentUPS, targetUPS }) => { const appContext = useAppContext() const match = useMatch() @@ -69,8 +70,7 @@ export const ControlsBarTimeline: React.FC = ({ currentUPS }) => { return (

- Turn: {turn}/{maxTurn}   {appContext.state.updatesPerSecond} UPS ( - {appContext.state.updatesPerSecond < 0 && '-'} + Turn: {turn}/{maxTurn}   {targetUPS} UPS ({targetUPS < 0 && '-'} {currentUPS})

diff --git a/client/src/components/controls-bar/controls-bar.tsx b/client/src/components/controls-bar/controls-bar.tsx index 87dcbd53..baba767d 100644 --- a/client/src/components/controls-bar/controls-bar.tsx +++ b/client/src/components/controls-bar/controls-bar.tsx @@ -4,130 +4,17 @@ import { ControlsBarButton } from './controls-bar-button' import { useAppContext } from '../../app-context' import { useKeyboard } from '../../util/keyboard' import { ControlsBarTimeline } from './controls-bar-timeline' -import { EventType, useListenEvent } from '../../app-events' -import { useForceUpdate } from '../../util/react-util' import Tooltip from '../tooltip' -import { PageType, usePage } from '../../app-search-params' -import gameRunner, { useGame, useTurn } from '../../playback/GameRunner' - -const SIMULATION_UPDATE_INTERVAL_MS = 17 // About 60 fps +import gameRunner, { useControls, useTurn } from '../../playback/GameRunner' export const ControlsBar: React.FC = () => { - const { state: appState, setState: setAppState } = useAppContext() - const game = useGame() + const { state: appState } = useAppContext() const turn = useTurn() const [minimized, setMinimized] = React.useState(false) const keyboard = useKeyboard() - const [page, setPage] = usePage() - - const currentUPSBuffer = React.useRef([]) - - const currentMatch = turn?.match - const isPlayable = !!turn && !!game && !!currentMatch - const hasNextMatch = currentMatch && game!.matches.indexOf(currentMatch!) + 1 < game!.matches.length - - const changePaused = (paused: boolean) => { - if (!currentMatch) return - setAppState((prevState) => ({ - ...prevState, - paused: paused, - updatesPerSecond: appState.updatesPerSecond == 0 && !paused ? 1 : appState.updatesPerSecond - })) - } - - const multiplyUpdatesPerSecond = (multiplier: number) => { - if (!isPlayable) return - setAppState((old) => { - const u = old.updatesPerSecond - const sign = Math.sign(u * multiplier) - const newMag = Math.max(1 / 4, Math.min(64, Math.abs(u * multiplier))) - return { ...old, updatesPerSecond: sign * newMag } - }) - } - - const stepTurn = (delta: number) => { - if (!isPlayable) return - // explicit rerender at the end so a render doesnt occur between these two steps - currentMatch!.stepTurn(delta, false) - currentMatch!.roundSimulation() - currentMatch!.rerender() - } - - const jumpToTurn = (turn: number) => { - if (!isPlayable) return - // explicit rerender at the end so a render doesnt occur between these two steps - currentMatch!.jumpToTurn(turn, false) - currentMatch!.roundSimulation() - currentMatch!.rerender() - } - - const jumpToEnd = () => { - if (!isPlayable) return - // explicit rerender at the end so a render doesnt occur between these two steps - currentMatch!.jumpToEnd(false) - currentMatch!.roundSimulation() - currentMatch!.rerender() - } - - const nextMatch = () => { - if (!isPlayable) return - const prevMatch = game.currentMatch! - const prevMatchIndex = game.matches.indexOf(prevMatch) - if (prevMatchIndex + 1 == game.matches.length) { - closeGame() - return - } - - setAppState((prevState) => ({ - ...prevState - })) - gameRunner.selectMatch(game.matches[prevMatchIndex + 1]) - } - - const closeGame = () => { - setAppState((prevState) => ({ - ...prevState - })) - gameRunner.setGame(undefined) - if (appState.tournament) setPage(PageType.TOURNAMENT) - } + const { paused, currentUPS, targetUPS } = useControls() - React.useEffect(() => { - // We want to pause whenever the match changes - changePaused(true) - }, [currentMatch]) - - React.useEffect(() => { - if (!isPlayable) return - if (appState.paused) { - // Snap bots to their actual position when paused by rounding simulation - // to the true turn - currentMatch!.roundSimulation() - currentMatch!.rerender() - return - } - - const msPerUpdate = 1000 / appState.updatesPerSecond - const updatesPerInterval = SIMULATION_UPDATE_INTERVAL_MS / msPerUpdate - const stepInterval = setInterval(() => { - const prevTurn = currentMatch!.currentTurn.turnNumber - currentMatch!.stepSimulation(updatesPerInterval) - - if (prevTurn != currentMatch!.currentTurn.turnNumber) { - currentUPSBuffer.current.push(Date.now()) - while (currentUPSBuffer.current.length > 0 && currentUPSBuffer.current[0] < Date.now() - 1000) - currentUPSBuffer.current.shift() - } - - if (currentMatch!.currentTurn.isEnd() && appState.updatesPerSecond > 0) { - changePaused(true) - } - }, SIMULATION_UPDATE_INTERVAL_MS) - - return () => { - clearInterval(stepInterval) - } - }, [appState.updatesPerSecond, game, currentMatch, appState.paused]) + const hasNextMatch = turn && turn?.match.game.matches.indexOf(turn.match!) + 1 < turn.match.game.matches.length useEffect(() => { if (appState.disableHotkeys) return @@ -137,23 +24,23 @@ export const ControlsBar: React.FC = () => { // specific accessibility features that mess with these shortcuts. if (keyboard.targetElem instanceof HTMLButtonElement) keyboard.targetElem.blur() - if (keyboard.keyCode === 'Space') changePaused(!appState.paused) + if (keyboard.keyCode === 'Space') gameRunner.setPaused(!paused) if (keyboard.keyCode === 'KeyC') setMinimized(!minimized) const applyArrows = () => { - if (appState.paused) { - if (keyboard.keyCode === 'ArrowRight') stepTurn(1) - if (keyboard.keyCode === 'ArrowLeft') stepTurn(-1) + if (paused) { + if (keyboard.keyCode === 'ArrowRight') gameRunner.stepTurn(1) + if (keyboard.keyCode === 'ArrowLeft') gameRunner.stepTurn(-1) } else { - if (keyboard.keyCode === 'ArrowRight') multiplyUpdatesPerSecond(2) - if (keyboard.keyCode === 'ArrowLeft') multiplyUpdatesPerSecond(0.5) + if (keyboard.keyCode === 'ArrowRight') gameRunner.multiplyUpdatesPerSecond(2) + if (keyboard.keyCode === 'ArrowLeft') gameRunner.multiplyUpdatesPerSecond(0.5) } } applyArrows() - if (keyboard.keyCode === 'Comma') jumpToTurn(0) - if (keyboard.keyCode === 'Period') jumpToEnd() + if (keyboard.keyCode === 'Comma') gameRunner.jumpToTurn(0) + if (keyboard.keyCode === 'Period') gameRunner.jumpToEnd() const initalDelay = 250 const repeatDelay = 100 @@ -168,10 +55,10 @@ export const ControlsBar: React.FC = () => { } }, [keyboard.keyCode]) - if (!isPlayable) return null + if (!turn) return null const atStart = turn.turnNumber == 0 - const atEnd = turn.turnNumber == currentMatch.maxTurn + const atEnd = turn.turnNumber == turn.match.maxTurn return (
{ ' flex bg-darkHighlight text-white p-1.5 rounded-t-md z-10 gap-1.5 relative' } > - + } tooltip="Reverse" - onClick={() => multiplyUpdatesPerSecond(-1)} + onClick={() => gameRunner.multiplyUpdatesPerSecond(-1)} /> } tooltip={'Decrease Speed'} - onClick={() => multiplyUpdatesPerSecond(0.5)} - disabled={Math.abs(appState.updatesPerSecond) <= 0.25} + onClick={() => gameRunner.multiplyUpdatesPerSecond(0.5)} + disabled={Math.abs(targetUPS) <= 0.25} /> } tooltip="Step Backwards" - onClick={() => stepTurn(-1)} + onClick={() => gameRunner.stepTurn(-1)} disabled={atStart} /> - {appState.paused ? ( + {paused ? ( } tooltip="Play" onClick={() => { - changePaused(false) + gameRunner.setPaused(false) }} /> ) : ( @@ -226,32 +113,32 @@ export const ControlsBar: React.FC = () => { icon={} tooltip="Pause" onClick={() => { - changePaused(true) + gameRunner.setPaused(true) }} /> )} } tooltip="Next Turn" - onClick={() => stepTurn(1)} + onClick={() => gameRunner.stepTurn(1)} disabled={atEnd} /> } tooltip={'Increase Speed'} - onClick={() => multiplyUpdatesPerSecond(2)} - disabled={Math.abs(appState.updatesPerSecond) >= 64} + onClick={() => gameRunner.multiplyUpdatesPerSecond(2)} + disabled={Math.abs(targetUPS) >= 64} /> } tooltip="Jump To Start" - onClick={() => jumpToTurn(0)} + onClick={() => gameRunner.jumpToTurn(0)} disabled={atStart} /> } tooltip="Jump To End" - onClick={jumpToEnd} + onClick={() => gameRunner.jumpToEnd()} disabled={atEnd} /> {appState.tournament && ( @@ -259,10 +146,14 @@ export const ControlsBar: React.FC = () => { } tooltip="Next Match" - onClick={nextMatch} + onClick={() => gameRunner.nextMatch()} disabled={!hasNextMatch} /> - } tooltip="Close Game" onClick={closeGame} /> + } + tooltip="Close Game" + onClick={() => gameRunner.setGame(undefined)} + /> )}
diff --git a/client/src/playback/GameRunner.ts b/client/src/playback/GameRunner.ts index e244e149..d8ecc49a 100644 --- a/client/src/playback/GameRunner.ts +++ b/client/src/playback/GameRunner.ts @@ -4,17 +4,72 @@ import Match from './Match' import Turn from './Turn' import { EventType } from '../app-events' +const SIMULATION_UPDATE_INTERVAL_MS = 17 // About 60 fps + class GameRunner { + targetUPS: number = 1 + currentUPSBuffer: number[] = [] + paused: boolean = true + _controlListeners: (() => void)[] = [] + game: Game | undefined = undefined _gameListeners: ((game: Game | undefined) => void)[] = [] match: Match | undefined = undefined _matchListeners: ((match: Match | undefined) => void)[] = [] _turnListeners: ((turn: Turn | undefined) => void)[] = [] + eventLoop: NodeJS.Timeout | undefined = undefined + constructor() { document.addEventListener(EventType.TURN_PROGRESS as string, () => this._updateTurnListeners()) } + startEventLoop(): void { + if (this.eventLoop) throw new Error('Event loop already exists') + + this.eventLoop = setInterval(() => { + if (!this.match || this.paused) { + this.shutDownEventLoop() + return + } + + const prevTurn = this.match!.currentTurn.turnNumber + + const msPerUpdate = 1000 / this.targetUPS + const updatesPerInterval = SIMULATION_UPDATE_INTERVAL_MS / msPerUpdate + this.match!.stepSimulation(updatesPerInterval) + + if (prevTurn != this.match!.currentTurn.turnNumber) { + this.currentUPSBuffer.push(Date.now()) + while (this.currentUPSBuffer.length > 0 && this.currentUPSBuffer[0] < Date.now() - 1000) + this.currentUPSBuffer.shift() + } + + if (this.match!.currentTurn.isEnd() && this.targetUPS > 0) { + this.setPaused(true) + } + }, SIMULATION_UPDATE_INTERVAL_MS) + } + + shutDownEventLoop(): void { + if (!this.eventLoop) throw new Error('Event loop does not exist') + // Snap bots to their actual position when paused by rounding simulation to the true turn + if (this.match) { + this.match.roundSimulation() + this.match.rerender() + } + clearInterval(this.eventLoop) + this.eventLoop = undefined + } + + updateEventLoop(): void { + if (this.match && !this.paused && !this.eventLoop) { + this.startEventLoop() + } else if (this.eventLoop) { + this.shutDownEventLoop() + } + } + _updateTurnListeners(): void { this._turnListeners.forEach((listener) => listener(this.match?.currentTurn)) } @@ -30,10 +85,13 @@ class GameRunner { setMatch(match: Match | undefined): void { this.match = match + this.paused = true + this._updateControlListeners() this._matchListeners.forEach((listener) => listener(match)) if (!this.game && match) { this.setGame(match.game) } + this.updateEventLoop() this._updateTurnListeners() } @@ -41,6 +99,60 @@ class GameRunner { match.game.currentMatch = match this.setMatch(match) } + + _updateControlListeners(): void { + this._controlListeners.forEach((listener) => listener()) + } + + multiplyUpdatesPerSecond(multiplier: number) { + if (!this.match) return + const scaled = this.targetUPS * multiplier + const newMag = Math.max(1 / 4, Math.min(64, Math.abs(scaled))) + this.targetUPS = Math.sign(scaled) * newMag + this._updateControlListeners() + } + + setPaused(paused: boolean): void { + if (!this.match) return + this.paused = paused + if (!paused && this.targetUPS == 0) this.targetUPS = 1 + this.updateEventLoop() + this._updateControlListeners() + } + + stepTurn(delta: number) { + if (!this.match) return + // explicit rerender at the end so a render doesnt occur between these two steps + this.match!.stepTurn(delta, false) + this.match!.roundSimulation() + this.match!.rerender() + } + + jumpToTurn(turn: number) { + if (!this.match) return + // explicit rerender at the end so a render doesnt occur between these two steps + this.match!.jumpToTurn(turn, false) + this.match!.roundSimulation() + this.match!.rerender() + } + + jumpToEnd() { + if (!this.match) return + // explicit rerender at the end so a render doesnt occur between these two steps + this.match!.jumpToEnd(false) + this.match!.roundSimulation() + this.match!.rerender() + } + + nextMatch() { + if (!this.match || !this.game) return + const prevMatchIndex = this.game.matches.indexOf(this.match) + if (prevMatchIndex + 1 == this.game.matches.length) { + this.setGame(undefined) + } else { + this.selectMatch(this.game.matches[prevMatchIndex + 1]) + } + } } const gameRunner = new GameRunner() @@ -86,5 +198,27 @@ function useTurn(): Turn | undefined { return turn } +function useControls(): { + targetUPS: number + currentUPS: number + paused: boolean +} { + const [targetUPS, setTargetUPS] = React.useState(gameRunner.targetUPS) + const [currentUPS, setCurrentUPS] = React.useState(gameRunner.currentUPSBuffer.length) + const [paused, setPaused] = React.useState(gameRunner.paused) + React.useEffect(() => { + const listener = () => { + setTargetUPS(gameRunner.targetUPS) + setCurrentUPS(gameRunner.currentUPSBuffer.length) + setPaused(gameRunner.paused) + } + gameRunner._controlListeners.push(listener) + return () => { + gameRunner._controlListeners = gameRunner._controlListeners.filter((l) => l !== listener) + } + }, []) + return { targetUPS, currentUPS, paused } +} + export default gameRunner -export { useGame, useMatch, useTurn } +export { useGame, useMatch, useTurn, useControls } From d3eb423f408f563d01b3a4cbe2f7222bdb3bd9b4 Mon Sep 17 00:00:00 2001 From: Aidan Blum Levine Date: Tue, 10 Sep 2024 22:22:36 -0400 Subject: [PATCH 3/5] cleanup and stuff --- .../tournament-renderer/tournament-game.tsx | 7 ++----- client/src/components/sidebar/game/game.tsx | 4 +--- .../src/components/sidebar/game/histogram.tsx | 5 +---- .../components/sidebar/game/quick-histogram.tsx | 2 +- .../src/components/sidebar/game/team-table.tsx | 9 +++------ .../sidebar/map-editor/map-editor-brushes.tsx | 1 - .../sidebar/map-editor/map-editor-field.tsx | 2 +- .../sidebar/map-editor/map-editor.tsx | 17 +++++------------ client/src/components/sidebar/queue/queue.tsx | 6 +++--- .../src/components/sidebar/runner/scaffold.ts | 17 +++++++---------- client/src/components/sidebar/sidebar.tsx | 6 ++---- 11 files changed, 26 insertions(+), 50 deletions(-) diff --git a/client/src/components/game/tournament-renderer/tournament-game.tsx b/client/src/components/game/tournament-renderer/tournament-game.tsx index e978eb49..4db0bc38 100644 --- a/client/src/components/game/tournament-renderer/tournament-game.tsx +++ b/client/src/components/game/tournament-renderer/tournament-game.tsx @@ -6,6 +6,7 @@ import { PageType, usePage } from '../../../app-search-params' import { Pressable } from 'react-zoomable-ui' import { Crown } from '../../../icons/crown' import Tooltip from '../../tooltip' +import gameRunner from '../../../playback/GameRunner' interface Props { game: TournamentGame @@ -32,14 +33,10 @@ export const TournamentGameElement: React.FC = ({ lines, game }) => { } const loadedGame = Game.loadFullGameRaw(buffer) - // select the first match - const selectedMatch = loadedGame.matches[0] - loadedGame.currentMatch = selectedMatch + gameRunner.selectMatch(loadedGame.matches[0]) appContext.setState((prevState) => ({ ...prevState, - activeGame: loadedGame, - activeMatch: loadedGame.currentMatch, queue: prevState.queue.concat([loadedGame]) })) game.viewed = true diff --git a/client/src/components/sidebar/game/game.tsx b/client/src/components/sidebar/game/game.tsx index d9c05722..a2a1f848 100644 --- a/client/src/components/sidebar/game/game.tsx +++ b/client/src/components/sidebar/game/game.tsx @@ -7,12 +7,10 @@ import { useAppContext } from '../../../app-context' import { SectionHeader } from '../../section-header' import { Crown } from '../../../icons/crown' import { BiMedal } from 'react-icons/bi' -import { EventType, useListenEvent } from '../../../app-events' import Tooltip from '../../tooltip' -import { useForceUpdate } from '../../../util/react-util' import Match from '../../../playback/Match' import { Team } from '../../../playback/Game' -import { useGame, useMatch, useTurn } from '../../../playback/GameRunner' +import { useGame, useTurn } from '../../../playback/GameRunner' const NO_GAME_TEAM_NAME = '?????' diff --git a/client/src/components/sidebar/game/histogram.tsx b/client/src/components/sidebar/game/histogram.tsx index 25ede95b..ed963cbe 100644 --- a/client/src/components/sidebar/game/histogram.tsx +++ b/client/src/components/sidebar/game/histogram.tsx @@ -1,9 +1,6 @@ import React from 'react' -import { AppContext, useAppContext } from '../../../app-context' -import { useListenEvent, EventType } from '../../../app-events' -import { useForceUpdate } from '../../../util/react-util' import { CanvasHistogram } from './quick-histogram' -import { ATTACK_COLOR, SPECIALTY_COLORS, TEAM_COLORS } from '../../../constants' +import { SPECIALTY_COLORS, TEAM_COLORS } from '../../../constants' import { useTurn } from '../../../playback/GameRunner' import Turn from '../../../playback/Turn' diff --git a/client/src/components/sidebar/game/quick-histogram.tsx b/client/src/components/sidebar/game/quick-histogram.tsx index aa276a45..172dd865 100644 --- a/client/src/components/sidebar/game/quick-histogram.tsx +++ b/client/src/components/sidebar/game/quick-histogram.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useMemo } from 'react' +import React, { useEffect, useRef } from 'react' import { drawAxes, getAxes, setCanvasResolution } from '../../../util/graph-util' interface HistogramProps { diff --git a/client/src/components/sidebar/game/team-table.tsx b/client/src/components/sidebar/game/team-table.tsx index 48cbc612..c33595cb 100644 --- a/client/src/components/sidebar/game/team-table.tsx +++ b/client/src/components/sidebar/game/team-table.tsx @@ -1,14 +1,11 @@ -import React, { useEffect } from 'react' -import { useAppContext } from '../../../app-context' -import { useForceUpdate } from '../../../util/react-util' -import { useListenEvent, EventType } from '../../../app-events' -import { getImageIfLoaded, imageSource, removeTriggerOnImageLoad, triggerOnImageLoad } from '../../../util/ImageLoader' +import React from 'react' +import { imageSource } from '../../../util/ImageLoader' import { TEAM_COLOR_NAMES } from '../../../constants' import { schema } from 'battlecode-schema' import { TeamTurnStat } from '../../../playback/TurnStat' import { DoubleChevronUpIcon } from '../../../icons/chevron' import { CurrentMap } from '../../../playback/Map' -import { useMatch, useTurn } from '../../../playback/GameRunner' +import { useTurn } from '../../../playback/GameRunner' interface UnitsIconProps { teamIdx: 0 | 1 diff --git a/client/src/components/sidebar/map-editor/map-editor-brushes.tsx b/client/src/components/sidebar/map-editor/map-editor-brushes.tsx index 52c2b204..7373ab08 100644 --- a/client/src/components/sidebar/map-editor/map-editor-brushes.tsx +++ b/client/src/components/sidebar/map-editor/map-editor-brushes.tsx @@ -1,6 +1,5 @@ import React from 'react' import { MapEditorBrush } from './MapEditorBrush' -import { BsChevronRight, BsChevronDown } from 'react-icons/bs' import { MapEditorBrushRowField } from './map-editor-field' import { SectionHeader } from '../../section-header' 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 953b5cc9..1085d400 100644 --- a/client/src/components/sidebar/map-editor/map-editor-field.tsx +++ b/client/src/components/sidebar/map-editor/map-editor-field.tsx @@ -1,6 +1,6 @@ import React from 'react' import { MapEditorBrushField, MapEditorBrushFieldType } from './MapEditorBrush' -import { TEAM_COLORS, TEAM_COLOR_NAMES } from '../../../constants' +import { TEAM_COLOR_NAMES } from '../../../constants' import { Toggle } from '../../toggle' import { Select, NumInput } from '../../forms' diff --git a/client/src/components/sidebar/map-editor/map-editor.tsx b/client/src/components/sidebar/map-editor/map-editor.tsx index 87cdc9ea..3ff35310 100644 --- a/client/src/components/sidebar/map-editor/map-editor.tsx +++ b/client/src/components/sidebar/map-editor/map-editor.tsx @@ -1,5 +1,5 @@ import React, { useEffect } from 'react' -import { CurrentMap, StaticMap } from '../../../playback/Map' +import { StaticMap } from '../../../playback/Map' import { MapEditorBrushRow } from './map-editor-brushes' import Bodies from '../../../playback/Bodies' import Game from '../../../playback/Game' @@ -13,7 +13,7 @@ import { exportMap, loadFileAsMap } from './MapGenerator' import { MAP_SIZE_RANGE } from '../../../constants' import { InputDialog } from '../../input-dialog' import { ConfirmDialog } from '../../confirm-dialog' -import { useTurn } from '../../../playback/GameRunner' +import gameRunner, { useTurn } from '../../../playback/GameRunner' type MapParams = { width: number @@ -102,11 +102,8 @@ export const MapEditorPage: React.FC = (props) => { // multiple times mapParams.imported = undefined - context.setState((prevState) => ({ - ...prevState, - activeGame: editGame.current ?? undefined, - activeMatch: editGame.current?.currentMatch - })) + gameRunner.setGame(editGame.current) + gameRunner.setMatch(editGame.current.currentMatch) const turn = editGame.current.currentMatch!.currentTurn const brushes = turn.map.getEditorBrushes().concat(turn.bodies.getEditorBrushes(turn.map.staticMap)) @@ -114,11 +111,7 @@ export const MapEditorPage: React.FC = (props) => { setBrushes(brushes) setCleared(turn.bodies.isEmpty() && turn.map.isEmpty()) } else { - context.setState((prevState) => ({ - ...prevState, - activeGame: undefined, - activeMatch: undefined - })) + gameRunner.setGame(undefined) } }, [mapParams, props.open]) diff --git a/client/src/components/sidebar/queue/queue.tsx b/client/src/components/sidebar/queue/queue.tsx index bc172134..2e3b763c 100644 --- a/client/src/components/sidebar/queue/queue.tsx +++ b/client/src/components/sidebar/queue/queue.tsx @@ -6,6 +6,7 @@ import { Button } from '../../button' import { FiUpload } from 'react-icons/fi' import Game from '../../../playback/Game' import { QueuedGame } from './queue-game' +import gameRunner from '../../../playback/GameRunner' interface Props { open: boolean @@ -31,10 +32,9 @@ export const QueuePage: React.FC = (props) => { context.setState((prevState) => ({ ...prevState, - queue: queue.concat([game]), - activeGame: game, - activeMatch: selectedMatch + queue: queue.concat([game]) })) + gameRunner.selectMatch(selectedMatch) } reader.readAsArrayBuffer(file) } diff --git a/client/src/components/sidebar/runner/scaffold.ts b/client/src/components/sidebar/runner/scaffold.ts index df99530b..cf7721cf 100644 --- a/client/src/components/sidebar/runner/scaffold.ts +++ b/client/src/components/sidebar/runner/scaffold.ts @@ -8,6 +8,7 @@ import { useAppContext } from '../../../app-context' import Game from '../../../playback/Game' import Match from '../../../playback/Match' import { RingBuffer } from '../../../util/ring-buffer' +import gameRunner from '../../../playback/GameRunner' export type JavaInstall = { display: string @@ -138,18 +139,14 @@ export function useScaffold(): Scaffold { const onGameCreated = (game: Game) => { appContext.setState((prevState) => ({ ...prevState, - queue: prevState.queue.concat([game]), - activeGame: game, - activeMatch: game.currentMatch + queue: prevState.queue.concat([game]) })) + gameRunner.setGame(game) + gameRunner.setMatch(game.currentMatch) } const onMatchCreated = (match: Match) => { - appContext.setState((prevState) => ({ - ...prevState, - activeGame: match.game, - activeMatch: match - })) + gameRunner.selectMatch(match) } const onGameComplete = (game: Game) => { @@ -164,9 +161,9 @@ export function useScaffold(): Scaffold { appContext.setState((prevState) => ({ ...prevState, queue: prevState.queue.find((g) => g == game) ? prevState.queue : prevState.queue.concat([game]), - activeGame: game, - activeMatch: game.currentMatch })) + gameRunner.setGame(game) + gameRunner.setMatch(game.currentMatch) } setWebSocketListener( diff --git a/client/src/components/sidebar/sidebar.tsx b/client/src/components/sidebar/sidebar.tsx index 33c07c69..2760ceb6 100644 --- a/client/src/components/sidebar/sidebar.tsx +++ b/client/src/components/sidebar/sidebar.tsx @@ -18,6 +18,7 @@ import { useScaffold } from './runner/scaffold' import { ConfigPage } from '../../client-config' import { UpdateWarning } from './update-warning' import Game from '../../playback/Game' +import gameRunner from '../../playback/GameRunner' export const Sidebar: React.FC = () => { const { width, height } = useWindowDimensions() @@ -92,13 +93,10 @@ export const Sidebar: React.FC = () => { const loadedGame = Game.loadFullGameRaw(buffer) // select the first match - const selectedMatch = loadedGame.matches[0] - loadedGame.currentMatch = selectedMatch + gameRunner.selectMatch(loadedGame.matches[0]) context.setState((prevState) => ({ ...prevState, - activeGame: loadedGame, - activeMatch: loadedGame.currentMatch, queue: context.state.queue.concat([loadedGame]), loadingRemoteContent: '' })) From 72f8daa0de007fe8fa25d27fc0717d4d3ba6a104 Mon Sep 17 00:00:00 2001 From: Aidan Blum Levine Date: Tue, 10 Sep 2024 23:16:19 -0400 Subject: [PATCH 4/5] clean up some events --- client/src/app-events.tsx | 1 - .../components/sidebar/queue/queue-game.tsx | 14 +---- .../src/components/sidebar/runner/scaffold.ts | 13 +---- .../components/sidebar/runner/websocket.ts | 8 +-- client/src/playback/GameRunner.ts | 53 ++++++++----------- client/src/playback/Match.ts | 3 +- 6 files changed, 33 insertions(+), 59 deletions(-) diff --git a/client/src/app-events.tsx b/client/src/app-events.tsx index 4a8e82ff..5c22aaed 100644 --- a/client/src/app-events.tsx +++ b/client/src/app-events.tsx @@ -1,7 +1,6 @@ import { useEffect } from 'react' export enum EventType { - TURN_PROGRESS = 'turnprogress', TILE_CLICK = 'tileclick', TILE_DRAG = 'TILE_DRAG', CANVAS_RIGHT_CLICK = 'CANVAS_RIGHT_CLICK', diff --git a/client/src/components/sidebar/queue/queue-game.tsx b/client/src/components/sidebar/queue/queue-game.tsx index ff63ca18..7b1fd401 100644 --- a/client/src/components/sidebar/queue/queue-game.tsx +++ b/client/src/components/sidebar/queue/queue-game.tsx @@ -1,11 +1,10 @@ import React, { useState } from 'react' import Game from '../../../playback/Game' -import Match from '../../../playback/Match' import { useAppContext } from '../../../app-context' import { IconContext } from 'react-icons' import { IoCloseCircle, IoCloseCircleOutline } from 'react-icons/io5' import { schema } from 'battlecode-schema' -import gameRunner, { useGame } from '../../../playback/GameRunner' +import gameRunner from '../../../playback/GameRunner' import { useMatch } from '../../../playback/GameRunner' interface Props { @@ -18,15 +17,6 @@ export const QueuedGame: React.FC = (props) => { const isTournamentMode = context.state.tournament !== undefined const [hoveredClose, setHoveredClose] = useState(false) - const setMatch = (match: Match) => { - match.jumpToTurn(0) - context.setState((prevState) => ({ - ...prevState, - })) - - gameRunner.selectMatch(match) - } - const close = () => { context.setState((prevState) => ({ ...prevState, @@ -70,7 +60,7 @@ export const QueuedGame: React.FC = (props) => { 'bg-light hover:bg-lightHighlight cursor-pointer ' + (activeMatch === match ? 'bg-lightHighlight hover:bg-medHighlight' : '') } - onClick={() => setMatch(match)} + onClick={() => gameRunner.selectMatch(match)} > {match.map.name} {!isTournamentMode && ( diff --git a/client/src/components/sidebar/runner/scaffold.ts b/client/src/components/sidebar/runner/scaffold.ts index cf7721cf..1cb84a24 100644 --- a/client/src/components/sidebar/runner/scaffold.ts +++ b/client/src/components/sidebar/runner/scaffold.ts @@ -150,20 +150,11 @@ export function useScaffold(): Scaffold { } const onGameComplete = (game: Game) => { - // Reset all matches to beginning - for (const match of game.matches) { - match.jumpToTurn(0, true) - } - - // Start at first match - game.currentMatch = game.matches[0] - appContext.setState((prevState) => ({ ...prevState, - queue: prevState.queue.find((g) => g == game) ? prevState.queue : prevState.queue.concat([game]), + queue: prevState.queue.find((g) => g == game) ? prevState.queue : prevState.queue.concat([game]) })) - gameRunner.setGame(game) - gameRunner.setMatch(game.currentMatch) + if (game.matches.length > 0) gameRunner.selectMatch(game.matches[0]) } setWebSocketListener( diff --git a/client/src/components/sidebar/runner/websocket.ts b/client/src/components/sidebar/runner/websocket.ts index 461dae9c..cbd3add0 100644 --- a/client/src/components/sidebar/runner/websocket.ts +++ b/client/src/components/sidebar/runner/websocket.ts @@ -3,6 +3,7 @@ import Game from '../../../playback/Game' import Match from '../../../playback/Match' import assert from 'assert' import { EventType, publishEvent } from '../../../app-events' +import gameRunner from '../../../playback/GameRunner' export type FakeGameWrapper = { events: (index: number, unusedEventSlot: any) => schema.EventWrapper | null @@ -66,8 +67,8 @@ export default class WebSocketListener { match.jumpToTurn(match.maxTurn - 1, true) this.lastSetTurn = match.currentTurn.turnNumber } else { - // Publish anyways so the control bar updates - publishEvent(EventType.TURN_PROGRESS, {}) + // Publish so the control bar updates + gameRunner.onTurnChanged() } } @@ -110,7 +111,8 @@ export default class WebSocketListener { break } case schema.Event.GameFooter: { - publishEvent(EventType.TURN_PROGRESS, {}) + // Publish so the control bar updates + gameRunner.onTurnChanged() this.onGameComplete(this.activeGame!) this.reset() diff --git a/client/src/playback/GameRunner.ts b/client/src/playback/GameRunner.ts index d8ecc49a..5b26c308 100644 --- a/client/src/playback/GameRunner.ts +++ b/client/src/playback/GameRunner.ts @@ -13,18 +13,14 @@ class GameRunner { _controlListeners: (() => void)[] = [] game: Game | undefined = undefined - _gameListeners: ((game: Game | undefined) => void)[] = [] + _gameListeners: (() => void)[] = [] match: Match | undefined = undefined - _matchListeners: ((match: Match | undefined) => void)[] = [] - _turnListeners: ((turn: Turn | undefined) => void)[] = [] + _matchListeners: (() => void)[] = [] + _turnListeners: (() => void)[] = [] eventLoop: NodeJS.Timeout | undefined = undefined - constructor() { - document.addEventListener(EventType.TURN_PROGRESS as string, () => this._updateTurnListeners()) - } - - startEventLoop(): void { + private startEventLoop(): void { if (this.eventLoop) throw new Error('Event loop already exists') this.eventLoop = setInterval(() => { @@ -51,7 +47,7 @@ class GameRunner { }, SIMULATION_UPDATE_INTERVAL_MS) } - shutDownEventLoop(): void { + private shutDownEventLoop(): void { if (!this.eventLoop) throw new Error('Event loop does not exist') // Snap bots to their actual position when paused by rounding simulation to the true turn if (this.match) { @@ -62,7 +58,7 @@ class GameRunner { this.eventLoop = undefined } - updateEventLoop(): void { + private updateEventLoop(): void { if (this.match && !this.paused && !this.eventLoop) { this.startEventLoop() } else if (this.eventLoop) { @@ -70,29 +66,28 @@ class GameRunner { } } - _updateTurnListeners(): void { - this._turnListeners.forEach((listener) => listener(this.match?.currentTurn)) + onTurnChanged(): void { + this._turnListeners.forEach((l) => l()) } setGame(game: Game | undefined): void { this.game = game - this._gameListeners.forEach((listener) => listener(game)) + this._gameListeners.forEach((l) => l()) if (!game && this.match) { this.setMatch(undefined) } - this._updateTurnListeners() + this.onTurnChanged() } setMatch(match: Match | undefined): void { this.match = match + if (match) match.jumpToTurn(0) this.paused = true - this._updateControlListeners() - this._matchListeners.forEach((listener) => listener(match)) - if (!this.game && match) { - this.setGame(match.game) - } + this._controlListeners.forEach((l) => l()) + this._matchListeners.forEach((l) => l()) + if (!this.game && match) this.setGame(match.game) this.updateEventLoop() - this._updateTurnListeners() + this.onTurnChanged() } selectMatch(match: Match): void { @@ -100,16 +95,12 @@ class GameRunner { this.setMatch(match) } - _updateControlListeners(): void { - this._controlListeners.forEach((listener) => listener()) - } - multiplyUpdatesPerSecond(multiplier: number) { if (!this.match) return const scaled = this.targetUPS * multiplier const newMag = Math.max(1 / 4, Math.min(64, Math.abs(scaled))) this.targetUPS = Math.sign(scaled) * newMag - this._updateControlListeners() + this._controlListeners.forEach((l) => l()) } setPaused(paused: boolean): void { @@ -117,7 +108,7 @@ class GameRunner { this.paused = paused if (!paused && this.targetUPS == 0) this.targetUPS = 1 this.updateEventLoop() - this._updateControlListeners() + this._controlListeners.forEach((l) => l()) } stepTurn(delta: number) { @@ -160,7 +151,7 @@ const gameRunner = new GameRunner() function useGame(): Game | undefined { const [game, setGame] = React.useState(gameRunner.game) React.useEffect(() => { - const listener = (game: Game | undefined) => setGame(game) + const listener = () => setGame(gameRunner.game) gameRunner._gameListeners.push(listener) return () => { gameRunner._gameListeners = gameRunner._gameListeners.filter((l) => l !== listener) @@ -172,7 +163,7 @@ function useGame(): Game | undefined { function useMatch(): Match | undefined { const [match, setMatch] = React.useState(gameRunner.match) React.useEffect(() => { - const listener = (match: Match | undefined) => setMatch(match) + const listener = () => setMatch(gameRunner.match) gameRunner._matchListeners.push(listener) return () => { gameRunner._matchListeners = gameRunner._matchListeners.filter((l) => l !== listener) @@ -186,9 +177,9 @@ function useTurn(): Turn | undefined { // since turn objects are reused, we need to update when the turn number changes to force a re-render const [turnNumber, setTurnNumber] = React.useState(gameRunner.match?.currentTurn?.turnNumber) React.useEffect(() => { - const listener = (turn: Turn | undefined) => { - setTurn(turn) - setTurnNumber(turn?.turnNumber) + const listener = () => { + setTurn(gameRunner.match?.currentTurn) + setTurnNumber(gameRunner.match?.currentTurn?.turnNumber) } gameRunner._turnListeners.push(listener) return () => { diff --git a/client/src/playback/Match.ts b/client/src/playback/Match.ts index 0ddb1fe0..40237b96 100644 --- a/client/src/playback/Match.ts +++ b/client/src/playback/Match.ts @@ -7,6 +7,7 @@ import { CurrentMap, StaticMap } from './Map' import Actions from './Actions' import Bodies from './Bodies' import { publishEvent, EventType } from '../app-events' +import gameRunner from './GameRunner' // Amount of turns before a snapshot of the game state is saved for the next recalculation const SNAPSHOT_EVERY = 50 @@ -209,7 +210,7 @@ export default class Match { } this.currentTurn = updatingTurn - publishEvent(EventType.TURN_PROGRESS, {}) + gameRunner.onTurnChanged() if (rerender) this.rerender() } } From ae6e70629f4970b80e52546ea23ca58aea32072a Mon Sep 17 00:00:00 2001 From: Aidan Blum Levine Date: Wed, 11 Sep 2024 11:44:35 -0400 Subject: [PATCH 5/5] remove redundant match state --- client/src/playback/GameRunner.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/client/src/playback/GameRunner.ts b/client/src/playback/GameRunner.ts index 5b26c308..0e09b53f 100644 --- a/client/src/playback/GameRunner.ts +++ b/client/src/playback/GameRunner.ts @@ -10,11 +10,14 @@ class GameRunner { targetUPS: number = 1 currentUPSBuffer: number[] = [] paused: boolean = true - _controlListeners: (() => void)[] = [] game: Game | undefined = undefined + get match(): Match | undefined { + return this.game?.currentMatch + } + + _controlListeners: (() => void)[] = [] _gameListeners: (() => void)[] = [] - match: Match | undefined = undefined _matchListeners: (() => void)[] = [] _turnListeners: (() => void)[] = [] @@ -71,6 +74,7 @@ class GameRunner { } setGame(game: Game | undefined): void { + if (this.game == game) return this.game = game this._gameListeners.forEach((l) => l()) if (!game && this.match) { @@ -80,12 +84,15 @@ class GameRunner { } setMatch(match: Match | undefined): void { - this.match = match - if (match) match.jumpToTurn(0) + if (this.match == match) return + if (match) { + match.game.currentMatch = match + this.setGame(match.game) + match.jumpToTurn(0) + } this.paused = true this._controlListeners.forEach((l) => l()) this._matchListeners.forEach((l) => l()) - if (!this.game && match) this.setGame(match.game) this.updateEventLoop() this.onTurnChanged() }