mirror of
https://github.com/exogen/t2-mapper.git
synced 2026-03-18 20:01:01 +00:00
improve input handling
This commit is contained in:
parent
e9125951e4
commit
9694e0fd82
45 changed files with 1307 additions and 720 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
@ -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,
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue