diff --git a/client/src/app-context.tsx b/client/src/app-context.tsx index cea23d2f..d40acbfc 100644 --- a/client/src/app-context.tsx +++ b/client/src/app-context.tsx @@ -6,26 +6,18 @@ import { ClientConfig, getDefaultConfig } from './client-config' export interface AppState { queue: Game[] - activeGame: Game | undefined - activeMatch: Match | undefined tournament: Tournament | undefined tournamentState: TournamentState loadingRemoteContent: string - updatesPerSecond: number - paused: boolean disableHotkeys: boolean config: ClientConfig } const DEFAULT_APP_STATE: AppState = { queue: [], - activeGame: undefined, - activeMatch: undefined, tournament: undefined, tournamentState: DEFAULT_TOURNAMENT_STATE, loadingRemoteContent: '', - updatesPerSecond: 1, - paused: true, disableHotkeys: false, config: getDefaultConfig() } 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/controls-bar/controls-bar-timeline.tsx b/client/src/components/controls-bar/controls-bar-timeline.tsx index c1e8583d..96a0a4ea 100644 --- a/client/src/components/controls-bar/controls-bar-timeline.tsx +++ b/client/src/components/controls-bar/controls-bar-timeline.tsx @@ -1,13 +1,16 @@ import React, { useRef } from 'react' import { useAppContext } from '../../app-context' +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() let down = useRef(false) const timelineHover = (e: React.MouseEvent) => { @@ -34,25 +37,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,13 +65,12 @@ 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 (

- 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 07a1a039..baba767d 100644 --- a/client/src/components/controls-bar/controls-bar.tsx +++ b/client/src/components/controls-bar/controls-bar.tsx @@ -4,132 +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' - -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 { 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 = 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 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 { paused, currentUPS, targetUPS } = useControls() - 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 game = appState.activeGame! - const prevMatch = game.currentMatch! - const prevMatchIndex = game.matches.indexOf(prevMatch) - if (prevMatchIndex + 1 == game.matches.length) { - closeGame() - return - } - - game.currentMatch = game.matches[prevMatchIndex + 1] - setAppState((prevState) => ({ - ...prevState, - activeGame: game, - activeMatch: game.currentMatch - })) - } - - const closeGame = () => { - setAppState((prevState) => ({ - ...prevState, - activeGame: undefined, - activeMatch: undefined - })) - if (appState.tournament) setPage(PageType.TOURNAMENT) - } - - 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, appState.activeGame, currentMatch, appState.paused]) + const hasNextMatch = turn && turn?.match.game.matches.indexOf(turn.match!) + 1 < turn.match.game.matches.length useEffect(() => { if (appState.disableHotkeys) return @@ -139,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 @@ -170,13 +55,10 @@ export const ControlsBar: React.FC = () => { } }, [keyboard.keyCode]) - const forceUpdate = useForceUpdate() - useListenEvent(EventType.TURN_PROGRESS, forceUpdate) + if (!turn) return null - 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 == 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) }} /> ) : ( @@ -231,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 && ( @@ -264,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/components/game/game-area.tsx b/client/src/components/game/game-area.tsx index 65df02cd..30fe8561 100644 --- a/client/src/components/game/game-area.tsx +++ b/client/src/components/game/game-area.tsx @@ -2,9 +2,11 @@ import React from 'react' import { GameRenderer } from './game-renderer' import { useAppContext } from '../../app-context' import { TournamentRenderer } from './tournament-renderer/tournament-renderer' +import { useGame } from '../../playback/GameRunner' export const GameArea: React.FC = () => { 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/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 35517a9f..a2a1f848 100644 --- a/client/src/components/sidebar/game/game.tsx +++ b/client/src/components/sidebar/game/game.tsx @@ -7,11 +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, useTurn } from '../../../playback/GameRunner' const NO_GAME_TEAM_NAME = '?????' @@ -21,44 +20,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 +86,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 +105,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..ed963cbe 100644 --- a/client/src/components/sidebar/game/histogram.tsx +++ b/client/src/components/sidebar/game/histogram.tsx @@ -1,22 +1,16 @@ 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' - -function getChartData(appContext: AppContext): number[][][] { - const match = appContext.state.activeMatch - if (match === undefined) { - return [] - } +import { SPECIALTY_COLORS, TEAM_COLORS } from '../../../constants' +import { useTurn } from '../../../playback/GameRunner' +import Turn from '../../../playback/Turn' +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 +25,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/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/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-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 2f6d9d70..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,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 gameRunner, { 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 @@ -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]) @@ -181,7 +174,7 @@ export const MapEditorPage: React.FC = (props) => {
{ - if (!context.state.activeMatch?.currentTurn) return + if (!turn) return setMapNameOpen(true) }} > @@ -199,7 +192,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..7b1fd401 100644 --- a/client/src/components/sidebar/queue/queue-game.tsx +++ b/client/src/components/sidebar/queue/queue-game.tsx @@ -1,10 +1,11 @@ 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 from '../../../playback/GameRunner' +import { useMatch } from '../../../playback/GameRunner' interface Props { game: Game @@ -12,26 +13,17 @@ 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 - })) - } - 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,9 +58,9 @@ 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)} + onClick={() => gameRunner.selectMatch(match)} > {match.map.name} {!isTournamentMode && ( 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..1cb84a24 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,35 +139,22 @@ 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) => { - // 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]), - activeGame: game, - activeMatch: game.currentMatch + queue: prevState.queue.find((g) => g == game) ? prevState.queue : prevState.queue.concat([game]) })) + 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/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: '' })) 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..0e09b53f --- /dev/null +++ b/client/src/playback/GameRunner.ts @@ -0,0 +1,222 @@ +import React from 'react' +import Game from './Game' +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 + + game: Game | undefined = undefined + get match(): Match | undefined { + return this.game?.currentMatch + } + + _controlListeners: (() => void)[] = [] + _gameListeners: (() => void)[] = [] + _matchListeners: (() => void)[] = [] + _turnListeners: (() => void)[] = [] + + eventLoop: NodeJS.Timeout | undefined = undefined + + private 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) + } + + 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) { + this.match.roundSimulation() + this.match.rerender() + } + clearInterval(this.eventLoop) + this.eventLoop = undefined + } + + private updateEventLoop(): void { + if (this.match && !this.paused && !this.eventLoop) { + this.startEventLoop() + } else if (this.eventLoop) { + this.shutDownEventLoop() + } + } + + onTurnChanged(): void { + this._turnListeners.forEach((l) => l()) + } + + setGame(game: Game | undefined): void { + if (this.game == game) return + this.game = game + this._gameListeners.forEach((l) => l()) + if (!game && this.match) { + this.setMatch(undefined) + } + this.onTurnChanged() + } + + setMatch(match: Match | undefined): void { + 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()) + this.updateEventLoop() + this.onTurnChanged() + } + + selectMatch(match: Match): void { + match.game.currentMatch = match + this.setMatch(match) + } + + 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._controlListeners.forEach((l) => l()) + } + + setPaused(paused: boolean): void { + if (!this.match) return + this.paused = paused + if (!paused && this.targetUPS == 0) this.targetUPS = 1 + this.updateEventLoop() + this._controlListeners.forEach((l) => l()) + } + + 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() + +function useGame(): Game | undefined { + const [game, setGame] = React.useState(gameRunner.game) + React.useEffect(() => { + const listener = () => setGame(gameRunner.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 = () => setMatch(gameRunner.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 = () => { + setTurn(gameRunner.match?.currentTurn) + setTurnNumber(gameRunner.match?.currentTurn?.turnNumber) + } + gameRunner._turnListeners.push(listener) + return () => { + gameRunner._turnListeners = gameRunner._turnListeners.filter((l) => l !== listener) + } + }, []) + 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, useControls } 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() } }