Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Client refactor 2025 #80

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 0 additions & 8 deletions client/src/app-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down
1 change: 0 additions & 1 deletion client/src/app-events.tsx
Original file line number Diff line number Diff line change
@@ -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',
Expand Down
20 changes: 11 additions & 9 deletions client/src/components/controls-bar/controls-bar-timeline.tsx
Original file line number Diff line number Diff line change
@@ -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<Props> = ({ currentUPS }) => {
export const ControlsBarTimeline: React.FC<Props> = ({ currentUPS, targetUPS }) => {
const appContext = useAppContext()
const match = useMatch()

let down = useRef(false)
const timelineHover = (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
Expand All @@ -34,25 +37,25 @@ export const ControlsBarTimeline: React.FC<Props> = ({ 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<HTMLDivElement, 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 (
<div className="min-h-[30px] bg-bg rounded-md mr-2 relative" style={{ minWidth: TIMELINE_WIDTH }}>
<p className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-[9px] text-xs pointer-events-none">
Expand All @@ -62,13 +65,12 @@ export const ControlsBarTimeline: React.FC<Props> = ({ currentUPS }) => {
</div>
)

const turn = appContext.state.activeGame!.currentMatch!.currentTurn.turnNumber
const turn = match!.currentTurn.turnNumber
const turnPercentage = () => (1 - turn / maxTurn) * 100 + '%'
return (
<div className="min-h-[30px] bg-bg rounded-md mr-2 relative" style={{ minWidth: TIMELINE_WIDTH }}>
<p className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-[10px] text-xs select-none whitespace-nowrap">
Turn: <b>{turn}</b>/{maxTurn} &nbsp; {appContext.state.updatesPerSecond} UPS (
{appContext.state.updatesPerSecond < 0 && '-'}
Turn: <b>{turn}</b>/{maxTurn} &nbsp; {targetUPS} UPS ({targetUPS < 0 && '-'}
{currentUPS})
</p>
<div className="absolute bg-white/10 left-0 right-0 bottom-0 min-h-[5px] rounded"></div>
Expand Down
184 changes: 35 additions & 149 deletions client/src/components/controls-bar/controls-bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<number[]>([])

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
Expand All @@ -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
Expand All @@ -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 (
<div
Expand All @@ -200,74 +82,78 @@ export const ControlsBar: React.FC = () => {
' flex bg-darkHighlight text-white p-1.5 rounded-t-md z-10 gap-1.5 relative'
}
>
<ControlsBarTimeline currentUPS={currentUPSBuffer.current.length} />
<ControlsBarTimeline currentUPS={currentUPS} targetUPS={targetUPS} />
<ControlsBarButton
icon={<ControlIcons.ReverseIcon />}
tooltip="Reverse"
onClick={() => multiplyUpdatesPerSecond(-1)}
onClick={() => gameRunner.multiplyUpdatesPerSecond(-1)}
/>
<ControlsBarButton
icon={<ControlIcons.SkipBackwardsIcon />}
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}
/>
<ControlsBarButton
icon={<ControlIcons.GoPreviousIcon />}
tooltip="Step Backwards"
onClick={() => stepTurn(-1)}
onClick={() => gameRunner.stepTurn(-1)}
disabled={atStart}
/>
{appState.paused ? (
{paused ? (
<ControlsBarButton
icon={<ControlIcons.PlaybackPlayIcon />}
tooltip="Play"
onClick={() => {
changePaused(false)
gameRunner.setPaused(false)
}}
/>
) : (
<ControlsBarButton
icon={<ControlIcons.PlaybackPauseIcon />}
tooltip="Pause"
onClick={() => {
changePaused(true)
gameRunner.setPaused(true)
}}
/>
)}
<ControlsBarButton
icon={<ControlIcons.GoNextIcon />}
tooltip="Next Turn"
onClick={() => stepTurn(1)}
onClick={() => gameRunner.stepTurn(1)}
disabled={atEnd}
/>
<ControlsBarButton
icon={<ControlIcons.SkipForwardsIcon />}
tooltip={'Increase Speed'}
onClick={() => multiplyUpdatesPerSecond(2)}
disabled={Math.abs(appState.updatesPerSecond) >= 64}
onClick={() => gameRunner.multiplyUpdatesPerSecond(2)}
disabled={Math.abs(targetUPS) >= 64}
/>
<ControlsBarButton
icon={<ControlIcons.PlaybackStopIcon />}
tooltip="Jump To Start"
onClick={() => jumpToTurn(0)}
onClick={() => gameRunner.jumpToTurn(0)}
disabled={atStart}
/>
<ControlsBarButton
icon={<ControlIcons.GoEndIcon />}
tooltip="Jump To End"
onClick={jumpToEnd}
onClick={() => gameRunner.jumpToEnd()}
disabled={atEnd}
/>
{appState.tournament && (
<>
<ControlsBarButton
icon={<ControlIcons.NextMatch />}
tooltip="Next Match"
onClick={nextMatch}
onClick={() => gameRunner.nextMatch()}
disabled={!hasNextMatch}
/>
<ControlsBarButton icon={<ControlIcons.CloseGame />} tooltip="Close Game" onClick={closeGame} />
<ControlsBarButton
icon={<ControlIcons.CloseGame />}
tooltip="Close Game"
onClick={() => gameRunner.setGame(undefined)}
/>
</>
)}
</div>
Expand Down
Loading