394 lines
14 KiB
TypeScript
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));
|
|
}
|