This commit is contained in:
368
apps/web/src/useDeadAirClient.ts
Normal file
368
apps/web/src/useDeadAirClient.ts
Normal file
@@ -0,0 +1,368 @@
|
||||
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 && decoded.snapshot.state.round === renderSource.current.state.round) {
|
||||
const correction = renderSource.current.localCorrection;
|
||||
correction.x += localBefore.x - localAfter.x;
|
||||
correction.z += localBefore.z - localAfter.z;
|
||||
correction.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));
|
||||
}
|
||||
Reference in New Issue
Block a user