cleanup input handling, fix server move packets

This commit is contained in:
Brian Beck 2026-03-13 11:08:11 -07:00
parent 409df9fcaa
commit e9125951e4
84 changed files with 2085 additions and 1808 deletions

View file

@ -1,7 +1,7 @@
.ChatContainer {
position: absolute;
top: 8px;
left: 8px;
top: 6px;
left: 6px;
width: 400px;
max-width: 50%;
display: flex;
@ -11,8 +11,8 @@
}
.ChatWindow {
max-height: 12.5em;
min-height: 4em;
max-height: 12.5em;
overflow-y: auto;
background: rgba(0, 50, 60, 0.65);
padding: 6px;

View file

@ -0,0 +1,610 @@
import { useRef, useEffect } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import { Camera, Euler, Vector3 } from "three";
import { createLogger } from "../logger";
import {
liveConnectionStore,
useLiveSelector,
} from "../state/liveConnectionStore";
import { useEngineStoreApi } from "../state/engineStore";
import { streamPlaybackStore } from "../state/streamPlaybackStore";
import { useInputContext } from "./InputContext";
import { useTick, useGetTickFraction } from "./TickProvider";
import { yawPitchToQuaternion, MAX_PITCH } from "../stream/streamHelpers";
import type { StreamRecording, StreamCamera } from "../stream/types";
import type { LiveStreamAdapter } from "../stream/liveStreaming";
import type { ClientMove } from "../../relay/types";
const log = createLogger("InputConsumer");
const MAX_SPEED = 300;
const LOCAL_MAX_PITCH = Math.PI / 2 - 0.01; // ~89°
/**
* Max moves in the unacked buffer. Matches Torque's MaxMoveQueueSize.
* Also the max we can send per packet (5-bit count = 31), but we keep
* a slightly larger buffer to avoid losing moves during high latency.
*/
const MAX_MOVE_BUFFER = 45;
/** Max moves per packet (MoveCountBits = 5 → 2^5 - 1). */
const MAX_MOVES_PER_PACKET = 31;
/**
* $Camera::movementSpeed (default 40 in Tribes2.exe at _DAT_0079abe8).
* With trigger[1] (altTrigger) always set, the effective speed is 80.
*/
const CAMERA_SPEED = 40;
/** Torque tick duration in seconds (1/32 = 0.03125). */
const TICK_SEC = 1 / 32;
const M_2PI = 2 * Math.PI;
/**
* Quantize a rotation delta through Torque's Move clamp/unclamp round-trip.
* This ensures our predicted rotation exactly matches the server's decoded value.
*
* clamp: pyaw = (radians / 2π) * 65536, masked to 16 bits
* unclamp: radians = (short)pyaw * 2π / 65536
*/
function quantizeRotation(radians: number): number {
const packed = Math.round((radians / M_2PI) * 65536) | 0;
// Sign-extend 16-bit (like C's (short) cast).
const signed = (packed << 16) >> 16;
return (signed * M_2PI) / 65536;
}
// Scratch objects to avoid per-frame allocations.
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 {
/** Browser-assigned move index. */
moveIndex: number;
/** The full move data for re-sending to the server. */
move: ClientMove;
/** Rotation deltas for prediction replay. */
yaw: number;
pitch: number;
/** Movement axes for position prediction replay. */
x: number;
y: number;
z: number;
}
/**
* Apply Camera::processTick position update.
*
* Matches Tribes2.exe FUN_005cbc80: builds Rz(yaw)*Rx(pitch) orientation
* matrix, transforms local move axes to world space, applies at tick rate.
*
* Torque uses (cos,+sin;-sin,cos) rotation convention, NOT standard math.
* The resulting Rz(y)*Rx(p) row-major matrix columns are:
* right (move.x): { cy, -sy, 0 }
* forward (move.y): { sy*cp, cy*cp, -sp }
* up (move.z): { sy*sp, cy*sp, cp }
*
* IMPORTANT: `yaw`/`pitch` must be the rotation state from the PREVIOUS
* tick, not the current one. Tribes2.exe reads the old transform matrix
* (built at end of previous tick) for position computation.
*/
function applyProcessTickPosition(
pos: { x: number; y: number; z: number },
yaw: number,
pitch: number,
mx: number,
my: number,
mz: number,
speed: number,
): void {
if (mx === 0 && my === 0 && mz === 0) return;
const sy = Math.sin(yaw);
const cy = Math.cos(yaw);
const sp = Math.sin(pitch);
const cp = Math.cos(pitch);
// pos += (right*mx + forward*my + up*mz) * speed * TickSec
const scale = speed * TICK_SEC;
pos.x += (cy * mx + sy * cp * my + sy * sp * mz) * scale;
pos.y += (-sy * mx + cy * cp * my + cy * sp * mz) * scale;
pos.z += (-sp * my + cp * mz) * scale;
}
/**
* Consumes input frames from the move queue and applies them.
*
* Implements the same client-side prediction strategy as the real Tribes 2
* client (verified against Tribes2.exe):
*
* 1. Camera::processTick apply rotation + position at tick rate.
* 2. Camera::interpolateTick interpolate between tick states for smooth
* frame-rate rendering.
* 3. Send ALL unacked moves to the server at tick rate (like moveWritePacket).
* 4. On server correction (readPacketData), snap to authoritative state and
* replay all unacknowledged moves.
*
* The browser owns the move index counter and re-sends unacked moves for
* UDP reliability, just like the real Tribes 2 client.
*/
export function InputConsumer() {
const { moveQueue, mode, setMode } = useInputContext();
const adapter = useLiveSelector((s) => s.adapter);
const gameStatus = useLiveSelector((s) => s.gameStatus);
const sendMoves = useLiveSelector((s) => s.sendMoves);
const store = useEngineStoreApi();
const camera = useThree((state) => state.camera);
const getTickFraction = useGetTickFraction();
const activeAdapterRef = useRef<LiveStreamAdapter | null>(null);
// ── Move buffer (unacked moves) ──
const moveBuffer = useRef<BufferedMove[]>([]);
// Browser-owned move index counter.
const nextMoveIndex = useRef(0);
// The last lastMoveAck we processed (to detect new server corrections).
const lastProcessedAck = useRef(0);
// The last server camera snapshot we reconciled from (identity check).
const lastReconciledCamera = useRef<StreamCamera | null>(null);
// ── Local predicted state (Torque coordinates) ──
// Absolute predicted yaw/pitch in Torque radians.
const predYaw = useRef(0);
const predPitch = useRef(0);
// Predicted position in Torque world coords (x=east, y=north, z=up).
const predPos = useRef({ x: 0, y: 0, z: 0 });
// ── Previous tick state for interpolateTick ──
const prevYaw = useRef(0);
const prevPitch = useRef(0);
const prevPos = useRef({ x: 0, y: 0, z: 0 });
// Whether prediction has been initialized from a server snapshot.
const predInitialized = useRef(false);
// ── Accumulated input for current tick (live mode) ──
const tickDeltaYaw = useRef(0);
const tickDeltaPitch = useRef(0);
const tickMoveX = useRef(0);
const tickMoveY = useRef(0);
const tickMoveZ = useRef(0);
const tickTriggers = useRef([false, false, false, false, false, false]);
// Previous trigger state for edge detection.
const prevTriggers = useRef([false, false, false, false, false, false]);
const isLive =
!!adapter &&
(gameStatus === "connected" || gameStatus === "authenticating");
// Wire adapter to engine store.
useEffect(() => {
if (isLive && adapter) {
if (activeAdapterRef.current === adapter) return;
log.info("wiring adapter to engine store");
const liveState = liveConnectionStore.getState();
const liveRecording: StreamRecording = {
source: "live",
duration: Infinity,
missionName: liveState.mapName ?? null,
gameType: null,
serverDisplayName: liveState.serverName ?? null,
recorderName: liveState.warriorName ?? null,
recordingDate: null,
streamingPlayback: adapter,
};
store.getState().setRecording(liveRecording);
store.getState().setPlaybackStatus("playing");
activeAdapterRef.current = adapter;
// Reset prediction state for new connection.
predInitialized.current = false;
moveBuffer.current.length = 0;
nextMoveIndex.current = 0;
lastProcessedAck.current = 0;
lastReconciledCamera.current = null;
setMode("fly");
} else if (!isLive && activeAdapterRef.current) {
const current = store.getState().playback.recording;
if (current?.source === "live") {
store.getState().setRecording(null);
}
activeAdapterRef.current = null;
predInitialized.current = false;
moveBuffer.current.length = 0;
setMode("local");
}
}, [isLive, adapter, store, setMode]);
// ── processTick: send moves at the Torque tick rate (32Hz). ──
useTick(() => {
if (!activeAdapterRef.current || gameStatus !== "connected") return;
// Consume accumulated deltas.
const yaw = tickDeltaYaw.current;
const pitch = tickDeltaPitch.current;
tickDeltaYaw.current = 0;
tickDeltaPitch.current = 0;
const mx = tickMoveX.current;
const my = tickMoveY.current;
const mz = tickMoveZ.current;
tickMoveX.current = 0;
tickMoveY.current = 0;
tickMoveZ.current = 0;
const triggers = [...tickTriggers.current];
tickTriggers.current.fill(false);
// Trigger edge detection.
if (triggers[2] && !prevTriggers.current[2]) {
activeAdapterRef.current.toggleObserverMode();
log.info("observer mode: %s", activeAdapterRef.current.observerMode);
setMode(
activeAdapterRef.current.observerMode === "follow" ? "follow" : "fly",
);
}
prevTriggers.current = triggers;
// ── Camera::processTick equivalent ──
// Quantize rotation to match server's Move::clamp/unclamp round-trip.
// useFrame already applied raw deltas for responsiveness; correct to
// the quantized value the server will actually use.
const qYaw = quantizeRotation(yaw);
const qPitch = quantizeRotation(pitch);
predYaw.current += qYaw - yaw;
predPitch.current += qPitch - pitch;
// Save previous tick state for interpolateTick.
prevYaw.current = predYaw.current;
prevPitch.current = predPitch.current;
prevPos.current = { ...predPos.current };
// NOTE: Rotation is NOT re-applied here — useFrame already applied
// (now corrected to quantized). useTick only buffers and sends.
// Apply position using the PREVIOUS tick's rotation, matching Tribes2.exe:
// processTick reads the old transform matrix (built at end of previous
// tick) for position computation, even though mRot is updated first.
// Since predYaw/predPitch already include this tick's quantized deltas,
// subtract them to get the old rotation.
const speed = CAMERA_SPEED * 2;
const posRotYaw = predYaw.current - qYaw;
const posRotPitch = predPitch.current - qPitch;
applyProcessTickPosition(
predPos.current,
posRotYaw,
posRotPitch,
mx,
my,
mz,
speed,
);
// 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.
triggers[1] = true;
// Build the move and assign a browser-owned index.
const moveIndex = nextMoveIndex.current++;
const move: ClientMove = {
x: mx,
y: my,
z: mz,
yaw,
pitch,
roll: 0,
trigger: triggers,
freeLook: false,
};
// 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 });
// Cap buffer size.
if (buffer.length > MAX_MOVE_BUFFER) {
buffer.splice(0, buffer.length - MAX_MOVE_BUFFER);
}
// Prune acknowledged moves before sending.
const ack = activeAdapterRef.current.lastMoveAck;
while (buffer.length > 0 && buffer[0].moveIndex < ack) {
buffer.shift();
}
// Send ALL unacked moves, just like Tribes 2's moveWritePacket.
// The server deduplicates based on moveStartIndex.
if (buffer.length > 0) {
const movesToSend = buffer.slice(0, MAX_MOVES_PER_PACKET);
sendMoves(
movesToSend.map((m) => m.move),
movesToSend[0].moveIndex,
);
}
});
// ── useFrame: drain moveQueue, reconcile, interpolateTick + render. ──
useFrame((state, delta) => {
const frames = moveQueue.current;
if (frames.length > 0) {
// Drain the move queue.
let dYaw = 0;
let dPitch = 0;
let x = 0;
let y = 0;
let z = 0;
let frameDelta = 0;
const frameTriggers = [false, false, false, false, false, false];
for (const frame of frames) {
dYaw += frame.deltaYaw;
dPitch += frame.deltaPitch;
x = frame.x; // latest wins
y = frame.y;
z = frame.z;
frameDelta += frame.delta;
for (let i = 0; i < frame.triggers.length; i++) {
if (frame.triggers[i]) frameTriggers[i] = true;
}
}
moveQueue.current.length = 0;
if (isLive && activeAdapterRef.current && gameStatus === "connected") {
// Live mode: accumulate for useTick to consume and send.
tickDeltaYaw.current += dYaw;
tickDeltaPitch.current += dPitch;
tickMoveX.current = x;
tickMoveY.current = y;
tickMoveZ.current = z;
for (let i = 0; i < frameTriggers.length; i++) {
if (frameTriggers[i]) tickTriggers.current[i] = true;
}
// Apply look deltas to prediction immediately for frame-rate
// responsiveness (the pending deltas haven't been consumed by useTick
// yet, but we still want them to affect the camera this frame).
predYaw.current += dYaw;
predPitch.current = Math.max(
-MAX_PITCH,
Math.min(MAX_PITCH, predPitch.current + dPitch),
);
} else {
// Local mode: apply input directly to camera.
const spState = streamPlaybackStore.getState();
if (spState.playback && !spState.freeFlyCamera) return;
applyLocalCamera(camera, dYaw, dPitch, x, y, z, frameDelta);
return;
}
}
// ── Live mode: server reconciliation + interpolateTick ──
if (!isLive || !activeAdapterRef.current || gameStatus !== "connected") {
return;
}
const adapterRef = activeAdapterRef.current;
const snapshot = adapterRef.getSnapshot();
const serverCam = snapshot?.camera;
// Check for new server correction.
if (
serverCam &&
serverCam !== lastReconciledCamera.current &&
typeof serverCam.yaw === "number" &&
typeof serverCam.pitch === "number"
) {
lastReconciledCamera.current = serverCam;
// Prune acknowledged moves from the buffer.
const ack = adapterRef.lastMoveAck;
if (ack > lastProcessedAck.current) {
lastProcessedAck.current = ack;
const buffer = moveBuffer.current;
while (buffer.length > 0 && buffer[0].moveIndex < ack) {
buffer.shift();
}
}
// Snap to server's authoritative state (rotation + position).
predYaw.current = serverCam.yaw;
predPitch.current = serverCam.pitch;
predPos.current = {
x: serverCam.position[0],
y: serverCam.position[1],
z: serverCam.position[2],
};
// Replay all unacknowledged moves on top of server state.
// This is exactly what ProcessList::advanceClientTime does:
// for each pending move, call control->processTick(&move).
// Position uses the rotation from BEFORE each move (old transform),
// then rotation is updated for the next move.
const speed = CAMERA_SPEED * 2;
for (const move of moveBuffer.current) {
// Position first, using pre-move rotation (matches Tribes2.exe).
applyProcessTickPosition(
predPos.current,
predYaw.current,
predPitch.current,
move.x,
move.y,
move.z,
speed,
);
// Then update rotation for the next move.
predYaw.current += move.yaw;
predPitch.current = Math.max(
-MAX_PITCH,
Math.min(MAX_PITCH, predPitch.current + move.pitch),
);
}
// Also add any pending deltas not yet consumed by useTick.
predYaw.current += tickDeltaYaw.current;
predPitch.current = Math.max(
-MAX_PITCH,
Math.min(MAX_PITCH, predPitch.current + tickDeltaPitch.current),
);
// After reconciliation, snap prev state to match (no interpolation
// glitch — we want the corrected state to appear immediately).
prevYaw.current = predYaw.current;
prevPitch.current = predPitch.current;
prevPos.current = { ...predPos.current };
predInitialized.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(
predYaw.current,
predPitch.current,
);
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);
}
});
// Clean up on unmount.
useEffect(() => {
return () => {
if (activeAdapterRef.current) {
const current = store.getState().playback.recording;
if (current?.source === "live") {
store.getState().setRecording(null);
}
activeAdapterRef.current = null;
}
};
}, [store]);
return null;
}
/** Apply rotation and movement to the camera locally (local/demo mode). */
function applyLocalCamera(
camera: Camera,
dYaw: number,
dPitch: number,
x: number,
y: number,
z: number,
delta: number,
) {
if (dYaw !== 0 || dPitch !== 0) {
_lookEuler.setFromQuaternion(camera.quaternion, "YXZ");
_lookEuler.y -= dYaw;
_lookEuler.x -= dPitch;
_lookEuler.x = Math.max(
-LOCAL_MAX_PITCH,
Math.min(LOCAL_MAX_PITCH, _lookEuler.x),
);
camera.quaternion.setFromEuler(_lookEuler);
}
if (x !== 0 || y !== 0 || z !== 0) {
camera.getWorldDirection(_forwardVec);
_forwardVec.normalize();
_sideVec.crossVectors(camera.up, _forwardVec).normalize();
_moveVec.set(0, 0, 0);
if (y !== 0) _moveVec.addScaledVector(_forwardVec, y);
if (x !== 0) _moveVec.addScaledVector(_sideVec, -x);
if (z !== 0) _moveVec.y += z;
const len = _moveVec.length();
if (len > 0) {
// Clamp length to 1 so diagonal movement isn't faster, but preserve
// sub-1 magnitudes from speedMultiplier.
_moveVec.multiplyScalar((Math.min(1, len) / len) * MAX_SPEED * delta);
camera.position.add(_moveVec);
}
}
}
/**
* In follow/orbit mode, recompute orbit camera position from the server's
* orbit target using our predicted rotation.
*/
function applyOrbitCamera(
state: { camera: Camera },
serverCam: StreamCamera | undefined,
predYaw: number,
predPitch: number,
) {
if (
!serverCam ||
serverCam.mode !== "third-person" ||
!serverCam.orbitTargetId
) {
return;
}
const root = streamPlaybackStore.getState().root;
if (!root) return;
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);
}
}

View file

@ -0,0 +1,48 @@
import { createContext, useContext } from "react";
export type InputMode = "local" | "fly" | "follow";
export interface InputFrame {
/** Look rotation deltas (radians). */
deltaYaw: number;
deltaPitch: number;
/** Movement axes [-1, 1], pre-scaled by speedMultiplier. */
x: number;
y: number;
z: number;
/** Trigger states. OR'd by consumer between ticks. */
triggers: boolean[];
/** Frame delta time (seconds). */
delta: number;
}
export type OnInput = (frame: InputFrame) => void;
export interface InputContextValue {
/** Ref to accumulated input frames. Consumer reads and clears. */
moveQueue: React.RefObject<InputFrame[]>;
/** Callback for producers to report input each frame. */
onInput: OnInput;
/** Current input mode. Set by InputConsumer, read by producers. */
mode: InputMode;
/** Setter for mode (called by InputConsumer). */
setMode: (mode: InputMode) => void;
}
export const InputContext = createContext<InputContextValue | null>(null);
export function useInputContext(): InputContextValue {
const ctx = useContext(InputContext);
if (!ctx) {
throw new Error("useInputContext must be used within an InputProvider");
}
return ctx;
}
export function useOnInput(): OnInput {
return useInputContext().onInput;
}
export function useInputMode(): InputMode {
return useInputContext().mode;
}

View file

@ -1,4 +1,4 @@
import { lazy, ReactNode, Suspense } 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";
@ -6,6 +6,12 @@ import {
KeyboardAndMouseHandler,
KEYBOARD_CONTROLS,
} from "./KeyboardAndMouseHandler";
import {
InputContext,
type InputFrame,
type InputMode,
type OnInput,
} from "./InputContext";
const TouchHandler = lazy(() =>
import("@/src/components/TouchHandler").then((mod) => ({
@ -14,14 +20,23 @@ const TouchHandler = lazy(() =>
);
export function InputProvider({ children }: { children: ReactNode }) {
const moveQueue = useRef<InputFrame[]>([]);
const [mode, setMode] = useState<InputMode>("local");
const onInput: OnInput = useCallback((frame: InputFrame) => {
moveQueue.current.push(frame);
}, []);
return (
<KeyboardControls map={KEYBOARD_CONTROLS}>
<JoystickProvider>{children}</JoystickProvider>
</KeyboardControls>
<InputContext.Provider value={{ moveQueue, onInput, mode, setMode }}>
<KeyboardControls map={KEYBOARD_CONTROLS}>
<JoystickProvider>{children}</JoystickProvider>
</KeyboardControls>
</InputContext.Provider>
);
}
export function InputHandlers() {
export function InputProducers() {
const isTouch = useTouchDevice();
return (
@ -35,3 +50,6 @@ export function InputHandlers() {
</>
);
}
/** @deprecated Use `InputProducers` instead. */
export const InputHandlers = InputProducers;

View file

@ -170,12 +170,12 @@ export function InspectorControls({
<input
id="speedInput"
type="range"
min={0.1}
max={5}
step={0.05}
value={speedMultiplier}
min={1}
max={100}
step={1}
value={Math.round(speedMultiplier * 100)}
onChange={(event) =>
setSpeedMultiplier(parseFloat(event.target.value))
setSpeedMultiplier(parseFloat(event.target.value) / 100)
}
/>
<p className={styles.Description}>

View file

@ -1,11 +1,14 @@
import { useEffect, useEffectEvent, useRef } from "react";
import { Euler, Vector3 } from "three";
import { useFrame, useThree } from "@react-three/fiber";
import { useKeyboardControls } from "@react-three/drei";
import { PointerLockControls } from "three-stdlib";
import { useControls } from "./SettingsProvider";
import {
MAX_SPEED_MULTIPLIER,
MIN_SPEED_MULTIPLIER,
useControls,
} from "./SettingsProvider";
import { useCameras } from "./CamerasProvider";
import { streamPlaybackStore } from "../state/streamPlaybackStore";
import { useInputContext } from "./InputContext";
export enum Controls {
forward = "forward",
@ -51,16 +54,22 @@ export const KEYBOARD_CONTROLS = [
{ name: Controls.camera9, keys: ["Digit9"] },
];
const BASE_SPEED = 80;
const MIN_SPEED_ADJUSTMENT = 0.05;
const MAX_SPEED_ADJUSTMENT = 0.5;
const MAX_PITCH = Math.PI / 2 - 0.01; // ~89°
const MIN_SPEED_ADJUSTMENT = 2;
const MAX_SPEED_ADJUSTMENT = 10;
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 ARROW_LOOK_SPEED = 1; // radians/sec
function quantizeSpeed(speedMultiplier: number): number {
// Map [0.01, 1] → [1/16, 1], snapped to the 6-bit grid (multiples of 1/16).
const t =
(speedMultiplier - MIN_SPEED_MULTIPLIER) / (1 - MIN_SPEED_MULTIPLIER);
const steps = Math.round(t * 15); // 0..15 → 16 levels (1/16 to 16/16)
return (steps + 1) / 16;
}
export function KeyboardAndMouseHandler() {
// Don't let KeyboardControls handle stuff when metaKey is held.
useEffect(() => {
@ -85,6 +94,7 @@ export function KeyboardAndMouseHandler() {
const { speedMultiplier, setSpeedMultiplier, invertScroll, invertDrag } =
useControls();
const { onInput, mode } = useInputContext();
const [subscribe, getKeys] = useKeyboardControls<Controls>();
const camera = useThree((state) => state.camera);
const gl = useThree((state) => state.gl);
@ -94,11 +104,13 @@ export function KeyboardAndMouseHandler() {
const getInvertScroll = useEffectEvent(() => invertScroll);
const getInvertDrag = useEffectEvent(() => invertDrag);
// Scratch vectors/euler to avoid allocations each frame
const forwardVec = useRef(new Vector3());
const sideVec = useRef(new Vector3());
const moveVec = useRef(new Vector3());
const lookEuler = useRef(new Euler(0, 0, 0, "YXZ"));
// Accumulated mouse deltas between frames.
const mouseDeltaYaw = useRef(0);
const mouseDeltaPitch = useRef(0);
// Trigger flags set by event handlers, consumed in useFrame.
const triggerFire = useRef(false);
const triggerObserve = useRef(false);
// Setup pointer lock controls
useEffect(() => {
@ -110,11 +122,11 @@ export function KeyboardAndMouseHandler() {
};
}, [camera, gl.domElement]);
// When pointer is locked: click cycles camera.
// When pointer is unlocked: drag rotates camera, click locks pointer.
// Mouse handling: accumulate deltas for input frames.
// In local mode, drag-to-look works without pointer lock.
// Pointer lock and click behavior depend on mode.
useEffect(() => {
const canvas = gl.domElement;
const euler = new Euler(0, 0, 0, "YXZ");
let dragging = false;
let didDrag = false;
let startX = 0;
@ -130,6 +142,13 @@ export function KeyboardAndMouseHandler() {
};
const handleMouseMove = (e: MouseEvent) => {
if (controlsRef.current?.isLocked) {
// Pointer is locked: accumulate raw deltas.
mouseDeltaYaw.current += e.movementX * MOUSE_SENSITIVITY;
mouseDeltaPitch.current += e.movementY * MOUSE_SENSITIVITY;
return;
}
if (!dragging) return;
if (
!didDrag &&
@ -141,11 +160,8 @@ export function KeyboardAndMouseHandler() {
didDrag = true;
const dragSign = getInvertDrag() ? -1 : 1;
euler.setFromQuaternion(camera.quaternion, "YXZ");
euler.y += dragSign * e.movementX * MOUSE_SENSITIVITY;
euler.x += dragSign * e.movementY * MOUSE_SENSITIVITY;
euler.x = Math.max(-MAX_PITCH, Math.min(MAX_PITCH, euler.x));
camera.quaternion.setFromEuler(euler);
mouseDeltaYaw.current += dragSign * e.movementX * MOUSE_SENSITIVITY;
mouseDeltaPitch.current += dragSign * e.movementY * MOUSE_SENSITIVITY;
};
const handleMouseUp = () => {
@ -154,10 +170,17 @@ export function KeyboardAndMouseHandler() {
const handleClick = (e: MouseEvent) => {
const controls = controlsRef.current;
if (!controls || controls.isLocked) {
nextCamera();
if (controls?.isLocked) {
if (mode === "follow") {
// In follow mode, click cycles to next player (trigger 0).
triggerFire.current = true;
} else if (mode === "local") {
// In local mode, click while locked cycles preset cameras.
nextCamera();
}
// In fly mode, clicks while locked do nothing special.
} else if (e.target === canvas && !didDrag) {
controls.lock();
controls?.lock();
}
};
@ -172,9 +195,9 @@ export function KeyboardAndMouseHandler() {
document.removeEventListener("mouseup", handleMouseUp);
document.removeEventListener("click", handleClick);
};
}, [camera, gl.domElement, nextCamera]);
}, [camera, gl.domElement, nextCamera, mode]);
// Handle number keys 1-9 for camera selection
// Handle number keys 1-9 for camera selection (local-only UI action).
useEffect(() => {
const cameraControls = [
Controls.camera1,
@ -198,7 +221,7 @@ export function KeyboardAndMouseHandler() {
});
}, [subscribe, setCameraIndex, cameraCount]);
// Handle mousewheel for speed adjustment
// Handle mousewheel for speed adjustment (local setting, stays in KMH).
useEffect(() => {
const handleWheel = (e: WheelEvent) => {
e.preventDefault();
@ -207,16 +230,17 @@ export function KeyboardAndMouseHandler() {
const direction = (e.deltaY > 0 ? -1 : 1) * scrollSign;
const delta =
// Helps normalize sensitivity; trackpad scrolling will have many small
// updates while mouse wheels have fewer updates but large deltas.
Math.max(
MIN_SPEED_ADJUSTMENT,
Math.min(MAX_SPEED_ADJUSTMENT, Math.abs(e.deltaY * 0.01)),
) * direction;
setSpeedMultiplier((prev) => {
const newSpeed = Math.round((prev + delta) * 20) / 20;
return Math.max(0.1, Math.min(5, newSpeed));
const newSpeed = Math.round(prev * 100) + delta;
return Math.max(
MIN_SPEED_MULTIPLIER,
Math.min(MAX_SPEED_MULTIPLIER, newSpeed / 100),
);
});
};
@ -228,12 +252,28 @@ export function KeyboardAndMouseHandler() {
};
}, [gl.domElement, setSpeedMultiplier]);
useFrame((state, delta) => {
// When streaming is active and not in free-fly mode, the stream
// (StreamingController) drives the camera — skip our movement.
const spState = streamPlaybackStore.getState();
if (spState.playback && !spState.freeFlyCamera) return;
// 'O' key: toggle observer mode (sets trigger 2).
useEffect(() => {
if (mode === "local") return;
const handleKey = (e: KeyboardEvent) => {
if (e.code !== "KeyO" || e.metaKey || e.ctrlKey || e.altKey) return;
const target = e.target as HTMLElement;
if (
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.isContentEditable
) {
return;
}
triggerObserve.current = true;
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [mode]);
// Build and emit InputFrame each render frame.
useFrame((_state, delta) => {
const {
forward,
backward,
@ -247,58 +287,60 @@ export function KeyboardAndMouseHandler() {
lookRight,
} = getKeys();
// Arrow keys: rotate camera look direction
if (lookUp || lookDown || lookLeft || lookRight) {
lookEuler.current.setFromQuaternion(camera.quaternion, "YXZ");
if (lookLeft) lookEuler.current.y += ARROW_LOOK_SPEED * delta;
if (lookRight) lookEuler.current.y -= ARROW_LOOK_SPEED * delta;
if (lookUp) lookEuler.current.x += ARROW_LOOK_SPEED * delta;
if (lookDown) lookEuler.current.x -= ARROW_LOOK_SPEED * delta;
lookEuler.current.x = Math.max(
-MAX_PITCH,
Math.min(MAX_PITCH, lookEuler.current.x),
);
camera.quaternion.setFromEuler(lookEuler.current);
// Arrow keys contribute to look deltas.
let deltaYaw = mouseDeltaYaw.current;
let deltaPitch = mouseDeltaPitch.current;
mouseDeltaYaw.current = 0;
mouseDeltaPitch.current = 0;
if (lookLeft) deltaYaw -= ARROW_LOOK_SPEED * delta;
if (lookRight) deltaYaw += ARROW_LOOK_SPEED * delta;
if (lookUp) deltaPitch -= ARROW_LOOK_SPEED * delta;
if (lookDown) deltaPitch += ARROW_LOOK_SPEED * delta;
// Movement axes, pre-scaled by speedMultiplier (clamped to [-1, 1]).
let x = 0;
let y = 0;
let z = 0;
if (left) x -= 1;
if (right) x += 1;
if (forward) y += 1;
if (backward) y -= 1;
if (up) z += 1;
if (down) z -= 1;
const quantizedSpeedMultiplier = quantizeSpeed(speedMultiplier);
x = Math.max(-1, Math.min(1, x * quantizedSpeedMultiplier));
y = Math.max(-1, Math.min(1, y * quantizedSpeedMultiplier));
z = Math.max(-1, Math.min(1, z * quantizedSpeedMultiplier));
// Triggers.
const triggers = [false, false, false, false, false, false];
if (triggerFire.current) {
triggers[0] = true;
triggerFire.current = false;
}
if (triggerObserve.current) {
triggers[2] = true;
triggerObserve.current = false;
}
if (!forward && !backward && !left && !right && !up && !down) {
return;
}
// Only emit if there's actual input.
const hasLook = deltaYaw !== 0 || deltaPitch !== 0;
const hasMove = x !== 0 || y !== 0 || z !== 0;
const hasTriggers = triggers.some(Boolean);
if (!hasLook && !hasMove && !hasTriggers) return;
const speed = BASE_SPEED * speedMultiplier;
// Forward/backward: take complete camera angle into account (including Y)
camera.getWorldDirection(forwardVec.current);
forwardVec.current.normalize();
// Left/right: move along XZ plane
sideVec.current.crossVectors(camera.up, forwardVec.current).normalize();
moveVec.current.set(0, 0, 0);
if (forward) {
moveVec.current.add(forwardVec.current);
}
if (backward) {
moveVec.current.sub(forwardVec.current);
}
if (left) {
moveVec.current.add(sideVec.current);
}
if (right) {
moveVec.current.sub(sideVec.current);
}
if (up) {
moveVec.current.y += 1;
}
if (down) {
moveVec.current.y -= 1;
}
if (moveVec.current.lengthSq() > 0) {
moveVec.current.normalize().multiplyScalar(speed * delta);
camera.position.add(moveVec.current);
}
onInput({
deltaYaw,
deltaPitch,
x,
y,
z,
triggers,
delta,
});
});
return null;

View file

@ -1,365 +0,0 @@
import { useRef, useEffect } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import { Vector3 } from "three";
import { useKeyboardControls } from "@react-three/drei";
import { createLogger } from "../logger";
import {
liveConnectionStore,
useLiveSelector,
} from "../state/liveConnectionStore";
import { useEngineStoreApi } from "../state/engineStore";
import { streamPlaybackStore } from "../state/streamPlaybackStore";
import {
Controls,
MOUSE_SENSITIVITY,
ARROW_LOOK_SPEED,
} from "./KeyboardAndMouseHandler";
import { useControls } from "./SettingsProvider";
import { useTick, TICK_RATE } from "./TickProvider";
import { yawPitchToQuaternion, MAX_PITCH } from "../stream/streamHelpers";
import type { StreamRecording, StreamCamera } from "../stream/types";
import type { LiveStreamAdapter } from "../stream/liveStreaming";
const log = createLogger("LiveObserver");
const TICK_INTERVAL = 1 / TICK_RATE;
// Scratch objects to avoid per-frame allocations.
const _orbitDir = new Vector3();
const _orbitTarget = new Vector3();
/** Predicted camera rotation state for client-side prediction. */
interface PredictionState {
/** Absolute predicted yaw (Torque radians). */
yaw: number;
/** Absolute predicted pitch (Torque radians). */
pitch: number;
/** Previous tick's yaw, for inter-tick interpolation. */
prevYaw: number;
/** Previous tick's pitch, for inter-tick interpolation. */
prevPitch: number;
/** Whether prediction has been initialized from a server snapshot. */
initialized: boolean;
/** Last server camera snapshot we synced from (identity check for new data). */
lastSyncedCamera: StreamCamera | null;
}
/**
* Bridges the LiveStreamAdapter into the playback pipeline.
* Sends Move structs to the relay and applies client-side rotation prediction
* so camera look feels responsive at frame rate, matching how the real
* Tribes 2 client works (predict locally, correct from server).
*/
export function LiveObserver() {
const adapter = useLiveSelector((s) => s.adapter);
const gameStatus = useLiveSelector((s) => s.gameStatus);
const sendMove = useLiveSelector((s) => s.sendMove);
const store = useEngineStoreApi();
const { speedMultiplier } = useControls();
const activeAdapterRef = useRef<LiveStreamAdapter | null>(null);
const gl = useThree((state) => state.gl);
const [, getKeys] = useKeyboardControls<Controls>();
// Accumulated rotation deltas since last move was sent. Mouse events and
// arrow keys both add to these; consumed at the tick rate (32ms).
const deltaYawRef = useRef(0);
const deltaPitchRef = useRef(0);
// Client-side prediction state.
const predRef = useRef<PredictionState>({
yaw: 0,
pitch: 0,
prevYaw: 0,
prevPitch: 0,
initialized: false,
lastSyncedCamera: null,
});
// Sub-tick accumulator for interpolation (0..TICK_INTERVAL).
const tickAccRef = useRef(0);
// Wire adapter to engine store.
useEffect(() => {
if (
adapter &&
(gameStatus === "connected" || gameStatus === "authenticating")
) {
if (activeAdapterRef.current === adapter) return;
log.info("wiring adapter to engine store");
const liveState = liveConnectionStore.getState();
const liveRecording: StreamRecording = {
source: "live",
duration: Infinity,
missionName: liveState.mapName ?? null,
gameType: null,
serverDisplayName: liveState.serverName ?? null,
recorderName: liveState.warriorName ?? null,
recordingDate: null,
streamingPlayback: adapter,
};
store.getState().setRecording(liveRecording);
store.getState().setPlaybackStatus("playing");
activeAdapterRef.current = adapter;
// Reset prediction when connecting to a new server.
predRef.current.initialized = false;
predRef.current.lastSyncedCamera = null;
} else if (!adapter && activeAdapterRef.current) {
// Only clear the recording if it's still the live one we set.
const current = store.getState().playback.recording;
if (current?.source === "live") {
store.getState().setRecording(null);
}
activeAdapterRef.current = null;
predRef.current.initialized = false;
}
}, [adapter, gameStatus, store]);
// Accumulate mouse deltas when pointer is locked or dragging.
useEffect(() => {
let dragging = false;
const handleMouseMove = (e: MouseEvent) => {
if (document.pointerLockElement) {
// Match Three.js PointerLockControls default (0.002).
deltaYawRef.current += e.movementX * 0.002;
deltaPitchRef.current += e.movementY * 0.002;
} else if (dragging) {
deltaYawRef.current += e.movementX * MOUSE_SENSITIVITY;
deltaPitchRef.current += e.movementY * MOUSE_SENSITIVITY;
}
};
const handleMouseDown = (e: MouseEvent) => {
if (!document.pointerLockElement && e.target === gl.domElement) {
dragging = true;
}
};
const handleMouseUp = () => {
dragging = false;
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mousedown", handleMouseDown);
document.addEventListener("mouseup", handleMouseUp);
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mousedown", handleMouseDown);
document.removeEventListener("mouseup", handleMouseUp);
};
}, [gl.domElement]);
// Left-click when pointer-locked in follow mode: cycle to next player.
// Only intercepts in follow mode — in fly mode, clicks pass through to
// ObserverControls for pointer lock acquisition.
useEffect(() => {
const handleClick = (e: MouseEvent) => {
if (!document.pointerLockElement || !activeAdapterRef.current) return;
if (activeAdapterRef.current.observerMode !== "follow") return;
e.stopImmediatePropagation();
activeAdapterRef.current.cycleObserveNext();
};
document.addEventListener("click", handleClick, { capture: true });
return () => {
document.removeEventListener("click", handleClick, { capture: true });
};
}, [gl.domElement]);
// 'O' toggles between follow and free-fly observer modes on the server.
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.code !== "KeyO" || e.metaKey || e.ctrlKey || e.altKey) return;
const target = e.target as HTMLElement;
if (
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.isContentEditable
) {
return;
}
if (!activeAdapterRef.current) return;
activeAdapterRef.current.toggleObserverMode();
log.info("observer mode: %s", activeAdapterRef.current.observerMode);
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, []);
// Accumulate arrow-key rotation each render frame (frame-rate independent).
useFrame((_, delta) => {
if (!activeAdapterRef.current || gameStatus !== "connected") return;
const { lookUp, lookDown, lookLeft, lookRight } = getKeys();
if (lookRight) deltaYawRef.current += ARROW_LOOK_SPEED * delta;
if (lookLeft) deltaYawRef.current -= ARROW_LOOK_SPEED * delta;
if (lookDown) deltaPitchRef.current += ARROW_LOOK_SPEED * delta;
if (lookUp) deltaPitchRef.current -= ARROW_LOOK_SPEED * delta;
});
// Send moves at the Torque tick rate (32Hz) and apply rotation prediction.
useTick(() => {
if (!activeAdapterRef.current || gameStatus !== "connected") return;
const { forward, backward, left, right, up, down } = getKeys();
// Torque Camera axes: x = strafe (+ right), y = forward/back, z = up/down.
let mx = 0;
let my = 0;
let mz = 0;
if (forward) my += 1;
if (backward) my -= 1;
if (left) mx -= 1;
if (right) mx += 1;
if (up) mz += 1;
if (down) mz -= 1;
// Consume accumulated rotation deltas.
const yaw = deltaYawRef.current;
const pitch = deltaPitchRef.current;
deltaYawRef.current = 0;
deltaPitchRef.current = 0;
// Apply prediction: save previous tick state, then advance.
const pred = predRef.current;
pred.prevYaw = pred.yaw;
pred.prevPitch = pred.pitch;
pred.yaw += yaw;
pred.pitch = Math.max(-MAX_PITCH, Math.min(MAX_PITCH, pred.pitch + pitch));
// Reset sub-tick accumulator for interpolation.
tickAccRef.current = 0;
// Always set trigger[1] (altTrigger) to enable the server's 2× speed mode
// (80 u/s max). We use altTrigger instead of trigger[0] (fire) because the
// Observer::onTrigger script interprets fire as "join team" / "cycle player"
// depending on camera mode, but altTrigger is unhandled in all observer modes.
// The C++ Camera::processTick checks `trigger[0] || trigger[1]` for fast mode.
const speed = Math.min(1, speedMultiplier);
sendMove({
x: mx * speed,
y: my * speed,
z: mz * speed,
yaw,
pitch,
roll: 0,
trigger: [false, true, false, false, false, false],
freeLook: false,
});
});
// Override camera rotation with predicted values at frame rate.
// Priority 1 ensures this runs AFTER StreamingController (priority 0),
// which handles position from server snapshots.
useFrame((state, delta) => {
if (!activeAdapterRef.current || gameStatus !== "connected") return;
const pred = predRef.current;
// Sync prediction base from each new server snapshot. The server's
// yaw/pitch is authoritative; we layer any pending (unconsumed) mouse
// deltas on top so the camera feels responsive between server updates.
const snapshot = activeAdapterRef.current.getSnapshot();
const serverCam = snapshot?.camera;
if (
serverCam &&
serverCam !== pred.lastSyncedCamera &&
typeof serverCam.yaw === "number" &&
typeof serverCam.pitch === "number"
) {
// Pending deltas not yet consumed by useTick — replay on top of server.
const pendingYaw = deltaYawRef.current;
const pendingPitch = deltaPitchRef.current;
pred.prevYaw = pred.initialized ? pred.yaw : serverCam.yaw;
pred.prevPitch = pred.initialized ? pred.pitch : serverCam.pitch;
pred.yaw = serverCam.yaw + pendingYaw;
pred.pitch = Math.max(
-MAX_PITCH,
Math.min(MAX_PITCH, serverCam.pitch + pendingPitch),
);
pred.lastSyncedCamera = serverCam;
pred.initialized = true;
}
if (!pred.initialized) return;
// Advance sub-tick accumulator for interpolation.
tickAccRef.current += delta;
const t = Math.min(1, tickAccRef.current / TICK_INTERVAL);
// Interpolate between previous and current tick prediction, then add
// pending (unconsumed) mouse/arrow deltas so rotation responds at frame
// rate rather than waiting for the next useTick to consume them.
const interpYaw =
pred.prevYaw + (pred.yaw - pred.prevYaw) * t + deltaYawRef.current;
const interpPitch = Math.max(
-MAX_PITCH,
Math.min(
MAX_PITCH,
pred.prevPitch +
(pred.pitch - pred.prevPitch) * t +
deltaPitchRef.current,
),
);
// Convert predicted rotation to Three.js quaternion and apply.
const [qx, qy, qz, qw] = yawPitchToQuaternion(interpYaw, interpPitch);
// For third-person (orbit) mode, recompute orbit position from predicted
// angles so the orbit responds at frame rate too.
if (serverCam?.mode === "third-person" && serverCam.orbitTargetId) {
const root = streamPlaybackStore.getState().root;
const targetGroup = root?.children.find(
(child) => child.name === serverCam.orbitTargetId,
);
if (targetGroup) {
_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(interpPitch);
const cx = Math.cos(interpPitch);
const sz = Math.sin(interpYaw);
const cz = Math.cos(interpYaw);
// Camera pulls back along negative forward direction (Torque column 1
// of Rz*Rx, converted to Three.js coords).
// Torque forward = (-sz*cx, cz*cx, sx) → Three.js = (cz*cx, sx, -sz*cx)
// Negate for pull-back: (-cz*cx, -sx, sz*cx)
_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);
}
}
} else {
// Observer fly or first-person: override rotation only (position comes
// from StreamingController's server snapshot interpolation).
state.camera.quaternion.set(qx, qy, qz, qw);
}
});
// Clean up on unmount.
useEffect(() => {
return () => {
if (activeAdapterRef.current) {
const current = store.getState().playback.recording;
if (current?.source === "live") {
store.getState().setRecording(null);
}
activeAdapterRef.current = null;
}
};
}, [store]);
return null;
}

View file

@ -125,9 +125,12 @@ function MusicPlayer({ track }: { track: string }) {
const url = `${RESOURCE_ROOT_URL}music/${track.toLowerCase()}.mp3`;
useEffect(() => {
return () => {
audioRef.current?.pause();
};
const audio = audioRef.current;
if (audio) {
return () => {
audio.pause();
};
}
}, []);
const toggle = () => {

View file

@ -14,10 +14,11 @@ import { Camera } from "three";
import { InspectorControls } from "@/src/components/InspectorControls";
import { MissionSelect } from "@/src/components/MissionSelect";
import { StreamingMissionInfo } from "@/src/components/StreamingMissionInfo";
import { SettingsProvider } from "@/src/components/SettingsProvider";
import { useSettings } from "@/src/components/SettingsProvider";
import { ObserverCamera } from "@/src/components/ObserverCamera";
import { AudioProvider } from "@/src/components/AudioContext";
import { CamerasProvider } from "@/src/components/CamerasProvider";
import { InputConsumer } from "./InputConsumer";
import {
RecordingProvider,
useRecording,
@ -33,11 +34,10 @@ import {
import { usePublicWindowAPI } from "@/src/components/usePublicWindowAPI";
import {
CurrentMission,
useFogQueryState,
useMissionQueryState,
} from "@/src/components/useQueryParams";
import { ThreeCanvas, InvalidateFunction } from "@/src/components/ThreeCanvas";
import { InputHandlers, InputProvider } from "./InputHandlers";
import { InputProducers, InputProvider } from "./InputHandlers";
import { VisualInput } from "./VisualInput";
import { LoadingIndicator } from "./LoadingIndicator";
import { AudioEnabled } from "./AudioEnabled";
@ -81,10 +81,6 @@ const DebugElements = createLazy(
() => import("@/src/components/DebugElements"),
);
const Mission = createLazy("Mission", () => import("@/src/components/Mission"));
const LiveObserver = createLazy(
"LiveObserver",
() => import("@/src/components/LiveObserver"),
);
const ChatSoundPlayer = createLazy(
"ChatSoundPlayer",
() => import("@/src/components/ChatSoundPlayer"),
@ -104,16 +100,12 @@ const ServerBrowser = createLazy(
export function MapInspector() {
const [currentMission, setCurrentMission] = useMissionQueryState();
const [fogEnabledOverride, setFogEnabledOverride] = useFogQueryState();
const clearFogEnabledOverride = useCallback(() => {
setFogEnabledOverride(null);
}, [setFogEnabledOverride]);
const features = useFeatures();
const { clearFogEnabledOverride, sidebarOpen, setSidebarOpen } =
useSettings();
const { missionName, missionType } = currentMission;
const [mapInfoOpen, setMapInfoOpen] = useState(false);
const [serverBrowserOpen, setServerBrowserOpen] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState(false);
const [choosingMap, setChoosingMap] = useState(false);
const [missionLoadingProgress, setMissionLoadingProgress] = useState(0);
const [showLoadingIndicator, setShowLoadingIndicator] = useState(true);
@ -136,7 +128,7 @@ export function MapInspector() {
setSidebarOpen(false);
}
},
[clearFogEnabledOverride, setCurrentMission, isTouch],
[clearFogEnabledOverride, setCurrentMission, isTouch, setSidebarOpen],
);
usePublicWindowAPI({ onChangeMission: changeMission });
@ -144,8 +136,6 @@ export function MapInspector() {
const recording = useRecording();
const dataSource = useDataSource();
const hasStreamData = dataSource === "demo" || dataSource === "live";
const hasLiveAdapter = useLiveSelector((s) => s.adapter != null);
// Sync the mission query param when streaming data provides a mission name.
const streamMissionName = useMissionName();
const streamMissionType = useMissionType();
@ -180,13 +170,13 @@ export function MapInspector() {
if (gameStatus === "connected" && isTouch) {
setSidebarOpen(false);
}
}, [gameStatus, isTouch]);
}, [gameStatus, isTouch, setSidebarOpen]);
useEffect(() => {
if (recording && isTouch) {
setSidebarOpen(false);
}
}, [isTouch, recording]);
}, [isTouch, recording, setSidebarOpen]);
const loadingProgress = missionLoadingProgress;
const isLoading = loadingProgress < 1;
@ -214,196 +204,183 @@ export function MapInspector() {
return (
<main className={styles.Frame}>
<RecordingProvider>
<SettingsProvider
fogEnabledOverride={fogEnabledOverride}
onClearFogEnabledOverride={clearFogEnabledOverride}
<header
className={styles.Toolbar}
onKeyDown={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
<header
className={styles.Toolbar}
onKeyDown={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
<button
type="button"
className={styles.ToggleSidebarButton}
data-orientation="top"
aria-label={sidebarOpen ? "Close sidebar" : "Open sidebar"}
title={sidebarOpen ? "Close sidebar" : "Open sidebar"}
onClick={(event) => {
startTransition(() => setSidebarOpen((open) => !open));
}}
>
<button
type="button"
className={styles.ToggleSidebarButton}
data-orientation="top"
aria-label={sidebarOpen ? "Close sidebar" : "Open sidebar"}
title={sidebarOpen ? "Close sidebar" : "Open sidebar"}
onClick={(event) => {
startTransition(() => setSidebarOpen((open) => !open));
}}
>
{sidebarOpen ? <LuPanelTopClose /> : <LuPanelTopOpen />}
</button>
<button
type="button"
className={styles.ToggleSidebarButton}
data-orientation="left"
aria-label={sidebarOpen ? "Close sidebar" : "Open sidebar"}
title={sidebarOpen ? "Close sidebar" : "Open sidebar"}
onClick={(event) => {
startTransition(() => setSidebarOpen((open) => !open));
}}
>
{sidebarOpen ? <LuPanelLeftClose /> : <LuPanelLeftOpen />}
</button>
<Activity
mode={hasStreamData && !choosingMap ? "visible" : "hidden"}
>
<StreamingMissionInfo />
</Activity>
<Activity
mode={!hasStreamData || choosingMap ? "visible" : "hidden"}
>
<MissionSelect
value={choosingMap ? "" : missionName}
missionType={choosingMap ? "" : missionType}
onChange={changeMission}
autoFocus={choosingMap}
/>
{choosingMap && (
<button
type="button"
className={styles.CancelButton}
onClick={() => {
setChoosingMap(false);
}}
>
Cancel
</button>
)}
</Activity>
</header>
{sidebarOpen ? <div className={styles.Backdrop} /> : null}
<Activity mode={sidebarOpen ? "visible" : "hidden"}>
<ViewTransition>
<div
className={styles.Sidebar}
onKeyDown={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
data-open={sidebarOpen}
>
<InspectorControls
missionName={missionName}
missionType={missionType}
onOpenMapInfo={() => setMapInfoOpen(true)}
onOpenServerBrowser={
features.live ? () => setServerBrowserOpen(true) : undefined
}
onChooseMap={
hasStreamData
? () => {
setChoosingMap(true);
}
: undefined
}
onCancelChoosingMap={() => {
setChoosingMap(false);
}}
choosingMap={choosingMap}
cameraRef={cameraRef}
invalidateRef={invalidateRef}
/>
</div>
</ViewTransition>
{sidebarOpen ? <LuPanelTopClose /> : <LuPanelTopOpen />}
</button>
<button
type="button"
className={styles.ToggleSidebarButton}
data-orientation="left"
aria-label={sidebarOpen ? "Close sidebar" : "Open sidebar"}
title={sidebarOpen ? "Close sidebar" : "Open sidebar"}
onClick={(event) => {
startTransition(() => setSidebarOpen((open) => !open));
}}
>
{sidebarOpen ? <LuPanelLeftClose /> : <LuPanelLeftOpen />}
</button>
<Activity mode={hasStreamData && !choosingMap ? "visible" : "hidden"}>
<StreamingMissionInfo />
</Activity>
<InputProvider>
<div className={styles.Content}>
<div className={styles.ThreeView}>
<ThreeCanvas
dpr={mapInfoOpen || serverBrowserOpen ? 0.25 : undefined}
onCreated={(state) => {
cameraRef.current = state.camera;
invalidateRef.current = state.invalidate;
}}
>
<TickProvider>
<CamerasProvider>
<InputHandlers />
<AudioProvider>
<SceneLighting />
<Suspense>
<EntityScene />
</Suspense>
<ObserverCamera />
<AudioEnabled>
<ChatSoundPlayer />
</AudioEnabled>
<DebugEnabled>
<DebugElements />
</DebugEnabled>
{recording ? (
<Suspense>
<StreamingController recording={recording} />
</Suspense>
) : null}
{!hasStreamData ? (
<Suspense>
<Mission
key={`${missionName}~${missionType}`}
name={missionName}
missionType={missionType}
onLoadingChange={handleLoadingChange}
/>
</Suspense>
) : null}
{hasLiveAdapter ? (
<Suspense>
<LiveObserver />
</Suspense>
) : null}
</AudioProvider>
</CamerasProvider>
</TickProvider>
</ThreeCanvas>
</div>
{hasStreamData ? (
<Suspense>
<PlayerHUD />
</Suspense>
) : null}
<VisualInput />
{showLoadingIndicator && (
<LoadingIndicator
isLoading={isLoading}
progress={loadingProgress}
/>
)}
<Activity mode={!hasStreamData || choosingMap ? "visible" : "hidden"}>
<MissionSelect
value={choosingMap ? "" : missionName}
missionType={choosingMap ? "" : missionType}
onChange={changeMission}
autoFocus={choosingMap}
/>
{choosingMap && (
<button
type="button"
className={styles.CancelButton}
onClick={() => {
setChoosingMap(false);
}}
>
Cancel
</button>
)}
</Activity>
</header>
{sidebarOpen ? <div className={styles.Backdrop} /> : null}
<Activity mode={sidebarOpen ? "visible" : "hidden"}>
<ViewTransition>
<div
className={styles.Sidebar}
onKeyDown={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
data-open={sidebarOpen}
>
<InspectorControls
missionName={missionName}
missionType={missionType}
onOpenMapInfo={() => setMapInfoOpen(true)}
onOpenServerBrowser={
features.live ? () => setServerBrowserOpen(true) : undefined
}
onChooseMap={
hasStreamData
? () => {
setChoosingMap(true);
}
: undefined
}
onCancelChoosingMap={() => {
setChoosingMap(false);
}}
choosingMap={choosingMap}
cameraRef={cameraRef}
invalidateRef={invalidateRef}
/>
</div>
</InputProvider>
<footer
className={styles.PlayerBar}
onKeyDown={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
{recording?.source === "demo" ? (
</ViewTransition>
</Activity>
<InputProvider>
<div className={styles.Content}>
<div className={styles.ThreeView}>
<ThreeCanvas
dpr={mapInfoOpen || serverBrowserOpen ? 0.25 : undefined}
onCreated={(state) => {
cameraRef.current = state.camera;
invalidateRef.current = state.invalidate;
}}
>
<TickProvider>
<CamerasProvider>
<InputProducers />
<AudioProvider>
<SceneLighting />
<Suspense>
<EntityScene />
</Suspense>
<ObserverCamera />
<AudioEnabled>
<ChatSoundPlayer />
</AudioEnabled>
<DebugEnabled>
<DebugElements />
</DebugEnabled>
{recording ? (
<Suspense>
<StreamingController recording={recording} />
</Suspense>
) : null}
{!hasStreamData ? (
<Suspense>
<Mission
key={`${missionName}~${missionType}`}
name={missionName}
missionType={missionType}
onLoadingChange={handleLoadingChange}
/>
</Suspense>
) : null}
<InputConsumer />
</AudioProvider>
</CamerasProvider>
</TickProvider>
</ThreeCanvas>
</div>
{hasStreamData ? (
<Suspense>
<DemoPlaybackControls />
<PlayerHUD />
</Suspense>
) : null}
</footer>
{mapInfoOpen ? (
<ViewTransition>
<Suspense>
<MapInfoDialog
onClose={() => setMapInfoOpen(false)}
missionName={missionName}
missionType={missionType ?? ""}
/>
</Suspense>
</ViewTransition>
<VisualInput />
{showLoadingIndicator && (
<LoadingIndicator
isLoading={isLoading}
progress={loadingProgress}
/>
)}
</div>
</InputProvider>
<footer
className={styles.PlayerBar}
onKeyDown={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
{recording?.source === "demo" ? (
<Suspense>
<DemoPlaybackControls />
</Suspense>
) : null}
{serverBrowserOpen ? (
<ViewTransition>
<Suspense>
<ServerBrowser onClose={() => setServerBrowserOpen(false)} />
</Suspense>
</ViewTransition>
) : null}
</SettingsProvider>
</footer>
{mapInfoOpen ? (
<ViewTransition>
<Suspense>
<MapInfoDialog
onClose={() => setMapInfoOpen(false)}
missionName={missionName}
missionType={missionType ?? ""}
/>
</Suspense>
</ViewTransition>
) : null}
{serverBrowserOpen ? (
<ViewTransition>
<Suspense>
<ServerBrowser onClose={() => setServerBrowserOpen(false)} />
</Suspense>
</ViewTransition>
) : null}
</RecordingProvider>
</main>
);

View file

@ -73,7 +73,7 @@
.WeaponHUD {
position: absolute;
right: 8px;
right: 6px;
top: 50%;
transform: translateY(-50%);
display: flex;
@ -87,18 +87,27 @@
/* ── Team Scores (bottom-left) ── */
.TeamInfo {
display: flex;
flex-direction: column;
gap: 2px;
}
.TeamScores {
position: absolute;
bottom: 8px;
left: 8px;
font-family: monospace;
bottom: 6px;
left: 6px;
font-size: 12px;
border: 1px solid rgba(128, 255, 200, 0.15);
}
.TeamRow {
flex: 1 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
padding: 2px 8px;
padding: 4px 8px 4px 6px;
background: rgba(0, 50, 60, 0.65);
}
@ -106,35 +115,39 @@
border-top: 1px solid rgba(128, 255, 200, 0.15);
}
.TeamName {
min-width: 6em;
font-size: 12px;
font-weight: 500;
}
.TeamNameFriendly {
color: #2ecc40;
min-width: 60px;
composes: TeamName;
color: #2de46a;
}
.TeamNameEnemy {
color: #e44;
min-width: 60px;
composes: TeamName;
color: rgb(121, 203, 212);
}
.TeamScore {
color: #fff;
min-width: 24px;
font-weight: 500;
text-align: right;
font-weight: bold;
}
.TeamCount {
color: #9ba;
min-width: 24px;
text-align: right;
font-size: 9px;
}
/* ── Pack + Inventory HUD (bottom-right) ── */
.PackInventoryHUD {
position: absolute;
bottom: 8px;
right: 8px;
bottom: 6px;
right: 6px;
display: flex;
align-items: center;
gap: 4px;
@ -166,7 +179,6 @@
}
.PackInvCount {
font-family: monospace;
font-size: 11px;
color: #bfe;
min-width: 12px;

View file

@ -235,15 +235,20 @@ function TeamScores() {
(DEFAULT_TEAM_NAMES[team.teamId] ?? `Team ${team.teamId}`);
return (
<div key={team.teamId} className={styles.TeamRow}>
<span
className={
isFriendly ? styles.TeamNameFriendly : styles.TeamNameEnemy
}
>
{name}
</span>
<div className={styles.TeamInfo}>
<span
className={
isFriendly ? styles.TeamNameFriendly : styles.TeamNameEnemy
}
>
{name}
</span>{" "}
<span className={styles.TeamCount}>
{team.playerCount}{" "}
{team.playerCount === 1 ? "player" : "players"}
</span>
</div>
<span className={styles.TeamScore}>{team.score}</span>
<span className={styles.TeamCount}>({team.playerCount})</span>
</div>
);
})}

View file

@ -23,6 +23,8 @@ const IFF_ENEMY_URL = textureToUrl("gui/hud_enemytriangle");
const _tmpVec = new Vector3();
const EMPTY_KEYFRAMES = [];
/**
* Floating nameplate above a player model showing the entity name and a health
* bar. Fades out with distance.
@ -45,7 +47,7 @@ export function PlayerNameplate({ entity }: { entity: PlayerEntity }) {
}, [gltf.scene]);
// Check whether this entity has any health data at all.
const keyframes = entity.keyframes ?? [];
const keyframes = entity.keyframes ?? EMPTY_KEYFRAMES;
const hasHealthData = useMemo(
() => keyframes.some((kf) => kf.health != null),
[keyframes],

View file

@ -8,6 +8,8 @@ import {
useRef,
useState,
} from "react";
import { useFogQueryState } from "./useQueryParams";
import { useTouchDevice } from "./useTouchDevice";
type StateSetter<T> = ReturnType<typeof useState<T>>[1];
@ -16,6 +18,7 @@ export type TouchMode = "dualStick" | "moveLookStick";
type SettingsContext = {
fogEnabled: boolean;
setFogEnabled: StateSetter<boolean>;
clearFogEnabledOverride: () => void;
highQualityFog: boolean;
setHighQualityFog: StateSetter<boolean>;
fov: number;
@ -28,6 +31,8 @@ type SettingsContext = {
setWarriorName: StateSetter<string>;
audioVolume: number;
setAudioVolume: StateSetter<number>;
sidebarOpen: boolean;
setSidebarOpen: StateSetter<boolean>;
};
type DebugContext = {
@ -50,6 +55,9 @@ type ControlsContext = {
setInvertJoystick: StateSetter<boolean>;
};
export const MIN_SPEED_MULTIPLIER = 0.01;
export const MAX_SPEED_MULTIPLIER = 1;
const SettingsContext = createContext<SettingsContext | null>(null);
const DebugContext = createContext<DebugContext | null>(null);
const ControlsContext = createContext<ControlsContext | null>(null);
@ -68,6 +76,7 @@ type PersistedSettings = {
invertScroll?: boolean;
invertDrag?: boolean;
invertJoystick?: boolean;
sidebarOpen?: boolean;
};
export function useSettings() {
@ -82,18 +91,10 @@ export function useControls() {
return useContext(ControlsContext);
}
export function SettingsProvider({
children,
fogEnabledOverride,
onClearFogEnabledOverride,
}: {
children: ReactNode;
fogEnabledOverride?: boolean | null;
onClearFogEnabledOverride: () => void;
}) {
export function SettingsProvider({ children }: { children: ReactNode }) {
const [fogEnabled, setFogEnabled] = useState(true);
const [highQualityFog, setHighQualityFog] = useState(false);
const [speedMultiplier, setSpeedMultiplier] = useState(1);
const [speedMultiplier, setSpeedMultiplier] = useState(0.15);
const [fov, setFov] = useState(90);
const [audioEnabled, setAudioEnabled] = useState(false);
const [audioVolume, setAudioVolume] = useState(0.75);
@ -104,20 +105,27 @@ export function SettingsProvider({
const [invertScroll, setInvertScroll] = useState(false);
const [invertDrag, setInvertDrag] = useState(false);
const [invertJoystick, setInvertJoystick] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState(false);
const [renderOnDemand, setRenderOnDemand] = useState(false);
const [fogEnabledOverride, setFogEnabledOverride] = useFogQueryState();
const clearFogEnabledOverride = useCallback(() => {
setFogEnabledOverride(null);
}, [setFogEnabledOverride]);
const setFogEnabledWithoutOverride: StateSetter<boolean> = useCallback(
(value) => {
setFogEnabled(value);
onClearFogEnabledOverride();
clearFogEnabledOverride();
},
[onClearFogEnabledOverride],
[clearFogEnabledOverride],
);
const settingsContext: SettingsContext = useMemo(
() => ({
fogEnabled: fogEnabledOverride ?? fogEnabled,
setFogEnabled: setFogEnabledWithoutOverride,
clearFogEnabledOverride,
highQualityFog,
setHighQualityFog,
fov,
@ -130,17 +138,21 @@ export function SettingsProvider({
setWarriorName,
audioVolume,
setAudioVolume,
sidebarOpen,
setSidebarOpen,
}),
[
fogEnabled,
fogEnabledOverride,
setFogEnabledWithoutOverride,
clearFogEnabledOverride,
highQualityFog,
fov,
audioEnabled,
animationEnabled,
warriorName,
audioVolume,
sidebarOpen,
],
);
@ -178,8 +190,13 @@ export function SettingsProvider({
],
);
const isTouch = useTouchDevice();
// Read persisted settings from localStorage.
useEffect(() => {
// Defer until we know whether or not we're on a touch device...
if (isTouch == null) return;
let savedSettings: PersistedSettings = {};
try {
savedSettings = JSON.parse(localStorage.getItem("settings")) || {};
@ -203,7 +220,10 @@ export function SettingsProvider({
}
if (savedSettings.speedMultiplier != null) {
setSpeedMultiplier(
Math.max(0, Math.min(1, savedSettings.speedMultiplier)),
Math.max(
MIN_SPEED_MULTIPLIER,
Math.min(MAX_SPEED_MULTIPLIER, savedSettings.speedMultiplier),
),
);
}
if (savedSettings.fov != null) {
@ -227,7 +247,13 @@ export function SettingsProvider({
if (savedSettings.invertJoystick != null) {
setInvertJoystick(savedSettings.invertJoystick);
}
}, []);
if (savedSettings.sidebarOpen != null) {
// Don't restore on touch devices!
if (!isTouch) {
setSidebarOpen(savedSettings.sidebarOpen);
}
}
}, [isTouch]);
// Persist settings to localStorage with debouncing to avoid excessive writes
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@ -254,6 +280,7 @@ export function SettingsProvider({
invertScroll,
invertDrag,
invertJoystick,
sidebarOpen,
};
try {
localStorage.setItem("settings", JSON.stringify(settingsToSave));
@ -281,6 +308,7 @@ export function SettingsProvider({
invertScroll,
invertDrag,
invertJoystick,
sidebarOpen,
]);
return (

View file

@ -418,36 +418,40 @@ export function StreamingController({
// When freeFlyCamera is active, skip stream camera positioning so
// ObserverControls drives the camera instead.
const freeFly = streamPlaybackStore.getState().freeFlyCamera;
// In live mode, LiveObserver owns camera rotation (client-side prediction).
// StreamingController still handles position, FOV, and entity interpolation.
// In live mode, InputConsumer owns camera position and rotation
// (moves are applied locally, matching how the real Tribes 2 client
// handles its control Camera). StreamingController still handles
// entity interpolation, FOV, and orbit target positioning.
const isLive = recording.source === "live";
if (currentCamera && !freeFly) {
if (previousCamera) {
const px = previousCamera.position[0];
const py = previousCamera.position[1];
const pz = previousCamera.position[2];
const cx = currentCamera.position[0];
const cy = currentCamera.position[1];
const cz = currentCamera.position[2];
const ix = px + (cx - px) * interpT;
const iy = py + (cy - py) * interpT;
const iz = pz + (cz - pz) * interpT;
state.camera.position.set(iy, iz, ix);
// In live mode, InputConsumer owns both camera position and rotation
// (client-side prediction with server reconciliation + interpolateTick,
// matching Tribes 2's Camera behavior). StreamingController only
// handles entity interpolation, FOV, and orbit target positioning.
if (!isLive) {
if (previousCamera) {
const px = previousCamera.position[0];
const py = previousCamera.position[1];
const pz = previousCamera.position[2];
const cx = currentCamera.position[0];
const cy = currentCamera.position[1];
const cz = currentCamera.position[2];
const ix = px + (cx - px) * interpT;
const iy = py + (cy - py) * interpT;
const iz = pz + (cz - pz) * interpT;
state.camera.position.set(iy, iz, ix);
if (!isLive) {
_interpQuatA.set(...previousCamera.rotation);
_interpQuatB.set(...currentCamera.rotation);
_interpQuatA.slerp(_interpQuatB, interpT);
state.camera.quaternion.copy(_interpQuatA);
}
} else {
state.camera.position.set(
currentCamera.position[1],
currentCamera.position[2],
currentCamera.position[0],
);
if (!isLive) {
} else {
state.camera.position.set(
currentCamera.position[1],
currentCamera.position[2],
currentCamera.position[0],
);
state.camera.quaternion.set(...currentCamera.rotation);
}
}
@ -550,8 +554,8 @@ export function StreamingController({
}
const mode = currentCamera?.mode;
// In live mode, LiveObserver handles orbit positioning from predicted
// angles so the orbit responds at frame rate. Skip here to avoid fighting.
// In live mode, InputConsumer handles orbit positioning from local rotation
// so the orbit responds at frame rate. Skip here to avoid fighting.
if (
!freeFly &&
!isLive &&

View file

@ -67,6 +67,10 @@
margin-top: -0.5px;
}
.Error {
color: rgb(255, 106, 69);
}
@media (max-width: 899px) {
.Metadata {
display: none;

View file

@ -68,12 +68,16 @@ export function StreamingMissionInfo() {
</div>
<div className={styles.Metadata}>
{isLive ? (
playerName ? (
<div className={styles.Attribution}>
Connected as{" "}
<span className={styles.PlayerName}>{playerName}</span>
</div>
) : null
isLiveConnected ? (
playerName ? (
<div className={styles.Attribution}>
Connected as{" "}
<span className={styles.PlayerName}>{playerName}</span>
</div>
) : null
) : (
<div className={styles.Error}>Disconnected</div>
)
) : playerName && dateString ? (
<div className={styles.Attribution}>
Recorded by <span className={styles.PlayerName}>{playerName}</span>{" "}

View file

@ -18,6 +18,8 @@ export type TickCallback = (tick: number) => void;
interface TickContextValue {
subscribe: (callback: TickCallback) => () => void;
getTick: () => number;
/** Fraction [0, 1) of the current tick elapsed since the last tick fired. */
getTickFraction: () => number;
}
const TickContext = createContext<TickContextValue | null>(null);
@ -56,7 +58,15 @@ export function TickProvider({ children }: TickProviderProps) {
const getTick = useCallback(() => tickRef.current, []);
const context = useMemo(() => ({ subscribe, getTick }), [subscribe, getTick]);
const getTickFraction = useCallback(
() => accumulatorRef.current / TICK_INTERVAL,
[],
);
const context = useMemo(
() => ({ subscribe, getTick, getTickFraction }),
[subscribe, getTick, getTickFraction],
);
return (
<TickContext.Provider value={context}>{children}</TickContext.Provider>
@ -83,3 +93,11 @@ export function useGetTick() {
}
return context.getTick;
}
export function useGetTickFraction() {
const context = useContext(TickContext);
if (!context) {
throw new Error("useGetTickFraction must be used within a TickProvider");
}
return context.getTickFraction;
}

View file

@ -1,16 +1,14 @@
import { useEffect, useEffectEvent, useRef } from "react";
import { Euler, Vector3 } from "three";
import { useFrame, useThree } from "@react-three/fiber";
import { useControls } from "./SettingsProvider";
import { useJoystick } from "./JoystickContext";
import { useOnInput } from "./InputContext";
const BASE_SPEED = 80;
const LOOK_SENSITIVITY = 0.004;
const STICK_LOOK_SENSITIVITY = 2.5;
const DUAL_MOVE_DEADZONE = 0.08;
const DUAL_LOOK_DEADZONE = 0.15;
const SINGLE_STICK_DEADZONE = 0.15;
const MAX_PITCH = Math.PI / 2 - 0.01; // ~89°
export type JoystickState = {
angle: number;
@ -21,25 +19,18 @@ export type JoystickState = {
export function TouchHandler() {
const { speedMultiplier, touchMode, invertDrag, invertJoystick } =
useControls();
const camera = useThree((state) => state.camera);
const gl = useThree((state) => state.gl);
const { moveState, lookState } = useJoystick();
const onInput = useOnInput();
// Touch look state
const euler = useRef(new Euler(0, 0, 0, "YXZ"));
const lookTouchId = useRef<number | null>(null);
const lastTouchPos = useRef({ x: 0, y: 0 });
const getInvertDrag = useEffectEvent(() => invertDrag);
// Scratch vectors
const forwardVec = useRef(new Vector3());
const sideVec = useRef(new Vector3());
const moveVec = useRef(new Vector3());
// Initialize euler from current camera rotation on mount
useEffect(() => {
euler.current.setFromQuaternion(camera.quaternion, "YXZ");
}, [camera]);
// Accumulated touch-drag deltas between frames.
const touchDeltaYaw = useRef(0);
const touchDeltaPitch = useRef(0);
// Touch-drag look handling (moveLookStick mode)
useEffect(() => {
@ -47,27 +38,13 @@ export function TouchHandler() {
const canvas = gl.domElement;
// const isTouchOnJoystick = (touch: Touch) => {
// const zone = joystickZone.current;
// if (!zone) return false;
// const rect = zone.getBoundingClientRect();
// return (
// touch.clientX >= rect.left &&
// touch.clientX <= rect.right &&
// touch.clientY >= rect.top &&
// touch.clientY <= rect.bottom
// );
// };
const handleTouchStart = (e: TouchEvent) => {
if (lookTouchId.current !== null) return;
for (let i = 0; i < e.changedTouches.length; i++) {
const touch = e.changedTouches[i];
// if (!isTouchOnJoystick(touch)) {
lookTouchId.current = touch.identifier;
lastTouchPos.current = { x: touch.clientX, y: touch.clientY };
break;
// }
}
};
@ -81,14 +58,8 @@ export function TouchHandler() {
lastTouchPos.current = { x: touch.clientX, y: touch.clientY };
const dragSign = getInvertDrag() ? -1 : 1;
euler.current.setFromQuaternion(camera.quaternion, "YXZ");
euler.current.y += dragSign * dx * LOOK_SENSITIVITY;
euler.current.x += dragSign * dy * LOOK_SENSITIVITY;
euler.current.x = Math.max(
-MAX_PITCH,
Math.min(MAX_PITCH, euler.current.x),
);
camera.quaternion.setFromEuler(euler.current);
touchDeltaYaw.current += dragSign * dx * LOOK_SENSITIVITY;
touchDeltaPitch.current += dragSign * dy * LOOK_SENSITIVITY;
break;
}
}
@ -115,14 +86,23 @@ export function TouchHandler() {
canvas.removeEventListener("touchcancel", handleTouchEnd);
lookTouchId.current = null;
};
}, [camera, gl.domElement, touchMode]);
}, [gl.domElement, touchMode]);
useFrame((_state, delta) => {
const { force: moveForce, angle: moveAngle } = moveState.current;
const { force: lookForce, angle: lookAngle } = lookState.current;
let deltaYaw = touchDeltaYaw.current;
let deltaPitch = touchDeltaPitch.current;
touchDeltaYaw.current = 0;
touchDeltaPitch.current = 0;
let x = 0;
let y = 0;
const z = 0;
if (touchMode === "dualStick") {
// Right stick → camera rotation
// Right stick -> camera rotation
if (lookForce > DUAL_LOOK_DEADZONE) {
const normalizedLookForce =
(lookForce - DUAL_LOOK_DEADZONE) / (1 - DUAL_LOOK_DEADZONE);
@ -130,56 +110,41 @@ export function TouchHandler() {
const lookY = Math.sin(lookAngle);
const joySign = invertJoystick ? -1 : 1;
euler.current.setFromQuaternion(camera.quaternion, "YXZ");
euler.current.y -=
deltaYaw -=
joySign *
lookX *
normalizedLookForce *
STICK_LOOK_SENSITIVITY *
delta;
euler.current.x +=
deltaPitch +=
joySign *
lookY *
normalizedLookForce *
STICK_LOOK_SENSITIVITY *
delta;
euler.current.x = Math.max(
-MAX_PITCH,
Math.min(MAX_PITCH, euler.current.x),
);
camera.quaternion.setFromEuler(euler.current);
}
// Left stick movement
// Left stick -> movement
if (moveForce > DUAL_MOVE_DEADZONE) {
const normalizedMoveForce =
(moveForce - DUAL_MOVE_DEADZONE) / (1 - DUAL_MOVE_DEADZONE);
const speed = BASE_SPEED * speedMultiplier * normalizedMoveForce;
const joyX = Math.cos(moveAngle);
const joyY = Math.sin(moveAngle);
camera.getWorldDirection(forwardVec.current);
forwardVec.current.normalize();
sideVec.current.crossVectors(camera.up, forwardVec.current).normalize();
moveVec.current
.set(0, 0, 0)
.addScaledVector(forwardVec.current, joyY)
.addScaledVector(sideVec.current, -joyX);
if (moveVec.current.lengthSq() > 0) {
moveVec.current.normalize().multiplyScalar(speed * delta);
camera.position.add(moveVec.current);
}
// Map joystick to movement axes, pre-scaled by speedMultiplier.
x = Math.max(
-1,
Math.min(1, -joyX * normalizedMoveForce * speedMultiplier),
);
y = Math.max(
-1,
Math.min(1, joyY * normalizedMoveForce * speedMultiplier),
);
}
} else if (touchMode === "moveLookStick") {
if (moveForce > 0) {
// Move forward at half the configured speed.
const speed = BASE_SPEED * speedMultiplier * 0.5;
camera.getWorldDirection(forwardVec.current);
forwardVec.current.normalize();
moveVec.current.copy(forwardVec.current).multiplyScalar(speed * delta);
camera.position.add(moveVec.current);
// Move forward at half speed.
y = Math.max(-1, Math.min(1, 0.5 * speedMultiplier));
if (moveForce >= SINGLE_STICK_DEADZONE) {
// Outer zone: also control camera look (yaw + pitch).
@ -189,29 +154,38 @@ export function TouchHandler() {
(moveForce - SINGLE_STICK_DEADZONE) / (1 - SINGLE_STICK_DEADZONE);
const singleJoySign = invertJoystick ? -1 : 1;
euler.current.setFromQuaternion(camera.quaternion, "YXZ");
euler.current.y -=
deltaYaw -=
singleJoySign *
lookX *
normalizedLookForce *
STICK_LOOK_SENSITIVITY *
0.5 *
delta;
euler.current.x +=
deltaPitch +=
singleJoySign *
lookY *
normalizedLookForce *
STICK_LOOK_SENSITIVITY *
0.5 *
delta;
euler.current.x = Math.max(
-MAX_PITCH,
Math.min(MAX_PITCH, euler.current.x),
);
camera.quaternion.setFromEuler(euler.current);
}
}
}
// Only emit if there's actual input.
const hasLook = deltaYaw !== 0 || deltaPitch !== 0;
const hasMove = x !== 0 || y !== 0 || z !== 0;
if (!hasLook && !hasMove) return;
onInput({
deltaYaw,
deltaPitch,
x,
y,
z,
triggers: [],
delta,
});
});
return null;

View file

@ -45,7 +45,7 @@ export interface LiveConnectionStore extends LiveConnectionState {
listServers(): void;
joinServer(address: string, warriorName?: string): void;
disconnectServer(): void;
sendMove(move: ClientMove): void;
sendMoves(moves: ClientMove[], moveStartIndex: number): void;
sendCommand(command: string, ...args: string[]): void;
}
@ -254,8 +254,8 @@ export const liveConnectionStore = createStore<LiveConnectionStore>(
});
},
sendMove(move) {
get()._relay?.sendMove(move);
sendMoves(moves, moveStartIndex) {
get()._relay?.sendMoves(moves, moveStartIndex);
},
sendCommand(command, ...args) {

View file

@ -39,6 +39,9 @@ export class LiveStreamAdapter extends StreamEngine {
/** Current mission name as reported by the server. */
missionName: string | null = null;
/** Server's latest move acknowledgment (which moveIndex it has processed). */
lastMoveAck = 0;
constructor(relay: RelayClient) {
super();
this.relay = relay;
@ -306,57 +309,17 @@ export class LiveStreamAdapter extends StreamEngine {
*/
observerMode: "fly" | "follow" = "fly";
/** Enter follow mode (from fly) or cycle to next player (in follow). */
cycleObserveNext(): void {
if (this.observerMode === "fly") {
// Jump trigger enters observerFollow from observerFly
log.info("observer: fly → follow (jump trigger)");
this.sendTrigger(2);
this.observerMode = "follow";
} else {
// Fire trigger cycles to next player in observerFollow
log.info("observer: cycle next (fire trigger)");
this.sendTrigger(0);
}
}
/** Toggle between follow and free-fly observer modes. */
/** Toggle between follow and free-fly observer modes (local state only). */
toggleObserverMode(): void {
if (this.observerMode === "fly") {
// Jump trigger enters observerFollow from observerFly
log.info("observer: fly → follow (jump trigger)");
this.sendTrigger(2);
log.info("observer: fly → follow");
this.observerMode = "follow";
} else {
// Jump trigger returns to observerFly from observerFollow
log.info("observer: follow → fly (jump trigger)");
this.sendTrigger(2);
log.info("observer: follow → fly");
this.observerMode = "fly";
}
}
private sendTrigger(index: number): void {
const trigger: [boolean, boolean, boolean, boolean, boolean, boolean] = [
false,
false,
false,
false,
false,
false,
];
trigger[index] = true;
this.relay.sendMove({
x: 0,
y: 0,
z: 0,
yaw: 0,
pitch: 0,
roll: 0,
trigger,
freeLook: false,
});
}
/** Get the player list (for observer cycling UI). */
getPlayerList(): PlayerListEntry[] {
const entries: PlayerListEntry[] = [];
@ -409,6 +372,9 @@ export class LiveStreamAdapter extends StreamEngine {
);
}
// Track move acknowledgments for client-side prediction replay.
this.lastMoveAck = parsed.gameState.lastMoveAck;
// Control object state
this.processControlObject(parsed.gameState);

View file

@ -175,9 +175,9 @@ export class RelayClient {
this.send({ type: "sendGhostAck", sequence, ghostCount });
}
/** Send a move struct to the relay for forwarding to the game server. */
sendMove(move: ClientMove): void {
this.send({ type: "sendMove", move });
/** Send moves to the relay for immediate forwarding to the game server. */
sendMoves(moves: ClientMove[], moveStartIndex: number): void {
this.send({ type: "sendMoves", moves, moveStartIndex });
}
/** Close the WebSocket connection entirely. */