Files
syncer/apps/web/src/useDeadAirClient.ts
Syncer Deploy 6c345ba595
All checks were successful
build / image (push) Successful in 27s
stabilize dead air camera reconciliation
2026-08-31 13:21:31 -04:00

394 lines
14 KiB
TypeScript

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<DeadAirClientState>;
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<DeadAirRenderFrame>({
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<Omit<DeadAirClientView, "renderSource" | "setAction" | "unlockAudio">>({
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<string>();
const stream = createInputStateStream<DeadAirInput>(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<ArrayBuffer>) => {
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));
}