add generic stateful input streaming
All checks were successful
build / image (push) Successful in 50s

This commit is contained in:
Syncer Deploy
2026-08-28 11:27:53 -03:00
parent 0bf2b95755
commit 96b73922cc
15 changed files with 767 additions and 81 deletions

View File

@@ -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);

View File

@@ -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));
}

View File

@@ -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));
}