improve input handling

This commit is contained in:
Brian Beck 2026-03-13 18:04:02 -07:00
parent e9125951e4
commit 9694e0fd82
45 changed files with 1307 additions and 720 deletions

View file

@ -60,8 +60,6 @@ const _forwardVec = new Vector3();
const _sideVec = new Vector3();
const _moveVec = new Vector3();
const _lookEuler = new Euler(0, 0, 0, "YXZ");
const _orbitDir = new Vector3();
const _orbitTarget = new Vector3();
/** A buffered move sent to the server, awaiting acknowledgment. */
interface BufferedMove {
@ -167,6 +165,17 @@ export function InputConsumer() {
// Whether prediction has been initialized from a server snapshot.
const predInitialized = useRef(false);
// ── Orbit target position for follow mode (Torque coordinates) ──
// Maintained at tick rate from snapshot data, interpolated at frame rate
// using the same time base as the camera (TickProvider), matching how
// Tribes2.exe's Camera::interpolateTick reads getRenderWorldBox() using
// the same dt as all other entities' interpolateTick.
const prevOrbitTargetPos = useRef({ x: 0, y: 0, z: 0 });
const currentOrbitTargetPos = useRef({ x: 0, y: 0, z: 0 });
const orbitTargetInitialized = useRef(false);
/** Snapshot reference from last orbit target update (identity check). */
const lastOrbitSnapshot = useRef<unknown>(null);
// ── Accumulated input for current tick (live mode) ──
const tickDeltaYaw = useRef(0);
const tickDeltaPitch = useRef(0);
@ -206,6 +215,8 @@ export function InputConsumer() {
// Reset prediction state for new connection.
predInitialized.current = false;
orbitTargetInitialized.current = false;
lastOrbitSnapshot.current = null;
moveBuffer.current.length = 0;
nextMoveIndex.current = 0;
lastProcessedAck.current = 0;
@ -219,6 +230,8 @@ export function InputConsumer() {
}
activeAdapterRef.current = null;
predInitialized.current = false;
orbitTargetInitialized.current = false;
lastOrbitSnapshot.current = null;
moveBuffer.current.length = 0;
setMode("local");
@ -294,7 +307,7 @@ export function InputConsumer() {
// Always set trigger[1] (altTrigger) — the Torque Camera doubles its
// movement speed when this trigger is active. Our speedMultiplier is
// a fraction of this faster base speed, already applied by the input
// producer (KeyboardAndMouseHandler) to the movement axes.
// producer (MouseAndKeyboardHandler) to the movement axes.
triggers[1] = true;
// Build the move and assign a browser-owned index.
@ -312,7 +325,15 @@ export function InputConsumer() {
// Buffer for prediction replay and re-sending.
const buffer = moveBuffer.current;
buffer.push({ moveIndex, move, yaw: qYaw, pitch: qPitch, x: mx, y: my, z: mz });
buffer.push({
moveIndex,
move,
yaw: qYaw,
pitch: qPitch,
x: mx,
y: my,
z: mz,
});
// Cap buffer size.
if (buffer.length > MAX_MOVE_BUFFER) {
@ -334,6 +355,35 @@ export function InputConsumer() {
movesToSend[0].moveIndex,
);
}
// ── Orbit target position tracking (follow mode) ──
// Read the orbit target's position from the snapshot at tick rate,
// matching Camera::processTick which reads getWorldBox().getCenter().
// Only update when the snapshot has actually changed (new packet data),
// otherwise prev gets overwritten with current on every useTick, destroying
// the interpolation endpoints between packets.
const snap = activeAdapterRef.current.getSnapshot();
if (snap !== lastOrbitSnapshot.current) {
lastOrbitSnapshot.current = snap;
const cam = snap?.camera;
if (cam?.orbitTargetId) {
const targetEntity = snap.entities.find(
(e) => e.id === cam.orbitTargetId,
);
if (targetEntity?.position) {
prevOrbitTargetPos.current = { ...currentOrbitTargetPos.current };
currentOrbitTargetPos.current = {
x: targetEntity.position[0],
y: targetEntity.position[1],
z: targetEntity.position[2],
};
if (!orbitTargetInitialized.current) {
prevOrbitTargetPos.current = { ...currentOrbitTargetPos.current };
orbitTargetInitialized.current = true;
}
}
}
}
});
// ── useFrame: drain moveQueue, reconcile, interpolateTick + render. ──
@ -468,36 +518,49 @@ export function InputConsumer() {
prevPos.current = { ...predPos.current };
predInitialized.current = true;
// Initialize orbit target position on first reconciliation with
// orbit data, so follow mode works immediately after mode switch.
if (serverCam.orbitTargetId && !orbitTargetInitialized.current) {
const targetEntity = snapshot.entities.find(
(e) => e.id === serverCam.orbitTargetId,
);
if (targetEntity?.position) {
const pos = {
x: targetEntity.position[0],
y: targetEntity.position[1],
z: targetEntity.position[2],
};
currentOrbitTargetPos.current = pos;
prevOrbitTargetPos.current = { ...pos };
orbitTargetInitialized.current = true;
}
}
}
if (!predInitialized.current) return;
if (mode === "fly") {
// ── Camera::interpolateTick equivalent for position ──
// Torque interpolates between prev and current tick states:
// renderState = prevState + (currentState - prevState) * tickFrac
// tickFrac goes 0→1 between ticks (0 = just after tick, 1 = next tick).
const tickFrac = getTickFraction();
const pp = prevPos.current;
const cp = predPos.current;
const renderX = pp.x + (cp.x - pp.x) * tickFrac;
const renderY = pp.y + (cp.y - pp.y) * tickFrac;
const renderZ = pp.z + (cp.z - pp.z) * tickFrac;
// Convert Torque coords (x=east, y=north, z=up) to Three.js (x=north, y=up, z=east).
state.camera.position.set(renderY, renderZ, renderX);
// Rotation uses predicted values directly (already includes pending
// deltas from useFrame above for immediate responsiveness).
const [qx, qy, qz, qw] = yawPitchToQuaternion(
applyFlyCamera(
state.camera,
prevPos.current,
predPos.current,
predYaw.current,
predPitch.current,
getTickFraction(),
);
state.camera.quaternion.set(qx, qy, qz, qw);
} else if (mode === "follow") {
// Follow/orbit mode: use server position for orbit target,
// but apply our predicted rotation for responsive orbit camera.
applyOrbitCamera(state, serverCam, predYaw.current, predPitch.current);
if (!orbitTargetInitialized.current) return;
applyOrbitCamera(
state.camera,
prevOrbitTargetPos.current,
currentOrbitTargetPos.current,
predYaw.current,
predPitch.current,
getTickFraction(),
serverCam?.orbitDistance ?? 4,
serverCam?.orbitTargetId,
);
}
});
@ -558,53 +621,87 @@ function applyLocalCamera(
}
}
interface TorquePos {
x: number;
y: number;
z: number;
}
/**
* In follow/orbit mode, recompute orbit camera position from the server's
* orbit target using our predicted rotation.
* Camera::interpolateTick for fly mode.
* Interpolates predicted position between tick states, sets rotation from
* frame-rate predicted values.
*/
function applyOrbitCamera(
state: { camera: Camera },
serverCam: StreamCamera | undefined,
function applyFlyCamera(
camera: Camera,
prevPos: TorquePos,
predPos: TorquePos,
predYaw: number,
predPitch: number,
tickFrac: number,
) {
if (
!serverCam ||
serverCam.mode !== "third-person" ||
!serverCam.orbitTargetId
) {
return;
}
// Torque interpolates: renderState = prev + (current - prev) * tickFrac
const renderX = prevPos.x + (predPos.x - prevPos.x) * tickFrac;
const renderY = prevPos.y + (predPos.y - prevPos.y) * tickFrac;
const renderZ = prevPos.z + (predPos.z - prevPos.z) * tickFrac;
const root = streamPlaybackStore.getState().root;
if (!root) return;
// Convert Torque coords (x=east, y=north, z=up) to Three.js (x=north, y=up, z=east).
camera.position.set(renderY, renderZ, renderX);
const targetGroup = root.children.find(
(child) => child.name === serverCam.orbitTargetId,
);
if (!targetGroup) return;
_orbitTarget.copy(targetGroup.position);
const entities = streamPlaybackStore.getState().entities;
const orbitEntity = entities.get(serverCam.orbitTargetId);
if (orbitEntity?.renderType === "Player") {
_orbitTarget.y += 1.0;
}
const sx = Math.sin(predPitch);
const cx = Math.cos(predPitch);
const sz = Math.sin(predYaw);
const cz = Math.cos(predYaw);
// Camera pulls back along negative forward (Torque Rz*Rx column 1,
// converted to Three.js coords).
_orbitDir.set(-cz * cx, -sx, sz * cx);
if (_orbitDir.lengthSq() > 1e-8) {
_orbitDir.normalize();
const orbitDistance = Math.max(0.1, serverCam.orbitDistance ?? 4);
state.camera.position
.copy(_orbitTarget)
.addScaledVector(_orbitDir, orbitDistance);
state.camera.lookAt(_orbitTarget);
}
const [qx, qy, qz, qw] = yawPitchToQuaternion(predYaw, predPitch);
camera.quaternion.set(qx, qy, qz, qw);
}
/**
* Camera::interpolateTick for orbit mode.
* Interpolates the orbit target's position between tick states using the
* camera's own tick fraction (matching Tribes2.exe where Camera and its
* orbit target use the same dt from ProcessList). Computes orbit pullback
* from frame-rate predicted rotation for responsive mouse control.
*/
function applyOrbitCamera(
camera: Camera,
prevTargetPos: TorquePos,
currentTargetPos: TorquePos,
predYaw: number,
predPitch: number,
tickFrac: number,
orbitDistance: number,
orbitTargetId: string | undefined,
) {
// Interpolate orbit target position between tick states (Torque coords).
const tx =
prevTargetPos.x + (currentTargetPos.x - prevTargetPos.x) * tickFrac;
const ty =
prevTargetPos.y + (currentTargetPos.y - prevTargetPos.y) * tickFrac;
const tz =
prevTargetPos.z + (currentTargetPos.z - prevTargetPos.z) * tickFrac;
// Height offset: approximate getWorldBox().getCenter() for players.
const isPlayer =
orbitTargetId != null &&
streamPlaybackStore.getState().entities.get(orbitTargetId)?.renderType ===
"Player";
const centerZ = tz + (isPlayer ? 1.0 : 0);
// Compute orbit pullback using frame-rate predicted rotation.
const sp = Math.sin(predPitch);
const cp = Math.cos(predPitch);
const sy = Math.sin(predYaw);
const cy = Math.cos(predYaw);
// Torque forward (column 1 of Rz*Rx, Torque convention):
// {sy*cp, cy*cp, -sp}
// Camera pulls back along negative forward:
// {-sy*cp, -cy*cp, sp}
const dist = Math.max(0.1, orbitDistance);
const camX = tx - sy * cp * dist;
const camY = ty - cy * cp * dist;
const camZ = centerZ + sp * dist;
// Convert Torque coords to Three.js (x=north, y=up, z=east).
camera.position.set(camY, camZ, camX);
const [qx, qy, qz, qw] = yawPitchToQuaternion(predYaw, predPitch);
camera.quaternion.set(qx, qy, qz, qw);
}

View file

@ -1,11 +1,18 @@
import { lazy, ReactNode, Suspense, useCallback, useRef, useState } from "react";
import {
lazy,
ReactNode,
Suspense,
useCallback,
useRef,
useState,
} from "react";
import { KeyboardControls } from "@react-three/drei";
import { JoystickProvider } from "./JoystickContext";
import { useTouchDevice } from "./useTouchDevice";
import {
KeyboardAndMouseHandler,
MouseAndKeyboardHandler,
KEYBOARD_CONTROLS,
} from "./KeyboardAndMouseHandler";
} from "./MouseAndKeyboardHandler";
import {
InputContext,
type InputFrame,
@ -41,7 +48,7 @@ export function InputProducers() {
return (
<>
<KeyboardAndMouseHandler />
<MouseAndKeyboardHandler />
{isTouch ? (
<Suspense>
<TouchHandler />

View file

@ -17,6 +17,7 @@ export function JoinServerButton({
// const serverName = useLiveSelector((s) => s.serverName);
const ping = useLiveSelector(selectPing);
const disconnectServer = useLiveSelector((s) => s.disconnectServer);
const disconnectRelay = useLiveSelector((s) => s.disconnectRelay);
const isLive = gameStatus === "connected";
const isConnecting =
@ -33,6 +34,7 @@ export function JoinServerButton({
onClick={() => {
if (isLive) {
disconnectServer();
disconnectRelay();
} else {
onOpenServerBrowser();
}

View file

@ -1,5 +1,5 @@
import { useKeyboardControls } from "@react-three/drei";
import { Controls } from "./KeyboardAndMouseHandler";
import { Controls } from "./MouseAndKeyboardHandler";
import { useRecording } from "./RecordingProvider";
import styles from "./KeyboardOverlay.module.css";

View file

@ -55,11 +55,11 @@ export const KEYBOARD_CONTROLS = [
];
const MIN_SPEED_ADJUSTMENT = 2;
const MAX_SPEED_ADJUSTMENT = 10;
const MAX_SPEED_ADJUSTMENT = 11;
const DRAG_THRESHOLD = 3; // px of movement before it counts as a drag
/** Shared mouse/look sensitivity used across all modes (.mis, .rec, live). */
export const MOUSE_SENSITIVITY = 0.003;
export const MOUSE_SENSITIVITY = 0.002;
export const ARROW_LOOK_SPEED = 1; // radians/sec
function quantizeSpeed(speedMultiplier: number): number {
@ -70,7 +70,7 @@ function quantizeSpeed(speedMultiplier: number): number {
return (steps + 1) / 16;
}
export function KeyboardAndMouseHandler() {
export function MouseAndKeyboardHandler() {
// Don't let KeyboardControls handle stuff when metaKey is held.
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
@ -103,6 +103,7 @@ export function KeyboardAndMouseHandler() {
const getInvertScroll = useEffectEvent(() => invertScroll);
const getInvertDrag = useEffectEvent(() => invertDrag);
const getMode = useEffectEvent(() => mode);
// Accumulated mouse deltas between frames.
const mouseDeltaYaw = useRef(0);
@ -159,7 +160,11 @@ export function KeyboardAndMouseHandler() {
}
didDrag = true;
const dragSign = getInvertDrag() ? -1 : 1;
// In follow/orbit mode, drag direction is reversed because the camera
// orbits around a target — dragging right should move the camera right
// (decreasing yaw), opposite of fly mode.
const orbitFlip = getMode() === "follow" ? -1 : 1;
const dragSign = (getInvertDrag() ? 1 : -1) * orbitFlip;
mouseDeltaYaw.current += dragSign * e.movementX * MOUSE_SENSITIVITY;
mouseDeltaPitch.current += dragSign * e.movementY * MOUSE_SENSITIVITY;
};
@ -229,10 +234,14 @@ export function KeyboardAndMouseHandler() {
const scrollSign = getInvertScroll() ? -1 : 1;
const direction = (e.deltaY > 0 ? -1 : 1) * scrollSign;
// scale deltaY in a way that feels natural for both trackpads (often just
// a deltaY of 1 at a time!) and scroll wheels (can be 100s or more).
const scaledDeltaY = Math.ceil(Math.log2(Math.abs(e.deltaY) + 1));
const delta =
Math.max(
MIN_SPEED_ADJUSTMENT,
Math.min(MAX_SPEED_ADJUSTMENT, Math.abs(e.deltaY * 0.01)),
Math.min(MAX_SPEED_ADJUSTMENT, scaledDeltaY),
) * direction;
setSpeedMultiplier((prev) => {

View file

@ -215,6 +215,8 @@ export const liveConnectionStore = createStore<LiveConnectionStore>(
newAdapter.missionTypeDisplayName ?? undefined,
gameClassName: newAdapter.gameClassName ?? undefined,
serverDisplayName: newAdapter.serverDisplayName ?? undefined,
// connectedPlayerName is derived from the control object's target
// info, which reflects the server-assigned name (not warriorName).
recorderName: newAdapter.connectedPlayerName ?? undefined,
});
};

View file

@ -1846,10 +1846,17 @@ export abstract class StreamEngine implements StreamingPlayback {
});
this.onRosterChanged();
}
// The first MsgClientJoin is the connected player's own join message.
// Detect our own join: the server sends "Welcome to Tribes2" in the
// format string (args[1]) only for the joining client. This is the same
// technique the T2 community's player_support.cs uses.
if (!this.connectedPlayerName && name) {
this.connectedPlayerName = name;
this.onMissionInfoChange?.();
const msgFormat = stripTaggedStringMarkup(
this.resolveNetString(args[1]),
);
if (msgFormat.includes("Welcome to Tribes")) {
this.connectedPlayerName = name;
this.onMissionInfoChange?.();
}
}
} else if (msgType === "MsgClientDrop" && args.length >= 3) {
const clientId = parseInt(this.resolveNetString(args[2]), 10);