first prototype
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type {
|
||||
Action,
|
||||
ClientMessage,
|
||||
GameOptions,
|
||||
GameView,
|
||||
Lobby,
|
||||
RoundResult,
|
||||
ServerMessage,
|
||||
TrickResult,
|
||||
} from './types'
|
||||
|
||||
export type ConnectionStatus = 'connecting' | 'open' | 'closed' | 'replaced'
|
||||
|
||||
export interface Toast {
|
||||
id: number
|
||||
message: string
|
||||
}
|
||||
|
||||
// Per tab, so several tabs can act as different players; survives reloads.
|
||||
const TOKEN_KEY = 'wizard.token'
|
||||
const TRICK_DISPLAY_MS = 2200
|
||||
const ROUND_DISPLAY_MS = 5000
|
||||
const TOAST_MS = 4000
|
||||
const MAX_RETRY_DELAY_MS = 10000
|
||||
// Close code the server uses when the session was opened in another tab.
|
||||
const CLOSE_REPLACED = 4000
|
||||
|
||||
function websocketUrl(): string {
|
||||
const configured = import.meta.env.VITE_WS_URL as string | undefined
|
||||
if (configured) return configured
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
return `${protocol}//${window.location.host}/api/ws`
|
||||
}
|
||||
|
||||
function readToken(): string | null {
|
||||
try {
|
||||
return sessionStorage.getItem(TOKEN_KEY)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function storeToken(token: string) {
|
||||
try {
|
||||
sessionStorage.setItem(TOKEN_KEY, token)
|
||||
} catch {
|
||||
// Storage unavailable: reconnecting after a reload will start a new session.
|
||||
}
|
||||
}
|
||||
|
||||
export function useWizard() {
|
||||
const [status, setStatus] = useState<ConnectionStatus>('connecting')
|
||||
const [playerId, setPlayerId] = useState<string | null>(null)
|
||||
const [lobby, setLobby] = useState<Lobby | null>(null)
|
||||
const [game, setGame] = useState<GameView | null>(null)
|
||||
// The most recently completed trick, shown for a moment before it is cleared.
|
||||
const [completedTrick, setCompletedTrick] = useState<TrickResult | null>(null)
|
||||
// Result of the round that just ended, shown as a short summary.
|
||||
const [completedRound, setCompletedRound] = useState<RoundResult | null>(null)
|
||||
const [toasts, setToasts] = useState<Toast[]>([])
|
||||
|
||||
const socketRef = useRef<WebSocket | null>(null)
|
||||
const toastId = useRef(0)
|
||||
|
||||
const showError = useCallback((message: string) => {
|
||||
const id = ++toastId.current
|
||||
setToasts((current) => [...current, { id, message }])
|
||||
setTimeout(() => setToasts((current) => current.filter((toast) => toast.id !== id)), TOAST_MS)
|
||||
}, [])
|
||||
|
||||
const dismissToast = useCallback((id: number) => {
|
||||
setToasts((current) => current.filter((toast) => toast.id !== id))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false
|
||||
let attempt = 0
|
||||
let retryTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let trickTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let roundTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const handleMessage = (message: ServerMessage) => {
|
||||
switch (message.type) {
|
||||
case 'welcome':
|
||||
storeToken(message.token)
|
||||
setPlayerId(message.player_id)
|
||||
break
|
||||
case 'state': {
|
||||
setLobby(message.lobby)
|
||||
setGame(message.game)
|
||||
const trick = message.events.find((event) => event.type === 'trick_completed')
|
||||
if (trick) {
|
||||
setCompletedTrick(trick.trick)
|
||||
clearTimeout(trickTimer)
|
||||
trickTimer = setTimeout(() => setCompletedTrick(null), TRICK_DISPLAY_MS)
|
||||
}
|
||||
const round = message.events.find((event) => event.type === 'round_completed')
|
||||
if (round) {
|
||||
setCompletedRound(round.result)
|
||||
clearTimeout(roundTimer)
|
||||
roundTimer = setTimeout(() => setCompletedRound(null), ROUND_DISPLAY_MS)
|
||||
}
|
||||
if (!message.game) {
|
||||
setCompletedTrick(null)
|
||||
setCompletedRound(null)
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'error':
|
||||
showError(message.message)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const connect = () => {
|
||||
const socket = new WebSocket(websocketUrl())
|
||||
socketRef.current = socket
|
||||
|
||||
socket.onopen = () => {
|
||||
attempt = 0
|
||||
setStatus('open')
|
||||
socket.send(JSON.stringify({ type: 'hello', token: readToken() } satisfies ClientMessage))
|
||||
}
|
||||
socket.onmessage = (event) => {
|
||||
try {
|
||||
handleMessage(JSON.parse(event.data as string) as ServerMessage)
|
||||
} catch (error) {
|
||||
console.error('Invalid message from server', error)
|
||||
}
|
||||
}
|
||||
socket.onclose = (event) => {
|
||||
if (disposed) return
|
||||
socketRef.current = null
|
||||
if (event.code === CLOSE_REPLACED) {
|
||||
setStatus('replaced')
|
||||
return
|
||||
}
|
||||
setStatus('closed')
|
||||
const delay = Math.min(1000 * 2 ** attempt, MAX_RETRY_DELAY_MS)
|
||||
attempt += 1
|
||||
retryTimer = setTimeout(() => {
|
||||
setStatus('connecting')
|
||||
connect()
|
||||
}, delay)
|
||||
}
|
||||
}
|
||||
|
||||
connect()
|
||||
return () => {
|
||||
disposed = true
|
||||
clearTimeout(retryTimer)
|
||||
clearTimeout(trickTimer)
|
||||
clearTimeout(roundTimer)
|
||||
socketRef.current?.close()
|
||||
socketRef.current = null
|
||||
}
|
||||
}, [showError])
|
||||
|
||||
const send = useCallback(
|
||||
(message: ClientMessage) => {
|
||||
const socket = socketRef.current
|
||||
if (socket?.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify(message))
|
||||
} else {
|
||||
showError('Not connected to the server')
|
||||
}
|
||||
},
|
||||
[showError],
|
||||
)
|
||||
|
||||
const actions = useMemo(
|
||||
() => ({
|
||||
createLobby: (name: string) => send({ type: 'create_lobby', name }),
|
||||
joinLobby: (code: string, name: string) => send({ type: 'join_lobby', code, name }),
|
||||
leaveLobby: () => send({ type: 'leave_lobby' }),
|
||||
addAI: () => send({ type: 'add_ai' }),
|
||||
removePlayer: (playerId: string) => send({ type: 'remove_player', player_id: playerId }),
|
||||
setOptions: (options: GameOptions) => send({ type: 'set_options', ...options }),
|
||||
startGame: () => send({ type: 'start_game' }),
|
||||
play: (action: Action) => send({ type: 'action', action }),
|
||||
returnToLobby: () => send({ type: 'return_to_lobby' }),
|
||||
}),
|
||||
[send],
|
||||
)
|
||||
|
||||
return { status, playerId, lobby, game, completedTrick, completedRound, toasts, dismissToast, actions }
|
||||
}
|
||||
|
||||
export type WizardActions = ReturnType<typeof useWizard>['actions']
|
||||
Reference in New Issue
Block a user