add generic stateful input streaming
All checks were successful
build / image (push) Successful in 50s
All checks were successful
build / image (push) Successful in 50s
This commit is contained in:
@@ -1,5 +1,9 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { FixedStepClock, type NetworkStats } from "@syncer/engine";
|
||||
import {
|
||||
createInputStateStream,
|
||||
FixedStepClock,
|
||||
type NetworkStats,
|
||||
} from "@syncer/engine";
|
||||
import {
|
||||
FLUX_SOCKET_PATH,
|
||||
fluxGame,
|
||||
@@ -61,6 +65,8 @@ export function useFluxClient(): FluxClientView {
|
||||
let connection: ConnectionStatus = "connecting";
|
||||
let validation: ValidationStatus = "waiting";
|
||||
let input: FluxInput = { thrust: false };
|
||||
const inputStream = createInputStateStream<FluxInput>(fluxGame);
|
||||
let lastInputFrame: ArrayBuffer | null = null;
|
||||
|
||||
const publish = () => {
|
||||
if (!active) return;
|
||||
@@ -79,9 +85,18 @@ export function useFluxClient(): FluxClientView {
|
||||
if (socket?.readyState === WebSocket.OPEN) socket.send(frame);
|
||||
};
|
||||
|
||||
const sendInput = () => {
|
||||
const sendInput = (force = false) => {
|
||||
if (!engine.initialized) return;
|
||||
send(protocol.encodeClient({ kind: "input", packet: engine.createInput(input) }));
|
||||
inputStream.update(input);
|
||||
const emission = inputStream.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 updateThrust = (thrust: boolean) => {
|
||||
@@ -111,8 +126,10 @@ export function useFluxClient(): FluxClientView {
|
||||
engine.initialize(message.playerId, message.snapshot);
|
||||
clock.reset(performance.now());
|
||||
input = { thrust: false };
|
||||
inputStream.reset(input);
|
||||
lastInputFrame = null;
|
||||
validation = "waiting";
|
||||
sendInput();
|
||||
sendInput(true);
|
||||
break;
|
||||
case "snapshot":
|
||||
engine.reconcile(message.snapshot);
|
||||
@@ -128,7 +145,10 @@ export function useFluxClient(): FluxClientView {
|
||||
break;
|
||||
case "reject-input":
|
||||
engine.reject(message.sequence);
|
||||
inputStream.invalidate();
|
||||
lastInputFrame = null;
|
||||
validation = "invalid";
|
||||
sendInput(true);
|
||||
break;
|
||||
case "event":
|
||||
engine.receiveEvent(message.event, message.tick);
|
||||
@@ -174,6 +194,10 @@ export function useFluxClient(): FluxClientView {
|
||||
ping: engine.networkClock.createPing(performance.now()),
|
||||
}));
|
||||
}, 1_000);
|
||||
const inputTimer = window.setInterval(
|
||||
() => sendInput(),
|
||||
inputStream.policy.heartbeatIntervalMs,
|
||||
);
|
||||
const validationTimer = window.setInterval(() => {
|
||||
if (engine.initialized) {
|
||||
send(protocol.encodeClient({
|
||||
@@ -195,6 +219,7 @@ export function useFluxClient(): FluxClientView {
|
||||
setThrustRef.current = () => undefined;
|
||||
window.clearTimeout(retryTimer);
|
||||
window.clearInterval(pingTimer);
|
||||
window.clearInterval(inputTimer);
|
||||
window.clearInterval(validationTimer);
|
||||
if (animationFrame !== undefined) window.cancelAnimationFrame(animationFrame);
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
createInputStateStream,
|
||||
FixedStepClock,
|
||||
type NetworkStats,
|
||||
type ProjectedReplayFrame,
|
||||
} from "@syncer/engine";
|
||||
import {
|
||||
GAME_SOCKET_PATH,
|
||||
WEAPONS,
|
||||
Weapon,
|
||||
shooterGame,
|
||||
type ShooterInput,
|
||||
@@ -110,8 +110,8 @@ export function useGameClient(): GameClientView {
|
||||
let validation: ValidationStatus = "waiting";
|
||||
const pressedKeys = new Set<string>();
|
||||
let input: ShooterInput = { ...neutralInput };
|
||||
let lastSentInput: ShooterInput | null = null;
|
||||
let inputDirty = true;
|
||||
const inputStream = createInputStateStream<ShooterInput>(shooterGame);
|
||||
let lastInputFrame: ArrayBuffer | null = null;
|
||||
const incomingReplays = new Map<number, IncomingReplay>();
|
||||
let activeReplay: ActiveReplay | null = null;
|
||||
|
||||
@@ -178,15 +178,15 @@ export function useGameClient(): GameClientView {
|
||||
};
|
||||
|
||||
const sendInput = (force = false) => {
|
||||
if (!engine.initialized || (!force && !inputDirty)) return;
|
||||
if (!force && lastSentInput && inputsEqual(input, lastSentInput)) {
|
||||
inputDirty = false;
|
||||
return;
|
||||
if (!engine.initialized) return;
|
||||
inputStream.update(input);
|
||||
const emission = inputStream.consume(performance.now(), force);
|
||||
if (!emission) return;
|
||||
if (emission.kind === "state" || !lastInputFrame) {
|
||||
const packet = engine.createInput(emission.input);
|
||||
lastInputFrame = protocol.encodeClient({ kind: "input", packet });
|
||||
}
|
||||
const packet = engine.createInput(input);
|
||||
send(protocol.encodeClient({ kind: "input", packet }));
|
||||
lastSentInput = { ...input };
|
||||
inputDirty = false;
|
||||
send(lastInputFrame);
|
||||
};
|
||||
|
||||
const updateMovement = () => {
|
||||
@@ -196,7 +196,6 @@ export function useGameClient(): GameClientView {
|
||||
forward: Number(pressedKeys.has("w")) - Number(pressedKeys.has("s")),
|
||||
sprint: pressedKeys.has("shift"),
|
||||
};
|
||||
inputDirty = true;
|
||||
sendInput();
|
||||
};
|
||||
|
||||
@@ -229,8 +228,8 @@ export function useGameClient(): GameClientView {
|
||||
pitch: local?.pitch ?? 0,
|
||||
weapon: local?.weapon ?? Weapon.PulseRifle,
|
||||
};
|
||||
lastSentInput = null;
|
||||
inputDirty = true;
|
||||
inputStream.reset(input);
|
||||
lastInputFrame = null;
|
||||
validation = "waiting";
|
||||
sendInput(true);
|
||||
break;
|
||||
@@ -249,7 +248,10 @@ export function useGameClient(): GameClientView {
|
||||
break;
|
||||
case "reject-input":
|
||||
engine.reject(message.sequence);
|
||||
inputStream.invalidate();
|
||||
lastInputFrame = null;
|
||||
validation = "invalid";
|
||||
sendInput(true);
|
||||
break;
|
||||
case "event":
|
||||
engine.receiveEvent(message.event, message.tick);
|
||||
@@ -334,7 +336,6 @@ export function useGameClient(): GameClientView {
|
||||
updateMovement();
|
||||
} else if (key === "r") {
|
||||
input = { ...input, reload: true };
|
||||
inputDirty = true;
|
||||
sendInput();
|
||||
} else if (key === "1" || key === "2" || key === "3") {
|
||||
input = {
|
||||
@@ -346,7 +347,6 @@ export function useGameClient(): GameClientView {
|
||||
? Weapon.Scattergun
|
||||
: Weapon.RailRifle,
|
||||
};
|
||||
inputDirty = true;
|
||||
sendInput();
|
||||
}
|
||||
};
|
||||
@@ -359,7 +359,6 @@ export function useGameClient(): GameClientView {
|
||||
updateMovement();
|
||||
} else if (key === "r") {
|
||||
input = { ...input, reload: false };
|
||||
inputDirty = true;
|
||||
sendInput();
|
||||
}
|
||||
};
|
||||
@@ -371,13 +370,11 @@ export function useGameClient(): GameClientView {
|
||||
yaw: normalizeAngle(input.yaw + event.movementX * 0.00225),
|
||||
pitch: clamp(input.pitch - event.movementY * 0.0019, -1.25, 1.25),
|
||||
};
|
||||
inputDirty = true;
|
||||
};
|
||||
|
||||
const handleMouseDown = (event: MouseEvent) => {
|
||||
if (activeReplay || event.button !== 0 || !document.pointerLockElement) return;
|
||||
input = { ...input, fire: true };
|
||||
inputDirty = true;
|
||||
sendInput();
|
||||
};
|
||||
|
||||
@@ -385,14 +382,12 @@ export function useGameClient(): GameClientView {
|
||||
if (event.button !== 0) return;
|
||||
if (activeReplay) return;
|
||||
input = { ...input, fire: false };
|
||||
inputDirty = true;
|
||||
sendInput();
|
||||
};
|
||||
|
||||
const releaseControls = () => {
|
||||
pressedKeys.clear();
|
||||
input = { ...input, strafe: 0, forward: 0, fire: false, sprint: false, reload: false };
|
||||
inputDirty = true;
|
||||
sendInput(true);
|
||||
};
|
||||
|
||||
@@ -409,8 +404,8 @@ export function useGameClient(): GameClientView {
|
||||
document.addEventListener("pointerlockchange", handlePointerLockChange);
|
||||
|
||||
const inputTimer = window.setInterval(
|
||||
() => sendInput(input.fire && WEAPONS[input.weapon].automatic),
|
||||
1_000 / 30,
|
||||
() => sendInput(),
|
||||
inputStream.policy.heartbeatIntervalMs,
|
||||
);
|
||||
const pingTimer = window.setInterval(() => {
|
||||
send(protocol.encodeClient({ kind: "ping", ping: engine.networkClock.createPing(performance.now()) }));
|
||||
@@ -445,19 +440,6 @@ export function useGameClient(): GameClientView {
|
||||
return view;
|
||||
}
|
||||
|
||||
function inputsEqual(left: ShooterInput, right: ShooterInput): boolean {
|
||||
return (
|
||||
left.strafe === right.strafe &&
|
||||
left.forward === right.forward &&
|
||||
left.yaw === right.yaw &&
|
||||
left.pitch === right.pitch &&
|
||||
left.fire === right.fire &&
|
||||
left.sprint === right.sprint &&
|
||||
left.reload === right.reload &&
|
||||
left.weapon === right.weapon
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeAngle(value: number): number {
|
||||
return Math.atan2(Math.sin(value), Math.cos(value));
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { FixedStepClock, type NetworkStats } from "@syncer/engine";
|
||||
import {
|
||||
createInputStateStream,
|
||||
FixedStepClock,
|
||||
type NetworkStats,
|
||||
} from "@syncer/engine";
|
||||
import {
|
||||
ROYALE_SOCKET_PATH,
|
||||
royaleGame,
|
||||
@@ -72,8 +76,8 @@ export function useRoyaleClient(): RoyaleClientView {
|
||||
let connection: ConnectionStatus = "connecting";
|
||||
let validation: ValidationStatus = "waiting";
|
||||
let input: RoyaleInput = { ...neutralInput };
|
||||
let lastSent: RoyaleInput | null = null;
|
||||
let inputDirty = false;
|
||||
const inputStream = createInputStateStream<RoyaleInput>(royaleGame);
|
||||
let lastInputFrame: ArrayBuffer | null = null;
|
||||
let socketUrlIndex = 0;
|
||||
const connectionUrls = socketUrls();
|
||||
const pressed = new Set<string>();
|
||||
@@ -96,15 +100,14 @@ export function useRoyaleClient(): RoyaleClientView {
|
||||
};
|
||||
const sendInput = (force = false) => {
|
||||
if (!engine.initialized) return;
|
||||
if (!force && !inputDirty) return;
|
||||
if (!force && lastSent && inputsEqual(lastSent, input)) {
|
||||
inputDirty = false;
|
||||
return;
|
||||
inputStream.update(input);
|
||||
const emission = inputStream.consume(performance.now(), force);
|
||||
if (!emission) return;
|
||||
if (emission.kind === "state" || !lastInputFrame) {
|
||||
const packet = engine.createInput(emission.input);
|
||||
lastInputFrame = protocol.encodeClient({ kind: "input", packet });
|
||||
}
|
||||
const packet = engine.createInput(input);
|
||||
send(protocol.encodeClient({ kind: "input", packet }));
|
||||
lastSent = { ...input };
|
||||
inputDirty = false;
|
||||
send(lastInputFrame);
|
||||
};
|
||||
const updateMovement = () => {
|
||||
input = {
|
||||
@@ -113,7 +116,6 @@ export function useRoyaleClient(): RoyaleClientView {
|
||||
strafe: Number(pressed.has("d")) - Number(pressed.has("a")),
|
||||
sprint: pressed.has("shift"),
|
||||
};
|
||||
inputDirty = true;
|
||||
sendInput();
|
||||
};
|
||||
|
||||
@@ -145,8 +147,8 @@ export function useRoyaleClient(): RoyaleClientView {
|
||||
yaw: local?.yaw ?? 0,
|
||||
pitch: local?.pitch ?? 0,
|
||||
};
|
||||
lastSent = null;
|
||||
inputDirty = true;
|
||||
inputStream.reset(input);
|
||||
lastInputFrame = null;
|
||||
validation = "waiting";
|
||||
sendInput(true);
|
||||
break;
|
||||
@@ -165,7 +167,10 @@ export function useRoyaleClient(): RoyaleClientView {
|
||||
break;
|
||||
case "reject-input":
|
||||
engine.reject(message.sequence);
|
||||
inputStream.invalidate();
|
||||
lastInputFrame = null;
|
||||
validation = "invalid";
|
||||
sendInput(true);
|
||||
break;
|
||||
case "event":
|
||||
engine.receiveEvent(message.event, message.tick);
|
||||
@@ -202,7 +207,6 @@ export function useRoyaleClient(): RoyaleClientView {
|
||||
updateMovement();
|
||||
} else if (key === "r") {
|
||||
input = { ...input, reload: true };
|
||||
inputDirty = true;
|
||||
sendInput(true);
|
||||
}
|
||||
};
|
||||
@@ -213,7 +217,6 @@ export function useRoyaleClient(): RoyaleClientView {
|
||||
updateMovement();
|
||||
} else if (key === "r") {
|
||||
input = { ...input, reload: false };
|
||||
inputDirty = true;
|
||||
sendInput(true);
|
||||
}
|
||||
};
|
||||
@@ -224,24 +227,20 @@ export function useRoyaleClient(): RoyaleClientView {
|
||||
yaw: normalizeAngle(input.yaw - event.movementX * 0.0027),
|
||||
pitch: clamp(input.pitch - event.movementY * 0.0022, -1.25, 1.25),
|
||||
};
|
||||
inputDirty = true;
|
||||
};
|
||||
const mouseDown = (event: MouseEvent) => {
|
||||
if (event.button !== 0 || !document.pointerLockElement) return;
|
||||
input = { ...input, fire: true };
|
||||
inputDirty = true;
|
||||
sendInput(true);
|
||||
};
|
||||
const mouseUp = (event: MouseEvent) => {
|
||||
if (event.button !== 0) return;
|
||||
input = { ...input, fire: false };
|
||||
inputDirty = true;
|
||||
sendInput(true);
|
||||
};
|
||||
const release = () => {
|
||||
pressed.clear();
|
||||
input = { ...input, forward: 0, strafe: 0, sprint: false, fire: false, reload: false };
|
||||
inputDirty = true;
|
||||
sendInput(true);
|
||||
};
|
||||
const pointerLockChange = () => {
|
||||
@@ -257,8 +256,8 @@ export function useRoyaleClient(): RoyaleClientView {
|
||||
document.addEventListener("pointerlockchange", pointerLockChange);
|
||||
|
||||
const inputTimer = window.setInterval(() => {
|
||||
sendInput(input.fire);
|
||||
}, 1_000 / royaleGame.tickRateHz);
|
||||
sendInput();
|
||||
}, inputStream.policy.heartbeatIntervalMs);
|
||||
const pingTimer = window.setInterval(() => {
|
||||
send(protocol.encodeClient({
|
||||
kind: "ping",
|
||||
@@ -302,18 +301,6 @@ export function useRoyaleClient(): RoyaleClientView {
|
||||
return view;
|
||||
}
|
||||
|
||||
function inputsEqual(left: RoyaleInput, right: RoyaleInput): boolean {
|
||||
return (
|
||||
left.forward === right.forward &&
|
||||
left.strafe === right.strafe &&
|
||||
left.yaw === right.yaw &&
|
||||
left.pitch === right.pitch &&
|
||||
left.fire === right.fire &&
|
||||
left.sprint === right.sprint &&
|
||||
left.reload === right.reload
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeAngle(value: number): number {
|
||||
return Math.atan2(Math.sin(value), Math.cos(value));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user