smooth Movers presentation and reconciliation
All checks were successful
build / image (push) Successful in 34s
All checks were successful
build / image (push) Successful in 34s
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user