import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { createInputStateStream, FixedStepClock, type NetworkStats, } from "@syncer/engine"; import { DEAD_AIR_SOCKET_PATH, deadAirGame, type DeadAirClientState, type DeadAirInput, } from "@syncer/shared"; import { DeadAirAudio } from "./dead-air-audio.js"; import type { ConnectionStatus, ValidationStatus } from "./useGameClient.js"; export type DeadAirAction = "interact" | "toggleFlashlight" | "throwDecoy" | "reload"; export interface DeadAirCorrection { x: number; z: number; updatedAt: number; } export interface DeadAirRenderFrame { state: Readonly; interpolationAlpha: number; localCorrection: DeadAirCorrection; } export interface DeadAirRenderSource { readonly current: DeadAirRenderFrame; } export interface DeadAirClientView { connection: ConnectionStatus; validation: ValidationStatus; playerId: number | null; tick: number; inputLeadTicks: number; world: DeadAirClientState; network: NetworkStats; renderSource: DeadAirRenderSource; setAction(action: DeadAirAction, active: boolean): void; unlockAudio(): void; } const emptyNetwork: NetworkStats = { roundTripTime: 0, jitter: 0, clockOffset: 0, samples: 0, }; function neutralInput(): DeadAirInput { return { forward: 0, strafe: 0, yaw: 0, pitch: 0, fire: false, sprint: false, reload: false, interact: false, toggleFlashlight: false, throwDecoy: false, }; } function socketUrls(): string[] { const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; const proxied = `${protocol}//${window.location.host}${DEAD_AIR_SOCKET_PATH}`; if (window.location.protocol !== "http:" || window.location.port !== "5173") return [proxied]; return [`ws://${window.location.hostname}:3001${DEAD_AIR_SOCKET_PATH}`, proxied]; } export function useDeadAirClient(): DeadAirClientView { const engine = useMemo(() => deadAirGame.createClient(), []); const audio = useMemo(() => new DeadAirAudio(), []); const protocol = deadAirGame.protocol; const clock = useMemo( () => new FixedStepClock({ rateHz: deadAirGame.tickRateHz, maxCatchUpSteps: 5 }), [], ); const renderSource = useRef({ state: deadAirGame.client.createInitialState(), interpolationAlpha: 0, localCorrection: { x: 0, z: 0, updatedAt: 0 }, }); const actionRef = useRef<(action: DeadAirAction, active: boolean) => void>(() => undefined); const setAction = useCallback( (action: DeadAirAction, active: boolean) => actionRef.current(action, active), [], ); const unlockAudio = useCallback(() => audio.unlock(), [audio]); const [view, setView] = useState>({ connection: "connecting", validation: "waiting", playerId: null, tick: 0, inputLeadTicks: 1, world: deadAirGame.client.createInitialState(), network: emptyNetwork, }); useEffect(() => { let active = true; let socket: WebSocket | undefined; let retryTimer: number | undefined; let animationFrame: number | undefined; let connection: ConnectionStatus = "connecting"; let validation: ValidationStatus = "waiting"; let input = neutralInput(); const pressed = new Set(); const stream = createInputStateStream(deadAirGame); let lastInputFrame: ArrayBuffer | null = null; let socketUrlIndex = 0; let lastPublishedAt = Number.NEGATIVE_INFINITY; const urls = socketUrls(); const refreshRenderSource = () => { renderSource.current.state = engine.currentState as DeadAirClientState; renderSource.current.interpolationAlpha = clock.interpolationAlpha; }; const publish = (force = false, now = performance.now()) => { if (!active || (!force && now - lastPublishedAt < 100)) return; lastPublishedAt = now; setView({ connection, validation, playerId: engine.localPlayerId, tick: engine.tick, inputLeadTicks: engine.networkClock.recommendedInputLeadTicks(deadAirGame.tickRateHz), world: deadAirGame.client.cloneState(engine.currentState as DeadAirClientState), network: engine.networkClock.stats, }); }; const send = (frame: ArrayBuffer) => { if (socket?.readyState === WebSocket.OPEN) socket.send(frame); }; const sendInput = (force = false) => { if (!engine.initialized) return; stream.update(input); const emission = stream.consume(performance.now(), force); if (!emission) return; if (emission.kind === "state" || !lastInputFrame) { lastInputFrame = protocol.encodeClient({ kind: "input", packet: engine.createInput(emission.input), }); } send(lastInputFrame); }; const updateMovement = () => { input = { ...input, forward: Number(pressed.has("w")) - Number(pressed.has("s")), strafe: Number(pressed.has("d")) - Number(pressed.has("a")), sprint: pressed.has("shift"), }; sendInput(); }; const updateAction = (action: DeadAirAction, value: boolean) => { if (input[action] === value) return; input = { ...input, [action]: value }; sendInput(true); }; actionRef.current = updateAction; const connect = () => { let opened = false; connection = engine.initialized ? "reconnecting" : "connecting"; publish(true); socket = new WebSocket(urls[socketUrlIndex]!); socket.binaryType = "arraybuffer"; socket.addEventListener("open", () => { if (!active) return; opened = true; connection = "live"; publish(true); }); socket.addEventListener("message", (message: MessageEvent) => { if (!active || !(message.data instanceof ArrayBuffer)) return; try { const decoded = protocol.decodeServer(message.data); switch (decoded.kind) { case "welcome": { engine.initialize(decoded.playerId, decoded.snapshot); clock.reset(performance.now()); const local = decoded.snapshot.state.players.find((player) => player.id === decoded.playerId); input = { ...neutralInput(), yaw: local?.yaw ?? 0, pitch: local?.pitch ?? 0 }; stream.reset(input); lastInputFrame = null; validation = "waiting"; renderSource.current.localCorrection = { x: 0, z: 0, updatedAt: performance.now() }; sendInput(true); break; } case "snapshot": { const localBefore = (engine.currentState as DeadAirClientState).players.find( (player) => player.id === engine.localPlayerId, ); engine.reconcile(decoded.snapshot); const localAfter = (engine.currentState as DeadAirClientState).players.find( (player) => player.id === engine.localPlayerId, ); if ( localBefore && localAfter && localBefore.alive === localAfter.alive && decoded.snapshot.state.round === renderSource.current.state.round ) { const now = performance.now(); const correction = renderSource.current.localCorrection; const decay = Math.exp(-(now - correction.updatedAt) / 115); const leadSeconds = clock.interpolationAlpha / deadAirGame.tickRateHz; const deltaX = localBefore.x + localBefore.velocityX * leadSeconds - localAfter.x - localAfter.velocityX * leadSeconds; const deltaZ = localBefore.z + localBefore.velocityZ * leadSeconds - localAfter.z - localAfter.velocityZ * leadSeconds; if (Math.hypot(deltaX, deltaZ) < 3) { correction.x = correction.x * decay + deltaX; correction.z = correction.z * decay + deltaZ; } else { correction.x = 0; correction.z = 0; } correction.updatedAt = now; } else { renderSource.current.localCorrection = { x: 0, z: 0, updatedAt: performance.now(), }; } break; } case "acknowledge": engine.acknowledge(decoded.sequence); break; case "pong": engine.networkClock.receivePong(decoded.pong, performance.now()); break; case "validation": validation = decoded.valid ? "valid" : "invalid"; break; case "reject-input": engine.reject(decoded.sequence); stream.invalidate(); lastInputFrame = null; validation = "invalid"; sendInput(true); break; case "event": engine.receiveEvent(decoded.event, decoded.tick); audio.play(decoded.event); break; case "replay-start": case "replay-frame": case "replay-end": break; } refreshRenderSource(); publish(decoded.kind !== "snapshot"); } catch { socket?.close(1003, "Invalid acoustic frame"); } }); socket.addEventListener("close", () => { if (!active) return; if (!opened && urls.length > 1) socketUrlIndex = (socketUrlIndex + 1) % urls.length; else if (opened) socketUrlIndex = 0; connection = "reconnecting"; publish(true); retryTimer = window.setTimeout(connect, 1_000); }); socket.addEventListener("error", () => socket?.close()); }; const keyDown = (event: KeyboardEvent) => { const key = event.key.toLowerCase(); if (["w", "a", "s", "d", "shift", "e", "f", "q", "r"].includes(key)) event.preventDefault(); audio.unlock(); if (["w", "a", "s", "d", "shift"].includes(key)) { pressed.add(key); updateMovement(); } else if (!event.repeat && key === "e") updateAction("interact", true); else if (!event.repeat && key === "f") updateAction("toggleFlashlight", true); else if (!event.repeat && key === "q") updateAction("throwDecoy", true); else if (!event.repeat && key === "r") updateAction("reload", true); }; const keyUp = (event: KeyboardEvent) => { const key = event.key.toLowerCase(); if (["w", "a", "s", "d", "shift"].includes(key)) { pressed.delete(key); updateMovement(); } else if (key === "e") updateAction("interact", false); else if (key === "f") updateAction("toggleFlashlight", false); else if (key === "q") updateAction("throwDecoy", false); else if (key === "r") updateAction("reload", false); }; const mouseMove = (event: MouseEvent) => { if (!document.pointerLockElement) return; input = { ...input, yaw: normalizeAngle(input.yaw - event.movementX * 0.0026), pitch: clamp(input.pitch - event.movementY * 0.00215, -1.2, 1.2), }; sendInput(); }; const mouseDown = (event: MouseEvent) => { audio.unlock(); if (event.button !== 0 || !document.pointerLockElement) return; input = { ...input, fire: true }; sendInput(true); }; const mouseUp = (event: MouseEvent) => { if (event.button !== 0) return; input = { ...input, fire: false }; sendInput(true); }; const release = () => { pressed.clear(); input = { ...input, forward: 0, strafe: 0, sprint: false, fire: false, reload: false, interact: false, toggleFlashlight: false, throwDecoy: false, }; sendInput(true); }; const pointerLockChange = () => { if (!document.pointerLockElement) release(); }; window.addEventListener("keydown", keyDown); window.addEventListener("keyup", keyUp); window.addEventListener("mousemove", mouseMove); window.addEventListener("mousedown", mouseDown); window.addEventListener("mouseup", mouseUp); window.addEventListener("blur", release); document.addEventListener("pointerlockchange", pointerLockChange); const pingTimer = window.setInterval(() => { send(protocol.encodeClient({ kind: "ping", ping: engine.networkClock.createPing(performance.now()) })); }, 1_000); const validationTimer = window.setInterval(() => { if (engine.initialized) send(protocol.encodeClient({ kind: "state-report", report: engine.createStateReport() })); }, 2_000); const animate = (now: number) => { sendInput(); clock.advance(now, () => engine.step()); refreshRenderSource(); publish(false, now); animationFrame = window.requestAnimationFrame(animate); }; connect(); animationFrame = window.requestAnimationFrame(animate); return () => { active = false; actionRef.current = () => undefined; window.clearTimeout(retryTimer); window.clearInterval(pingTimer); window.clearInterval(validationTimer); if (animationFrame !== undefined) window.cancelAnimationFrame(animationFrame); window.removeEventListener("keydown", keyDown); window.removeEventListener("keyup", keyUp); window.removeEventListener("mousemove", mouseMove); window.removeEventListener("mousedown", mouseDown); window.removeEventListener("mouseup", mouseUp); window.removeEventListener("blur", release); document.removeEventListener("pointerlockchange", pointerLockChange); socket?.close(); audio.dispose(); }; }, [audio, clock, engine, protocol]); return { ...view, renderSource, setAction, unlockAudio }; } function normalizeAngle(value: number): number { return Math.atan2(Math.sin(value), Math.cos(value)); } function clamp(value: number, minimum: number, maximum: number): number { return Math.max(minimum, Math.min(maximum, value)); }