tome-web/client/game/useServerSocket.ts

288 lines
7.6 KiB
TypeScript

import React from 'react'
import useWebSocket from 'react-use-websocket'
import assertNever from '~/common/assertNever.ts'
import { AsyncHandle, GameCommand, GameAction } from './types.ts'
const WS_URL = Deno.env.get("WS_URL")
const MY_ID = (function(){
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
})
})()
function shouldReconnect() {
console.log('Reconnecting...')
return true
}
// session commands
// query, join, leave, play (game command), poll
interface SessionCommandAPI {
query: () => void
join: () => void
leave: () => void
play: () => void
poll: () => void
ready: () => void
}
interface SocketMessage {
command: keyof SessionCommandAPI
// required unless command === 'query'
match_id?: string
player_id?: string
// only present if command === 'play'
game_command?: GameCommand
}
type GameSocketSessionState =
| { status: 'not-connected' }
| { status: 'connecting' }
| { status: 'finding-game' }
| { status: 'joining-game'; playerId: string; matchId: string }
| { status: 'pre-game'; playerId: string; matchId: string }
| { status: 'in-game'; playerId: string; matchId: string }
type Action =
| { type: 'open' }
| { type: 'close' }
| { type: 'find-game'; playerId: string; matchId: string }
| { type: 'join-game' }
| { type: 'ready-game' }
function reducer(state: GameSocketSessionState, action: Action): GameSocketSessionState {
switch (action.type) {
case 'open': {
if (state.status !== 'connecting') return state
return { status: 'finding-game' }
}
case 'find-game': {
if (state.status !== 'finding-game') return state
return { status: 'joining-game', matchId: action.matchId, playerId: action.playerId }
}
case 'join-game': {
if (state.status !== 'joining-game') return state
return { ...state, status: 'pre-game' }
}
case 'ready-game': {
if (state.status !== 'pre-game') return state
return { ...state, status: 'in-game' }
}
case 'close': {
return { status: 'not-connected' }
}
default: return assertNever(action)
}
}
const initialState: GameSocketSessionState = {status: 'connecting'}
type ServerResult =
// session command result
| 'found'
| 'joined p1'
| 'joined p2'
| 'left'
| 'played'
| 'game ready'
| 'generic error'
// poll command result
| 'Scourge joined'
| 'Scourge turn'
| 'Scourge wins'
| 'Scourge player is ready'
| 'Scourge player has left'
| 'Sentinal joined'
| 'Sentinal turn'
| 'Sentinal wins'
| 'Sentinal player is ready'
| 'Sentinal player has left'
| 'update'
interface ServerResponse {
player_id: string
match_id: string
result: ServerResult
game_result: any
}
interface SocketHandle {
sendGameCommand: (command: GameCommand) => void
sendPoll: () => void
}
export default function useServerSocket(
onUpdate: (action: GameAction) => void
): AsyncHandle<SocketHandle> {
const [state, dispatch] = React.useReducer(reducer, initialState)
/* prep socket */
const onMessage = React.useCallback((message: WebSocketEventMap['message']) => {
const data = JSON.parse(message.data) as ServerResponse
switch (data.result) {
case 'found': {
dispatch({
type: 'find-game',
matchId: data.match_id,
playerId: data.player_id,
})
return
}
case 'joined p1': {
onUpdate({ type: 'set-player-team', team: 1 })
dispatch ({ type: 'join-game' })
return
}
case 'joined p2': {
onUpdate({ type: 'set-player-team', team: 2 })
dispatch ({ type: 'join-game' })
return
}
case 'game ready': {
dispatch({ type: 'ready-game' })
return
}
case 'played': {
switch (data.game_result.result_type) {
case 'a': {
const result = data.game_result.action_result
if (result) onUpdate({
type: 'receive-cards',
cards: result.cards,
})
return
}
case 's': {
const result = data.game_result.state_result
if (result) onUpdate({
type: 'update-state',
state: {
board: result.board,
player: result.player,
deckSize: result.deck_size,
enemyLife: result.enemy_life,
enemyDeckSize: result.enemy_deck_size,
enemyHandSize: result.enemy_hand_size,
currentTurn: result.current_turn,
canDraw: result.can_draw,
hasDrawn: result.has_drawn,
gameStatus: result.game_status,
},
})
return
}
default: return
}
}
case 'update': {
// console.log(message)
return;
}
default: return;
}
}, [onUpdate])
const onOpen = React.useCallback(() => {
console.log('socket opened')
dispatch({type: 'open'})
}, [])
const onError = React.useCallback((event: WebSocketEventMap['error']) => {
console.error(event)
}, [])
const onClose = React.useCallback((_event: WebSocketEventMap['close']) => {
console.log('socket closed')
dispatch({type: 'close'})
}, [])
const {sendJsonMessage} = useWebSocket(
WS_URL,
{onMessage, onOpen, onError, onClose, shouldReconnect},
)
// convenient type-safe wrapper
const sendJson = React.useCallback((message: SocketMessage) => {
sendJsonMessage(message)
}, [sendJsonMessage])
const sendGameCommand = React.useCallback((gameCommand: GameCommand) => {
if (state.status !== 'in-game') return
sendJson({
player_id: state.playerId,
match_id: state.matchId,
command: 'play',
game_command: gameCommand,
})
}, [state, sendJson])
const sendPoll = React.useCallback(() => {
if (state.status !== 'in-game') return
sendJson({
player_id: state.playerId,
match_id: state.matchId,
command: 'poll',
})
}, [sendJson, state])
/* effects to push the coordinator along */
React.useEffect(() => {
if (state.status !== 'finding-game') return
sendJson({command: 'query', player_id: MY_ID})
}, [sendJson, state.status])
React.useEffect(() => {
if (state.status !== 'joining-game') return
sendJson({command: 'join', player_id: state.playerId, match_id: state.matchId})
}, [sendJson, state])
React.useEffect(() => {
if (state.status !== 'pre-game') return
const intervalId = setInterval(() => {
sendJson({command: 'ready', player_id: state.playerId, match_id: state.matchId})
}, 200)
return () => {
clearInterval(intervalId)
}
}, [sendJson, state])
React.useEffect(() => {
if (state.status !== 'in-game') return
const leaveGame = () => {
sendJson({command: 'leave', player_id: state.playerId, match_id: state.matchId})
}
addEventListener('beforeunload', leaveGame)
return () => {
removeEventListener('beforeunload', leaveGame)
}
}, [sendJson, state])
/* return game command handler in wrapper */
const handle = React.useMemo<AsyncHandle<SocketHandle>>(() => {
switch (state.status) {
case 'in-game': {
return {status: 'connected', handle: {sendGameCommand, sendPoll}}
}
case 'finding-game':
case 'joining-game':
case 'pre-game': {
return {status: 'connecting'}
}
case 'connecting':
case 'not-connected': {
return state
}
default: return assertNever(state)
}
}, [sendGameCommand])
return handle
}