first prototype

This commit is contained in:
2026-09-27 09:52:00 +02:00
parent 4635199a41
commit 6d011d474f
109 changed files with 6354 additions and 507 deletions
+190
View File
@@ -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']