mirror of
https://github.com/exogen/t2-mapper.git
synced 2026-07-11 14:34:51 +00:00
cleanup input handling, fix server move packets
This commit is contained in:
parent
409df9fcaa
commit
e9125951e4
84 changed files with 2085 additions and 1808 deletions
|
|
@ -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;
|
||||
|
|
|
|||
610
src/components/InputConsumer.tsx
Normal file
610
src/components/InputConsumer.tsx
Normal 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);
|
||||
}
|
||||
}
|
||||
48
src/components/InputContext.tsx
Normal file
48
src/components/InputContext.tsx
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 = () => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -67,6 +67,10 @@
|
|||
margin-top: -0.5px;
|
||||
}
|
||||
|
||||
.Error {
|
||||
color: rgb(255, 106, 69);
|
||||
}
|
||||
|
||||
@media (max-width: 899px) {
|
||||
.Metadata {
|
||||
display: none;
|
||||
|
|
|
|||
|
|
@ -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>{" "}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue