smooth Movers presentation and reconciliation
All checks were successful
build / image (push) Successful in 34s

This commit is contained in:
Syncer Deploy
2026-08-31 12:47:06 -04:00
parent 38ac419204
commit f759a3138b
3 changed files with 226 additions and 28 deletions

View File

@@ -1,6 +1,7 @@
import { useEffect, useRef, type MutableRefObject } from "react";
import * as THREE from "three";
import {
MOVERS_TICK_RATE,
MOVERS_WALLS,
type FurnitureKind,
type MoversClientState,
@@ -8,9 +9,15 @@ import {
type MoversPlayerView,
type MoversTeam,
} from "@syncer/shared";
import {
MOVERS_CORRECTION_DECAY_MILLISECONDS,
type MoversPositionCorrection,
type MoversRenderFrame,
type MoversRenderSource,
} from "./useMoversClient.js";
interface Movers3DProps {
world: MoversClientState;
source: MoversRenderSource;
playerId: number | null;
}
@@ -43,13 +50,14 @@ interface MoversRuntime {
const yellow = 0xffc629;
const blue = 0x2997ff;
const furnitureTargetRotation = new THREE.Quaternion();
const furnitureExtrapolatedRotation = new THREE.Quaternion();
const furnitureAngularAxis = new THREE.Vector3();
export function Movers3D({ world, playerId }: Movers3DProps) {
export function Movers3D({ source, playerId }: Movers3DProps) {
const hostRef = useRef<HTMLDivElement>(null);
const worldRef = useRef(world);
const playerIdRef = useRef(playerId);
const lastEventIdRef = useRef(0);
worldRef.current = world;
playerIdRef.current = playerId;
useEffect(() => {
@@ -110,7 +118,7 @@ export function Movers3D({ world, playerId }: Movers3DProps) {
const animate = (time: number) => {
const deltaSeconds = Math.min(0.05, Math.max(0.001, (time - runtime.lastTime) / 1_000));
runtime.lastTime = time;
updateRuntime(runtime, worldRef.current, playerIdRef.current, lastEventIdRef, time, deltaSeconds);
updateRuntime(runtime, source.current, playerIdRef.current, lastEventIdRef, time, deltaSeconds);
renderer.render(scene, camera);
runtime.animationFrame = window.requestAnimationFrame(animate);
};
@@ -130,20 +138,48 @@ export function Movers3D({ world, playerId }: Movers3DProps) {
function updateRuntime(
runtime: MoversRuntime,
world: MoversClientState,
frame: MoversRenderFrame,
playerId: number | null,
lastEventIdRef: MutableRefObject<number>,
time: number,
deltaSeconds: number,
): void {
updatePlayers(runtime, world.players, playerId, deltaSeconds, time);
updateFurniture(runtime, world.furniture, deltaSeconds, time);
const world = frame.state;
const leadSeconds = Math.min(
1 / MOVERS_TICK_RATE,
Math.max(0, frame.interpolationAlpha / MOVERS_TICK_RATE),
);
updatePlayers(
runtime,
world.players,
playerId,
frame.playerCorrections,
leadSeconds,
deltaSeconds,
time,
);
updateFurniture(
runtime,
world.furniture,
frame.furnitureCorrections,
leadSeconds,
deltaSeconds,
time,
);
updateTrucks(runtime, world, deltaSeconds, time);
updateEffects(runtime, world, lastEventIdRef, time);
const local = world.players.find((player) => player.id === playerId);
const targetX = local?.x ?? 0;
const targetZ = local?.z ?? 0;
const localCorrection = local ? frame.playerCorrections.get(local.id) : undefined;
const localCorrectionFactor = local
? correctionFactorAt(frame.playerCorrections, local.id, time)
: 0;
const targetX = local
? local.x + local.velocityX * leadSeconds + (localCorrection?.x ?? 0) * localCorrectionFactor
: 0;
const targetZ = local
? local.z + local.velocityZ * leadSeconds + (localCorrection?.z ?? 0) * localCorrectionFactor
: 0;
const cameraAlpha = 1 - Math.exp(-5.8 * deltaSeconds);
runtime.camera.position.x = THREE.MathUtils.lerp(runtime.camera.position.x, targetX, cameraAlpha);
runtime.camera.position.y = THREE.MathUtils.lerp(runtime.camera.position.y, world.demolitionStarted ? 22 : 24, cameraAlpha);
@@ -161,6 +197,8 @@ function updatePlayers(
runtime: MoversRuntime,
players: MoversPlayerView[],
playerId: number | null,
corrections: Map<number, MoversPositionCorrection>,
leadSeconds: number,
deltaSeconds: number,
time: number,
): void {
@@ -173,10 +211,15 @@ function updatePlayers(
runtime.players.set(player.id, group);
runtime.scene.add(group);
}
const correction = corrections.get(player.id);
const correctionFactor = correctionFactorAt(corrections, player.id, time);
const targetX = player.x + player.velocityX * leadSeconds + (correction?.x ?? 0) * correctionFactor;
const targetZ = player.z + player.velocityZ * leadSeconds + (correction?.z ?? 0) * correctionFactor;
const targetYaw = player.yaw + (correction?.yaw ?? 0) * correctionFactor;
const alpha = 1 - Math.exp(-(player.id === playerId ? 22 : 13) * deltaSeconds);
group.position.x = THREE.MathUtils.lerp(group.position.x, player.x, alpha);
group.position.z = THREE.MathUtils.lerp(group.position.z, player.z, alpha);
group.rotation.y = lerpAngle(group.rotation.y, player.yaw, alpha);
group.position.x = THREE.MathUtils.lerp(group.position.x, targetX, alpha);
group.position.z = THREE.MathUtils.lerp(group.position.z, targetZ, alpha);
group.rotation.y = lerpAngle(group.rotation.y, targetYaw, alpha);
const speed = Math.hypot(player.velocityX, player.velocityZ);
const body = group.getObjectByName("body");
if (body) body.position.y = 1.12 + Math.sin(time * 0.014 + player.id) * Math.min(0.08, speed * 0.01);
@@ -199,6 +242,8 @@ function updatePlayers(
function updateFurniture(
runtime: MoversRuntime,
items: MoversFurnitureView[],
corrections: Map<number, MoversPositionCorrection>,
leadSeconds: number,
deltaSeconds: number,
time: number,
): void {
@@ -212,17 +257,39 @@ function updateFurniture(
runtime.furniture.set(item.id, group);
runtime.scene.add(group);
}
const correction = corrections.get(item.id);
const correctionFactor = correctionFactorAt(corrections, item.id, time);
const targetX = item.x + item.velocityX * leadSeconds + (correction?.x ?? 0) * correctionFactor;
const targetY = item.y + item.velocityY * leadSeconds + (correction?.y ?? 0) * correctionFactor;
const targetZ = item.z + item.velocityZ * leadSeconds + (correction?.z ?? 0) * correctionFactor;
const alpha = 1 - Math.exp(-(item.carriedBy === null ? 14 : 24) * deltaSeconds);
group.position.x = THREE.MathUtils.lerp(group.position.x, item.x, alpha);
group.position.y = THREE.MathUtils.lerp(group.position.y, item.y, alpha);
group.position.z = THREE.MathUtils.lerp(group.position.z, item.z, alpha);
const targetRotation = new THREE.Quaternion(
group.position.x = THREE.MathUtils.lerp(group.position.x, targetX, alpha);
group.position.y = THREE.MathUtils.lerp(group.position.y, targetY, alpha);
group.position.z = THREE.MathUtils.lerp(group.position.z, targetZ, alpha);
furnitureTargetRotation.set(
item.rotationX,
item.rotationY,
item.rotationZ,
item.rotationW,
).normalize();
group.quaternion.slerp(targetRotation, alpha);
const angularSpeed = Math.hypot(
item.angularVelocityX,
item.angularVelocityY,
item.angularVelocityZ,
);
if (angularSpeed > 0.0001 && leadSeconds > 0) {
furnitureAngularAxis.set(
item.angularVelocityX / angularSpeed,
item.angularVelocityY / angularSpeed,
item.angularVelocityZ / angularSpeed,
);
furnitureExtrapolatedRotation.setFromAxisAngle(
furnitureAngularAxis,
angularSpeed * leadSeconds,
);
furnitureTargetRotation.premultiply(furnitureExtrapolatedRotation);
}
group.quaternion.slerp(furnitureTargetRotation, alpha);
updateDamageAppearance(group, item.damage);
const securedLight = group.getObjectByName("secured") as THREE.PointLight | undefined;
if (securedLight) {
@@ -238,6 +305,23 @@ function updateFurniture(
}
}
function correctionFactorAt(
corrections: Map<number, MoversPositionCorrection>,
id: number,
now: number,
): number {
const correction = corrections.get(id);
if (!correction) return 0;
const decay = Math.exp(
-(now - correction.updatedAt) / MOVERS_CORRECTION_DECAY_MILLISECONDS,
);
if (decay < 0.002) {
corrections.delete(id);
return 0;
}
return decay;
}
function updateTrucks(
runtime: MoversRuntime,
world: MoversClientState,

View File

@@ -36,7 +36,7 @@ export function MoversGame() {
return (
<main className={`movers-game movers-game--${team}${client.world.demolitionStarted ? " movers-game--demolition" : ""}`}>
<Movers3D world={client.world} playerId={client.playerId} />
<Movers3D source={client.renderSource} playerId={client.playerId} />
<div className="movers-grade" aria-hidden="true" />
{!started ? (

View File

@@ -13,6 +13,7 @@ import {
import type { ConnectionStatus, ValidationStatus } from "./useGameClient.js";
export type MoversControl = "grab" | "throwItem" | "closeDoors";
export const MOVERS_CORRECTION_DECAY_MILLISECONDS = 115;
export interface MoversClientView {
connection: ConnectionStatus;
@@ -21,10 +22,30 @@ export interface MoversClientView {
tick: number;
inputLeadTicks: number;
world: MoversClientState;
renderSource: MoversRenderSource;
network: NetworkStats;
setControl(control: MoversControl, active: boolean): void;
}
export interface MoversPositionCorrection {
x: number;
y: number;
z: number;
yaw: number;
updatedAt: number;
}
export interface MoversRenderFrame {
state: Readonly<MoversClientState>;
interpolationAlpha: number;
playerCorrections: Map<number, MoversPositionCorrection>;
furnitureCorrections: Map<number, MoversPositionCorrection>;
}
export interface MoversRenderSource {
readonly current: MoversRenderFrame;
}
const emptyNetworkStats: NetworkStats = {
roundTripTime: 0,
jitter: 0,
@@ -58,11 +79,17 @@ export function useMoversClient(): MoversClientView {
[],
);
const controlRef = useRef<(control: MoversControl, active: boolean) => void>(() => undefined);
const renderSource = useRef<MoversRenderFrame>({
state: moversGame.client.createInitialState(),
interpolationAlpha: 0,
playerCorrections: new Map(),
furnitureCorrections: new Map(),
});
const setControl = useCallback(
(control: MoversControl, active: boolean) => controlRef.current(control, active),
[],
);
const [view, setView] = useState<Omit<MoversClientView, "setControl">>({
const [view, setView] = useState<Omit<MoversClientView, "setControl" | "renderSource">>({
connection: "connecting",
validation: "waiting",
playerId: null,
@@ -84,10 +111,18 @@ export function useMoversClient(): MoversClientView {
const inputStream = createInputStateStream<MoversInput>(moversGame);
let lastInputFrame: ArrayBuffer | null = null;
let socketUrlIndex = 0;
let lastPublishedAt = Number.NEGATIVE_INFINITY;
const connectionUrls = socketUrls();
const publish = () => {
const refreshRenderSource = () => {
renderSource.current.state = engine.currentState as MoversClientState;
renderSource.current.interpolationAlpha = clock.interpolationAlpha;
};
const publish = (force = false, now = performance.now()) => {
if (!active) return;
if (!force && now - lastPublishedAt < 100) return;
lastPublishedAt = now;
setView({
connection,
validation,
@@ -137,14 +172,14 @@ export function useMoversClient(): MoversClientView {
const connect = () => {
let opened = false;
connection = engine.initialized ? "reconnecting" : "connecting";
publish();
publish(true);
socket = new WebSocket(connectionUrls[socketUrlIndex]!);
socket.binaryType = "arraybuffer";
socket.addEventListener("open", () => {
if (!active) return;
opened = true;
connection = "live";
publish();
publish(true);
});
socket.addEventListener("message", (event: MessageEvent<ArrayBuffer>) => {
if (!active || !(event.data instanceof ArrayBuffer)) return;
@@ -161,9 +196,19 @@ export function useMoversClient(): MoversClientView {
validation = "waiting";
sendInput(true);
break;
case "snapshot":
case "snapshot": {
const before = engine.currentState as MoversClientState;
const receivedAt = performance.now();
engine.reconcile(message.snapshot);
accumulateCorrections(
renderSource.current,
before,
engine.currentState as MoversClientState,
receivedAt,
clock.interpolationAlpha / moversGame.tickRateHz,
);
break;
}
case "acknowledge":
engine.acknowledge(message.sequence);
break;
@@ -188,7 +233,8 @@ export function useMoversClient(): MoversClientView {
case "replay-end":
break;
}
publish();
refreshRenderSource();
publish(message.kind !== "snapshot");
} catch {
socket?.close(1003, "Invalid moving manifest");
}
@@ -199,7 +245,7 @@ export function useMoversClient(): MoversClientView {
socketUrlIndex = (socketUrlIndex + 1) % connectionUrls.length;
} else if (opened) socketUrlIndex = 0;
connection = "reconnecting";
publish();
publish(true);
retryTimer = window.setTimeout(connect, 1_000);
});
socket.addEventListener("error", () => socket?.close());
@@ -250,7 +296,8 @@ export function useMoversClient(): MoversClientView {
const animate = (now: number) => {
sendInput();
clock.advance(now, () => engine.step());
publish();
refreshRenderSource();
publish(false, now);
animationFrame = window.requestAnimationFrame(animate);
};
@@ -270,6 +317,73 @@ export function useMoversClient(): MoversClientView {
};
}, [clock, engine, protocol]);
return { ...view, setControl };
return { ...view, renderSource, setControl };
}
function accumulateCorrections(
frame: MoversRenderFrame,
before: MoversClientState,
after: Readonly<MoversClientState>,
now: number,
leadSeconds: number,
): void {
if (before.round !== after.round) {
frame.playerCorrections.clear();
frame.furnitureCorrections.clear();
return;
}
for (const player of before.players) {
const corrected = after.players.find((candidate) => candidate.id === player.id);
if (!corrected) continue;
accumulateCorrection(frame.playerCorrections, player.id, {
x: player.x + player.velocityX * leadSeconds - corrected.x - corrected.velocityX * leadSeconds,
y: 0,
z: player.z + player.velocityZ * leadSeconds - corrected.z - corrected.velocityZ * leadSeconds,
yaw: normalizeAngle(player.yaw - corrected.yaw),
}, now);
}
for (const item of before.furniture) {
const corrected = after.furniture.find((candidate) => candidate.id === item.id);
if (!corrected) continue;
if (
item.carriedBy !== corrected.carriedBy ||
item.securedBy !== corrected.securedBy
) {
frame.furnitureCorrections.delete(item.id);
continue;
}
accumulateCorrection(frame.furnitureCorrections, item.id, {
x: item.x + item.velocityX * leadSeconds - corrected.x - corrected.velocityX * leadSeconds,
y: item.y + item.velocityY * leadSeconds - corrected.y - corrected.velocityY * leadSeconds,
z: item.z + item.velocityZ * leadSeconds - corrected.z - corrected.velocityZ * leadSeconds,
yaw: 0,
}, now);
}
}
function accumulateCorrection(
corrections: Map<number, MoversPositionCorrection>,
id: number,
delta: Pick<MoversPositionCorrection, "x" | "y" | "z" | "yaw">,
now: number,
): void {
const existing = corrections.get(id);
const decay = existing
? Math.exp(-(now - existing.updatedAt) / MOVERS_CORRECTION_DECAY_MILLISECONDS)
: 0;
const x = (existing?.x ?? 0) * decay + delta.x;
const y = (existing?.y ?? 0) * decay + delta.y;
const z = (existing?.z ?? 0) * decay + delta.z;
const yaw = normalizeAngle((existing?.yaw ?? 0) * decay + delta.yaw);
if (Math.hypot(x, y, z, yaw) < 0.0001) {
corrections.delete(id);
return;
}
corrections.set(id, { x, y, z, yaw, updatedAt: now });
}
function normalizeAngle(value: number): number {
return Math.atan2(Math.sin(value), Math.cos(value));
}