mirror of
https://github.com/exogen/t2-mapper.git
synced 2026-04-29 08:16:22 +00:00
bug fixes, add player name support
This commit is contained in:
parent
e4ae265184
commit
d9b5e30831
75 changed files with 1139 additions and 544 deletions
|
|
@ -1,9 +0,0 @@
|
|||
import { useRecording } from "./RecordingProvider";
|
||||
import { DemoPlaybackController } from "./DemoPlaybackController";
|
||||
|
||||
export function DemoPlayback() {
|
||||
const recording = useRecording();
|
||||
|
||||
if (!recording) return null;
|
||||
return <DemoPlaybackController recording={recording} />;
|
||||
}
|
||||
|
|
@ -10,7 +10,7 @@ import { CopyCoordinatesButton } from "./CopyCoordinatesButton";
|
|||
import { LoadDemoButton } from "./LoadDemoButton";
|
||||
import { JoinServerButton } from "./JoinServerButton";
|
||||
import { useRecording } from "./RecordingProvider";
|
||||
import { useLiveConnectionOptional } from "./LiveConnection";
|
||||
import { useLiveSelector } from "../state/liveConnectionStore";
|
||||
import { FiInfo, FiSettings } from "react-icons/fi";
|
||||
import { Camera } from "three";
|
||||
import styles from "./InspectorControls.module.css";
|
||||
|
|
@ -51,8 +51,7 @@ export function InspectorControls({
|
|||
useControls();
|
||||
const { debugMode, setDebugMode } = useDebug();
|
||||
const demoRecording = useRecording();
|
||||
const live = useLiveConnectionOptional();
|
||||
const isLive = live?.adapter != null;
|
||||
const isLive = useLiveSelector((s) => s.adapter != null);
|
||||
const isStreaming = demoRecording != null || isLive;
|
||||
// Hide FOV/speed controls during .rec playback (faithfully replaying),
|
||||
// but show them in .mis browsing and live observer mode.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { BsFillLightningChargeFill } from "react-icons/bs";
|
||||
import { useLiveConnectionOptional } from "./LiveConnection";
|
||||
import { useLiveSelector, selectPing } from "../state/liveConnectionStore";
|
||||
import styles from "./JoinServerButton.module.css";
|
||||
|
||||
function formatPing(ms: number): string {
|
||||
|
|
@ -11,24 +11,26 @@ export function JoinServerButton({
|
|||
}: {
|
||||
onOpenServerBrowser: () => void;
|
||||
}) {
|
||||
const live = useLiveConnectionOptional();
|
||||
if (!live) return null;
|
||||
const gameStatus = useLiveSelector((s) => s.gameStatus);
|
||||
const serverName = useLiveSelector((s) => s.serverName);
|
||||
const ping = useLiveSelector(selectPing);
|
||||
const disconnectServer = useLiveSelector((s) => s.disconnectServer);
|
||||
|
||||
const isLive = live.gameStatus === "connected";
|
||||
const isLive = gameStatus === "connected";
|
||||
const isConnecting =
|
||||
live.gameStatus === "connecting" ||
|
||||
live.gameStatus === "challenging" ||
|
||||
live.gameStatus === "authenticating";
|
||||
gameStatus === "connecting" ||
|
||||
gameStatus === "challenging" ||
|
||||
gameStatus === "authenticating";
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.Root}
|
||||
aria-label={isLive ? "Disconnect" : "Join server"}
|
||||
title={isLive ? "Disconnect" : "Join server"}
|
||||
aria-label={isLive ? `Disconnect from ${serverName ?? "server"}` : "Join server"}
|
||||
title={isLive ? `Disconnect from ${serverName ?? "server"}` : "Join server"}
|
||||
onClick={() => {
|
||||
if (isLive) {
|
||||
live.disconnectServer();
|
||||
disconnectServer();
|
||||
} else {
|
||||
onOpenServerBrowser();
|
||||
}
|
||||
|
|
@ -43,9 +45,9 @@ export function JoinServerButton({
|
|||
{isConnecting ? "Connecting..." : "Connect"}
|
||||
</span>
|
||||
)}
|
||||
{isLive && (
|
||||
{isLive && ping != null && (
|
||||
<span className={styles.PingLabel}>
|
||||
{live.ping != null ? formatPing(live.ping) : "Live"}
|
||||
{formatPing(ping)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -1,266 +1,15 @@
|
|||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useCallback,
|
||||
useRef,
|
||||
useEffect,
|
||||
} from "react";
|
||||
import { RelayClient } from "../stream/relayClient";
|
||||
import { LiveStreamAdapter } from "../stream/liveStreaming";
|
||||
import type {
|
||||
ClientMove,
|
||||
ServerInfo,
|
||||
ConnectionStatus,
|
||||
} from "../../relay/types";
|
||||
|
||||
interface LiveConnectionState {
|
||||
relayConnected: boolean;
|
||||
gameStatus: ConnectionStatus | null;
|
||||
gameStatusMessage?: string;
|
||||
/** Map name from the server being joined (from GameInfoResponse or status). */
|
||||
mapName?: string;
|
||||
/** Effective RTT to the game server (relay↔T2 + browser↔relay). */
|
||||
ping: number | null;
|
||||
/** Browser↔relay WebSocket RTT in ms. */
|
||||
wsPing: number | null;
|
||||
servers: ServerInfo[];
|
||||
serversLoading: boolean;
|
||||
adapter: LiveStreamAdapter | null;
|
||||
/** True once the first ghost entity arrives (game is rendering). */
|
||||
liveReady: boolean;
|
||||
}
|
||||
|
||||
interface LiveConnectionActions {
|
||||
connectRelay: (url?: string) => void;
|
||||
disconnectRelay: () => void;
|
||||
listServers: () => void;
|
||||
joinServer: (address: string) => void;
|
||||
disconnectServer: () => void;
|
||||
sendMove: (move: ClientMove) => void;
|
||||
sendCommand: (command: string, ...args: string[]) => void;
|
||||
}
|
||||
|
||||
const LiveConnectionContext = createContext<
|
||||
(LiveConnectionState & LiveConnectionActions) | null
|
||||
>(null);
|
||||
|
||||
export function useLiveConnection() {
|
||||
const ctx = useContext(LiveConnectionContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useLiveConnection must be used within LiveConnectionProvider");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function useLiveConnectionOptional() {
|
||||
return useContext(LiveConnectionContext);
|
||||
}
|
||||
|
||||
const DEFAULT_RELAY_URL =
|
||||
process.env.NEXT_PUBLIC_RELAY_URL || "ws://localhost:8765";
|
||||
import { useEffect } from "react";
|
||||
import { disposeLiveConnection } from "../state/liveConnectionStore";
|
||||
|
||||
/** Cleanup-only provider — disposes the relay connection on unmount. */
|
||||
export function LiveConnectionProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const relayRef = useRef<RelayClient | null>(null);
|
||||
const adapterRef = useRef<LiveStreamAdapter | null>(null);
|
||||
// Queue of actions to run once the relay WebSocket opens.
|
||||
const pendingRef = useRef<Array<() => void>>([]);
|
||||
const listInFlightRef = useRef(false);
|
||||
|
||||
const [relayConnected, setRelayConnected] = useState(false);
|
||||
const [gameStatus, setGameStatus] = useState<ConnectionStatus | null>(null);
|
||||
const [gameStatusMessage, setGameStatusMessage] = useState<
|
||||
string | undefined
|
||||
>();
|
||||
const [mapName, setMapName] = useState<string | undefined>();
|
||||
const [servers, setServers] = useState<ServerInfo[]>([]);
|
||||
const [serversLoading, setServersLoading] = useState(false);
|
||||
const [adapter, setAdapter] = useState<LiveStreamAdapter | null>(null);
|
||||
const [liveReady, setLiveReady] = useState(false);
|
||||
const [relayPing, setRelayPing] = useState<number | null>(null);
|
||||
const [wsPing, setWsPing] = useState<number | null>(null);
|
||||
|
||||
const connectRelay = useCallback((url: string = DEFAULT_RELAY_URL) => {
|
||||
if (relayRef.current) {
|
||||
relayRef.current.close();
|
||||
relayRef.current = null;
|
||||
}
|
||||
|
||||
const relay = new RelayClient(url, {
|
||||
onOpen() {
|
||||
setRelayConnected(true);
|
||||
// Flush any queued actions (e.g. listServers called before open).
|
||||
for (const fn of pendingRef.current) fn();
|
||||
pendingRef.current = [];
|
||||
},
|
||||
onStatus(status, message, _connectSequence, statusMapName) {
|
||||
console.log(
|
||||
`[relay] game status: ${status}${message ? ` — ${message}` : ""}${statusMapName ? ` map=${statusMapName}` : ""}`,
|
||||
);
|
||||
setGameStatus(status);
|
||||
setGameStatusMessage(message);
|
||||
if (statusMapName) {
|
||||
setMapName(statusMapName);
|
||||
}
|
||||
},
|
||||
onServerList(list) {
|
||||
setServers(list);
|
||||
setServersLoading(false);
|
||||
listInFlightRef.current = false;
|
||||
},
|
||||
onGamePacket(data) {
|
||||
if (!adapterRef.current) {
|
||||
console.warn("[relay] received game packet but no adapter is active");
|
||||
}
|
||||
adapterRef.current?.feedPacket(data);
|
||||
},
|
||||
onPing(ms) {
|
||||
setRelayPing(ms);
|
||||
},
|
||||
onWsPing(ms) {
|
||||
setWsPing(ms);
|
||||
},
|
||||
onError(message) {
|
||||
console.error("Relay error:", message);
|
||||
setServersLoading(false);
|
||||
listInFlightRef.current = false;
|
||||
},
|
||||
onClose() {
|
||||
// Only update state if this is still the active relay.
|
||||
if (relayRef.current === relay) {
|
||||
relayRef.current = null;
|
||||
setRelayConnected(false);
|
||||
setGameStatus(null);
|
||||
setMapName(undefined);
|
||||
setRelayPing(null);
|
||||
setWsPing(null);
|
||||
setAdapter(null);
|
||||
setLiveReady(false);
|
||||
adapterRef.current = null;
|
||||
pendingRef.current = [];
|
||||
listInFlightRef.current = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
relay.connect();
|
||||
relayRef.current = relay;
|
||||
}, []);
|
||||
|
||||
const disconnectRelay = useCallback(() => {
|
||||
relayRef.current?.close();
|
||||
relayRef.current = null;
|
||||
adapterRef.current = null;
|
||||
pendingRef.current = [];
|
||||
setRelayConnected(false);
|
||||
setGameStatus(null);
|
||||
setMapName(undefined);
|
||||
setAdapter(null);
|
||||
setLiveReady(false);
|
||||
}, []);
|
||||
|
||||
const listServers = useCallback(() => {
|
||||
if (listInFlightRef.current) return;
|
||||
listInFlightRef.current = true;
|
||||
|
||||
const doList = () => {
|
||||
relayRef.current?.sendWsPing();
|
||||
relayRef.current?.listServers();
|
||||
};
|
||||
|
||||
setServersLoading(true);
|
||||
|
||||
if (relayRef.current?.connected) {
|
||||
doList();
|
||||
} else {
|
||||
// Connect first, then list once the socket opens.
|
||||
pendingRef.current.push(doList);
|
||||
if (!relayRef.current) {
|
||||
connectRelay();
|
||||
}
|
||||
}
|
||||
}, [connectRelay]);
|
||||
|
||||
const joinServer = useCallback((address: string) => {
|
||||
if (!relayRef.current) return;
|
||||
|
||||
// Set mapName from the cached server list immediately so the browser
|
||||
// can start loading the mission before the relay even connects to the
|
||||
// game server.
|
||||
const cachedServer = servers.find((s) => s.address === address);
|
||||
if (cachedServer?.mapName) {
|
||||
setMapName(cachedServer.mapName);
|
||||
}
|
||||
|
||||
const newAdapter = new LiveStreamAdapter(relayRef.current);
|
||||
newAdapter.onReady = () => setLiveReady(true);
|
||||
adapterRef.current = newAdapter;
|
||||
setLiveReady(false);
|
||||
setGameStatus(null);
|
||||
setAdapter(newAdapter);
|
||||
|
||||
relayRef.current.joinServer(address);
|
||||
}, [servers]);
|
||||
|
||||
const disconnectServer = useCallback(() => {
|
||||
relayRef.current?.disconnectServer();
|
||||
adapterRef.current?.reset();
|
||||
adapterRef.current = null;
|
||||
setAdapter(null);
|
||||
setLiveReady(false);
|
||||
setGameStatus(null);
|
||||
setMapName(undefined);
|
||||
setRelayPing(null);
|
||||
}, []);
|
||||
|
||||
const sendMove = useCallback((move: ClientMove) => {
|
||||
relayRef.current?.sendMove(move);
|
||||
}, []);
|
||||
|
||||
const sendCommand = useCallback((command: string, ...args: string[]) => {
|
||||
relayRef.current?.sendCommand(command, args);
|
||||
}, []);
|
||||
|
||||
// Clean up on unmount.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
relayRef.current?.close();
|
||||
};
|
||||
return () => disposeLiveConnection();
|
||||
}, []);
|
||||
|
||||
// Effective RTT = relay↔T2 RTT + browser↔relay RTT.
|
||||
const ping =
|
||||
relayPing != null && wsPing != null
|
||||
? relayPing + wsPing
|
||||
: relayPing ?? null;
|
||||
|
||||
const value: LiveConnectionState & LiveConnectionActions = {
|
||||
relayConnected,
|
||||
gameStatus,
|
||||
gameStatusMessage,
|
||||
mapName,
|
||||
ping,
|
||||
wsPing,
|
||||
servers,
|
||||
serversLoading,
|
||||
adapter,
|
||||
liveReady,
|
||||
connectRelay,
|
||||
disconnectRelay,
|
||||
listServers,
|
||||
joinServer,
|
||||
disconnectServer,
|
||||
sendMove,
|
||||
sendCommand,
|
||||
};
|
||||
|
||||
return (
|
||||
<LiveConnectionContext.Provider value={value}>
|
||||
{children}
|
||||
</LiveConnectionContext.Provider>
|
||||
);
|
||||
return children;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { useRef, useEffect } from "react";
|
|||
import { useFrame, useThree } from "@react-three/fiber";
|
||||
import { Vector3 } from "three";
|
||||
import { useKeyboardControls } from "@react-three/drei";
|
||||
import { useLiveConnection } from "./LiveConnection";
|
||||
import { useLiveSelector } from "../state/liveConnectionStore";
|
||||
import { useEngineStoreApi } from "../state/engineStore";
|
||||
import { streamPlaybackStore } from "../state/streamPlaybackStore";
|
||||
import { Controls, MOUSE_SENSITIVITY, ARROW_LOOK_SPEED } from "./ObserverControls";
|
||||
|
|
@ -44,7 +44,9 @@ interface PredictionState {
|
|||
* Tribes 2 client works (predict locally, correct from server).
|
||||
*/
|
||||
export function LiveObserver() {
|
||||
const { adapter, gameStatus, sendMove } = useLiveConnection();
|
||||
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);
|
||||
|
|
@ -131,11 +133,13 @@ export function LiveObserver() {
|
|||
};
|
||||
}, [gl.domElement]);
|
||||
|
||||
// Left-click when pointer-locked: enter follow mode (from fly) or cycle
|
||||
// to next player (in follow). Capture phase intercepts before ObserverControls.
|
||||
// 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();
|
||||
};
|
||||
|
|
@ -209,8 +213,11 @@ export function LiveObserver() {
|
|||
// Reset sub-tick accumulator for interpolation.
|
||||
tickAccRef.current = 0;
|
||||
|
||||
// Scale movement axes by speed multiplier. Values > 1 still clamp to
|
||||
// [-1, 1] server-side, but < 1 lets the user move slower.
|
||||
// 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,
|
||||
|
|
@ -219,13 +226,13 @@ export function LiveObserver() {
|
|||
yaw,
|
||||
pitch,
|
||||
roll: 0,
|
||||
trigger: [false, false, false, false, false, false],
|
||||
trigger: [false, true, false, false, false, false],
|
||||
freeLook: false,
|
||||
});
|
||||
});
|
||||
|
||||
// Override camera rotation with predicted values at frame rate.
|
||||
// Priority 1 ensures this runs AFTER DemoPlaybackController (priority 0),
|
||||
// Priority 1 ensures this runs AFTER StreamingController (priority 0),
|
||||
// which handles position from server snapshots.
|
||||
useFrame((state, delta) => {
|
||||
if (!activeAdapterRef.current || gameStatus !== "connected") return;
|
||||
|
|
@ -298,7 +305,11 @@ export function LiveObserver() {
|
|||
const cx = Math.cos(interpPitch);
|
||||
const sz = Math.sin(interpYaw);
|
||||
const cz = Math.cos(interpYaw);
|
||||
_orbitDir.set(-cx, -sz * sx, -cz * sx);
|
||||
// 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();
|
||||
|
|
@ -309,10 +320,10 @@ export function LiveObserver() {
|
|||
}
|
||||
} else {
|
||||
// Observer fly or first-person: override rotation only (position comes
|
||||
// from DemoPlaybackController's server snapshot interpolation).
|
||||
// from StreamingController's server snapshot interpolation).
|
||||
state.camera.quaternion.set(qx, qy, qz, qw);
|
||||
}
|
||||
});
|
||||
}, 1);
|
||||
|
||||
// Clean up on unmount.
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -180,7 +180,7 @@ function CameraMovement() {
|
|||
|
||||
useFrame((state, delta) => {
|
||||
// When streaming is active and not in free-fly mode, the stream
|
||||
// (DemoPlaybackController) drives the camera — skip our movement.
|
||||
// (StreamingController) drives the camera — skip our movement.
|
||||
const spState = streamPlaybackStore.getState();
|
||||
if (spState.playback && !spState.freeFlyCamera) return;
|
||||
|
||||
|
|
|
|||
|
|
@ -89,35 +89,94 @@
|
|||
|
||||
/* ── Chat Window (top-left) ── */
|
||||
|
||||
.ChatWindow {
|
||||
.ChatContainer {
|
||||
position: absolute;
|
||||
top: 56px;
|
||||
left: 0;
|
||||
max-width: 420px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
pointer-events: auto;
|
||||
border: 1px solid rgba(44, 172, 181, 0.4);
|
||||
}
|
||||
|
||||
.ChatWindow {
|
||||
max-width: 450px;
|
||||
max-height: 12.5em;
|
||||
overflow-y: auto;
|
||||
background: rgba(0, 50, 60, 0.65);
|
||||
padding: 4px 8px;
|
||||
padding: 6px 8px;
|
||||
user-select: text;
|
||||
font-size: 12px;
|
||||
line-height: 1.3;
|
||||
line-height: 1.333333;
|
||||
/* Thin scrollbar that doesn't take much space. */
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(44, 172, 181, 0.4) transparent;
|
||||
}
|
||||
|
||||
.ChatMessage {
|
||||
padding: 1px 0;
|
||||
transition: opacity 0.3s ease-out;
|
||||
/* Default to \c0 (GuiChatHudProfile fontColor) for untagged messages. */
|
||||
color: rgb(44, 172, 181);
|
||||
}
|
||||
|
||||
.ChatInputForm {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.ChatInput {
|
||||
width: 100%;
|
||||
background: rgba(0, 50, 60, 0.8);
|
||||
border: 0;
|
||||
border-top: 1px solid rgba(78, 179, 167, 0.2);
|
||||
border-radius: 0;
|
||||
color: rgb(40, 231, 240);
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
margin: 0;
|
||||
padding: 6px 8px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.ChatInput::placeholder {
|
||||
color: rgba(44, 172, 181, 0.5);
|
||||
}
|
||||
|
||||
.ChatInput:focus {
|
||||
background: rgba(0, 50, 60, 0.9);
|
||||
}
|
||||
|
||||
/* T2 GuiChatHudProfile fontColors palette (\c0–\c9). */
|
||||
.ChatColor0 { color: rgb(44, 172, 181); }
|
||||
.ChatColor1 { color: rgb(4, 235, 105); }
|
||||
.ChatColor2 { color: rgb(219, 200, 128); }
|
||||
.ChatColor3 { color: rgb(77, 253, 95); }
|
||||
.ChatColor4 { color: rgb(40, 231, 240); }
|
||||
.ChatColor5 { color: rgb(200, 200, 50); }
|
||||
.ChatColor6 { color: rgb(200, 200, 200); }
|
||||
.ChatColor7 { color: rgb(220, 220, 20); }
|
||||
.ChatColor8 { color: rgb(150, 150, 250); }
|
||||
.ChatColor9 { color: rgb(60, 220, 150); }
|
||||
.ChatColor0 {
|
||||
color: rgb(44, 172, 181);
|
||||
}
|
||||
.ChatColor1 {
|
||||
color: rgb(4, 235, 105);
|
||||
}
|
||||
.ChatColor2 {
|
||||
color: rgb(219, 200, 128);
|
||||
}
|
||||
.ChatColor3 {
|
||||
color: rgb(77, 253, 95);
|
||||
}
|
||||
.ChatColor4 {
|
||||
color: rgb(40, 231, 240);
|
||||
}
|
||||
.ChatColor5 {
|
||||
color: rgb(200, 200, 50);
|
||||
}
|
||||
.ChatColor6 {
|
||||
color: rgb(200, 200, 200);
|
||||
}
|
||||
.ChatColor7 {
|
||||
color: rgb(220, 220, 20);
|
||||
}
|
||||
.ChatColor8 {
|
||||
color: rgb(150, 150, 250);
|
||||
}
|
||||
.ChatColor9 {
|
||||
color: rgb(60, 220, 150);
|
||||
}
|
||||
|
||||
/* ── Team Scores (bottom-left) ── */
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { useRef, useEffect, useState, useCallback } from "react";
|
||||
import { useRecording } from "./RecordingProvider";
|
||||
import { useEngineSelector } from "../state";
|
||||
import { textureToUrl } from "../loaders";
|
||||
import { liveConnectionStore } from "../state/liveConnectionStore";
|
||||
import type {
|
||||
ChatSegment,
|
||||
ChatMessage,
|
||||
|
|
@ -266,50 +268,72 @@ function chatColorClass(msg: ChatMessage): string {
|
|||
// byte color code, so the correct default for server messages is c0.
|
||||
return CHAT_COLOR_CLASSES[0];
|
||||
}
|
||||
function ChatWindow() {
|
||||
function ChatWindow({ isLive }: { isLive: boolean }) {
|
||||
const messages = useEngineSelector(
|
||||
(state) => state.playback.streamSnapshot?.chatMessages,
|
||||
);
|
||||
const timeSec = useEngineSelector(
|
||||
(state) => state.playback.streamSnapshot?.timeSec,
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const prevCountRef = useRef(0);
|
||||
const [chatText, setChatText] = useState("");
|
||||
|
||||
// Auto-scroll to bottom when new messages arrive.
|
||||
const msgCount = messages?.length ?? 0;
|
||||
useEffect(() => {
|
||||
if (msgCount > prevCountRef.current && scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
prevCountRef.current = msgCount;
|
||||
}, [msgCount]);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const text = chatText.trim();
|
||||
if (!text) return;
|
||||
liveConnectionStore.getState().sendCommand("messageSent", text);
|
||||
setChatText("");
|
||||
},
|
||||
[chatText],
|
||||
);
|
||||
if (!messages || !messages.length || timeSec == null) return null;
|
||||
const fadeStart = 6;
|
||||
const fadeDuration = 1.5;
|
||||
const cutoff = timeSec - (fadeStart + fadeDuration);
|
||||
const visible = messages.filter(
|
||||
(m: ChatMessage) => m.timeSec > cutoff && m.text.trim() !== "",
|
||||
);
|
||||
if (!visible.length) return null;
|
||||
|
||||
const hasMessages = !!messages?.length;
|
||||
|
||||
return (
|
||||
<div className={styles.ChatWindow}>
|
||||
{visible.map((msg: ChatMessage, i: number) => {
|
||||
const age = timeSec - msg.timeSec;
|
||||
const opacity =
|
||||
age <= fadeStart
|
||||
? 1
|
||||
: Math.max(0, 1 - (age - fadeStart) / fadeDuration);
|
||||
return (
|
||||
<div
|
||||
key={`${msg.timeSec}-${i}`}
|
||||
className={styles.ChatMessage}
|
||||
style={{ opacity }}
|
||||
>
|
||||
{msg.segments ? (
|
||||
msg.segments.map((seg: ChatSegment, j: number) => (
|
||||
<span key={j} className={segmentColorClass(seg.colorCode)}>
|
||||
{seg.text}
|
||||
<div className={styles.ChatContainer}>
|
||||
{hasMessages && (
|
||||
<div ref={scrollRef} className={styles.ChatWindow}>
|
||||
{messages!.map((msg: ChatMessage, i: number) => (
|
||||
<div key={msg.id} className={styles.ChatMessage}>
|
||||
{msg.segments ? (
|
||||
msg.segments.map((seg: ChatSegment, j: number) => (
|
||||
<span key={j} className={segmentColorClass(seg.colorCode)}>
|
||||
{seg.text}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className={chatColorClass(msg)}>
|
||||
{msg.sender ? `${msg.sender}: ` : ""}
|
||||
{msg.text}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className={chatColorClass(msg)}>
|
||||
{msg.sender ? `${msg.sender}: ` : ""}
|
||||
{msg.text}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{isLive && (
|
||||
<form className={styles.ChatInputForm} onSubmit={handleSubmit}>
|
||||
<input
|
||||
className={styles.ChatInput}
|
||||
type="text"
|
||||
placeholder="Say something…"
|
||||
value={chatText}
|
||||
onChange={(e) => setChatText(e.target.value)}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
onKeyUp={(e) => e.stopPropagation()}
|
||||
maxLength={255}
|
||||
/>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -434,28 +458,31 @@ function PackAndInventoryHUD() {
|
|||
);
|
||||
}
|
||||
// ── Main HUD ──
|
||||
export function PlayerHUD() {
|
||||
export function PlayerHUD({ isLive = false }: { isLive?: boolean } = {}) {
|
||||
const recording = useRecording();
|
||||
const streamSnapshot = useEngineSelector(
|
||||
(state) => state.playback.streamSnapshot,
|
||||
);
|
||||
if (!recording) return null;
|
||||
if (!recording && !isLive) return null;
|
||||
const status = streamSnapshot?.status;
|
||||
if (!status) return null;
|
||||
return (
|
||||
<div className={styles.PlayerHUD}>
|
||||
<ChatWindow />
|
||||
<div className={styles.TopRight}>
|
||||
<div className={styles.Bars}>
|
||||
<HealthBar value={status.health} />
|
||||
<EnergyBar value={status.energy} />
|
||||
</div>
|
||||
<Compass yaw={streamSnapshot?.camera?.yaw} />
|
||||
</div>
|
||||
<WeaponHUD />
|
||||
<PackAndInventoryHUD />
|
||||
<TeamScores />
|
||||
<Reticle />
|
||||
<ChatWindow isLive={isLive} />
|
||||
{status && (
|
||||
<>
|
||||
<div className={styles.TopRight}>
|
||||
<div className={styles.Bars}>
|
||||
<HealthBar value={status.health} />
|
||||
<EnergyBar value={status.energy} />
|
||||
</div>
|
||||
<Compass yaw={streamSnapshot?.camera?.yaw} />
|
||||
</div>
|
||||
<WeaponHUD />
|
||||
<PackAndInventoryHUD />
|
||||
<TeamScores />
|
||||
<Reticle />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,11 +56,11 @@ function getArmThread(weaponShape: string | undefined): string {
|
|||
return "lookde";
|
||||
}
|
||||
|
||||
/** Number of table actions in the engine's ActionAnimationList. */
|
||||
const NUM_TABLE_ACTION_ANIMS = 7;
|
||||
/** Number of table actions in the engine's ActionAnimationList (Tribes2.exe build 25034). */
|
||||
const NUM_TABLE_ACTION_ANIMS = 8;
|
||||
|
||||
/** Table action names in engine order (indices 0-6). */
|
||||
const TABLE_ACTION_NAMES = ["root", "run", "back", "side", "fall", "jump", "land"];
|
||||
/** Table action names in engine order (indices 0-7). */
|
||||
const TABLE_ACTION_NAMES = ["root", "run", "back", "side", "fall", "jet", "jump", "land"];
|
||||
|
||||
|
||||
interface ActionAnimEntry {
|
||||
|
|
@ -75,8 +75,8 @@ interface ActionAnimEntry {
|
|||
* TSShapeConstructor's sequence entries (e.g. `"heavy_male_root.dsq root"`).
|
||||
*
|
||||
* The engine builds its action list as:
|
||||
* 1. Table actions (0-6): found by searching for aliased names (root, run, etc.)
|
||||
* 2. Non-table actions (7+): remaining sequences in TSShapeConstructor order.
|
||||
* 1. Table actions (0-7): found by searching for aliased names (root, run, etc.)
|
||||
* 2. Non-table actions (8+): remaining sequences in TSShapeConstructor order.
|
||||
*/
|
||||
function buildActionAnimMap(
|
||||
sequences: string[],
|
||||
|
|
@ -157,8 +157,8 @@ export function PlayerModel({ entity }: { entity: PlayerEntity }) {
|
|||
: undefined;
|
||||
});
|
||||
|
||||
// Clone scene preserving skeleton bindings, create mixer, find Mount0 bone.
|
||||
const { clonedScene, mixer, mount0, iflInitializers } = useMemo(() => {
|
||||
// Clone scene preserving skeleton bindings, create mixer, find mount bones.
|
||||
const { clonedScene, mixer, mount0, mount1, iflInitializers } = useMemo(() => {
|
||||
const scene = SkeletonUtils.clone(gltf.scene) as Group;
|
||||
const iflInits = processShapeScene(scene);
|
||||
|
||||
|
|
@ -174,11 +174,13 @@ export function PlayerModel({ entity }: { entity: PlayerEntity }) {
|
|||
const mix = new AnimationMixer(scene);
|
||||
|
||||
let m0: Object3D | null = null;
|
||||
let m1: Object3D | null = null;
|
||||
scene.traverse((n) => {
|
||||
if (!m0 && n.name === "Mount0") m0 = n;
|
||||
if (!m1 && n.name === "Mount1") m1 = n;
|
||||
});
|
||||
|
||||
return { clonedScene: scene, mixer: mix, mount0: m0, iflInitializers: iflInits };
|
||||
return { clonedScene: scene, mixer: mix, mount0: m0, mount1: m1, iflInitializers: iflInits };
|
||||
}, [gltf]);
|
||||
|
||||
// Build case-insensitive clip lookup with alias support.
|
||||
|
|
@ -314,6 +316,8 @@ export function PlayerModel({ entity }: { entity: PlayerEntity }) {
|
|||
const [currentWeaponShape, setCurrentWeaponShape] = useState(
|
||||
entity.weaponShape,
|
||||
);
|
||||
const packShapeRef = useRef(entity.packShape);
|
||||
const [currentPackShape, setCurrentPackShape] = useState(entity.packShape);
|
||||
|
||||
// Per-frame animation selection and mixer update.
|
||||
useFrame((_, delta) => {
|
||||
|
|
@ -321,6 +325,10 @@ export function PlayerModel({ entity }: { entity: PlayerEntity }) {
|
|||
weaponShapeRef.current = entity.weaponShape;
|
||||
setCurrentWeaponShape(entity.weaponShape);
|
||||
}
|
||||
if (entity.packShape !== packShapeRef.current) {
|
||||
packShapeRef.current = entity.packShape;
|
||||
setCurrentPackShape(entity.packShape);
|
||||
}
|
||||
const playback = engineStore.getState().playback;
|
||||
const isPlaying = playback.status === "playing";
|
||||
const time = streamPlaybackStore.getState().time;
|
||||
|
|
@ -430,6 +438,8 @@ export function PlayerModel({ entity }: { entity: PlayerEntity }) {
|
|||
const anim = pickMoveAnimation(
|
||||
kf?.velocity,
|
||||
kf?.rotation ?? [0, 0, 0, 1],
|
||||
entity.falling,
|
||||
entity.jetting,
|
||||
);
|
||||
|
||||
const prev = currentAnimRef.current;
|
||||
|
|
@ -518,6 +528,16 @@ export function PlayerModel({ entity }: { entity: PlayerEntity }) {
|
|||
</Suspense>
|
||||
</ShapeErrorBoundary>
|
||||
)}
|
||||
{currentPackShape && mount1 && (
|
||||
<ShapeErrorBoundary key={currentPackShape} fallback={null}>
|
||||
<Suspense fallback={null}>
|
||||
<MountedPackModel
|
||||
packShape={currentPackShape}
|
||||
mountBone={mount1}
|
||||
/>
|
||||
</Suspense>
|
||||
</ShapeErrorBoundary>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -896,6 +916,61 @@ function applyWeaponAnim(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attaches a pack shape to the player's Mount1 bone. Packs are static
|
||||
* mounted images (no state machine or animation) — just positioned via
|
||||
* the pack shape's Mountpoint node inverse offset, same as weapons.
|
||||
*/
|
||||
function MountedPackModel({
|
||||
packShape,
|
||||
mountBone,
|
||||
}: {
|
||||
packShape: string;
|
||||
mountBone: Object3D;
|
||||
}) {
|
||||
const packGltf = useStaticShape(packShape);
|
||||
|
||||
const { packClone, packIflInitializers } = useMemo(() => {
|
||||
const clone = SkeletonUtils.clone(packGltf.scene) as Group;
|
||||
const iflInits = processShapeScene(clone);
|
||||
|
||||
// Compute Mountpoint inverse offset so the pack aligns to Mount1.
|
||||
const mp = getPosedNodeTransform(
|
||||
packGltf.scene,
|
||||
packGltf.animations,
|
||||
"Mountpoint",
|
||||
);
|
||||
if (mp) {
|
||||
const invQuat = mp.quaternion.clone().invert();
|
||||
const invPos = mp.position.clone().negate().applyQuaternion(invQuat);
|
||||
clone.position.copy(invPos);
|
||||
clone.quaternion.copy(invQuat);
|
||||
}
|
||||
|
||||
return { packClone: clone, packIflInitializers: iflInits };
|
||||
}, [packGltf]);
|
||||
|
||||
useEffect(() => {
|
||||
mountBone.add(packClone);
|
||||
return () => {
|
||||
mountBone.remove(packClone);
|
||||
};
|
||||
}, [packClone, mountBone]);
|
||||
|
||||
// Initialize IFL materials (animated texture sequences).
|
||||
useEffect(() => {
|
||||
const cleanups: (() => void)[] = [];
|
||||
for (const { mesh, initialize } of packIflInitializers) {
|
||||
initialize(mesh, () => streamPlaybackStore.getState().time)
|
||||
.then((dispose) => cleanups.push(dispose))
|
||||
.catch(() => {});
|
||||
}
|
||||
return () => cleanups.forEach((fn) => fn());
|
||||
}, [packIflInitializers]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the eye offset from a player model's Eye bone in the idle ("Root"
|
||||
* animation) pose. The Eye node is a child of "Bip01 Head" in the skeleton
|
||||
|
|
|
|||
|
|
@ -36,16 +36,7 @@ export function PlayerNameplate({ entity }: { entity: PlayerEntity }) {
|
|||
const fillRef = useRef<HTMLDivElement>(null);
|
||||
const iffImgRef = useRef<HTMLImageElement>(null);
|
||||
const [isVisible, setIsVisible] = useState(true);
|
||||
|
||||
const displayName = useMemo(() => {
|
||||
if (entity.playerName) return entity.playerName;
|
||||
if (typeof entity.id === "string") {
|
||||
const m = entity.id.match(/\d+/);
|
||||
if (m) return `<Player #${m[0]}>`;
|
||||
return entity.id;
|
||||
}
|
||||
return "<Player>";
|
||||
}, [entity.id, entity.playerName]);
|
||||
const nameRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Derive IFF height from the shape's bounding box.
|
||||
const iffHeight = useMemo(() => {
|
||||
|
|
@ -106,6 +97,15 @@ export function PlayerNameplate({ entity }: { entity: PlayerEntity }) {
|
|||
nameContainerRef.current.style.opacity = opacityStr;
|
||||
}
|
||||
|
||||
// Update player name imperatively — entity.playerName is mutated in-place
|
||||
// by streaming playback without triggering re-renders.
|
||||
if (nameRef.current) {
|
||||
const name = entity.playerName ?? entity.id;
|
||||
if (nameRef.current.textContent !== name) {
|
||||
nameRef.current.textContent = name;
|
||||
}
|
||||
}
|
||||
|
||||
// Update IFF arrow image imperatively — entity.iffColor is mutated in-place
|
||||
// by streaming playback without triggering re-renders.
|
||||
if (iffImgRef.current && entity.iffColor) {
|
||||
|
|
@ -148,7 +148,9 @@ export function PlayerNameplate({ entity }: { entity: PlayerEntity }) {
|
|||
</Html>
|
||||
<Html position={[0, NAME_HEIGHT, 0]} center>
|
||||
<div ref={nameContainerRef} className={styles.Bottom}>
|
||||
<div className={styles.Name}>{displayName}</div>
|
||||
<div ref={nameRef} className={styles.Name}>
|
||||
{entity.playerName ?? entity.id}
|
||||
</div>
|
||||
{hasHealthData && (
|
||||
<div className={styles.HealthBar}>
|
||||
<div ref={fillRef} className={styles.HealthFill} />
|
||||
|
|
|
|||
|
|
@ -147,16 +147,49 @@
|
|||
|
||||
.JoinButton {
|
||||
composes: DialogButton from "./DialogButton.module.css";
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.CloseButton {
|
||||
composes: Secondary from "./DialogButton.module.css";
|
||||
}
|
||||
|
||||
.WarriorField {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.WarriorLabel {
|
||||
font-size: 12px;
|
||||
color: rgba(125, 255, 255, 0.6);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.WarriorInput {
|
||||
width: 130px;
|
||||
padding: 4px 6px;
|
||||
background: rgba(0, 50, 60, 0.8);
|
||||
border: 1px solid rgba(65, 131, 139, 0.5);
|
||||
border-radius: 2px;
|
||||
color: #b0d5c9;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.WarriorInput:focus {
|
||||
border-color: rgba(125, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.WarriorInput::placeholder {
|
||||
color: rgba(201, 220, 216, 0.3);
|
||||
}
|
||||
|
||||
.Hint {
|
||||
font-size: 12px;
|
||||
color: rgba(201, 220, 216, 0.3);
|
||||
margin-left: auto;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@media (max-width: 719px) {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ export function ServerBrowser({
|
|||
onRefresh,
|
||||
onJoin,
|
||||
wsPing,
|
||||
warriorName,
|
||||
onWarriorNameChange,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
|
|
@ -18,6 +20,8 @@ export function ServerBrowser({
|
|||
onJoin: (address: string) => void;
|
||||
/** Browser↔relay RTT to add to server pings for effective latency. */
|
||||
wsPing?: number | null;
|
||||
warriorName: string;
|
||||
onWarriorNameChange: (name: string) => void;
|
||||
}) {
|
||||
const [selectedAddress, setSelectedAddress] = useState<string | null>(null);
|
||||
const [sortKey, setSortKey] = useState<keyof ServerInfo>("ping");
|
||||
|
|
@ -176,6 +180,24 @@ export function ServerBrowser({
|
|||
</table>
|
||||
</div>
|
||||
<div className={styles.Footer}>
|
||||
<div className={styles.WarriorField}>
|
||||
<label className={styles.WarriorLabel} htmlFor="warriorName">
|
||||
Warrior
|
||||
</label>
|
||||
<input
|
||||
id="warriorName"
|
||||
className={styles.WarriorInput}
|
||||
type="text"
|
||||
value={warriorName}
|
||||
onChange={(e) => onWarriorNameChange(e.target.value)}
|
||||
placeholder="Name thyself…"
|
||||
maxLength={24}
|
||||
/>
|
||||
</div>
|
||||
<span className={styles.Hint}>Double-click a server to join</span>
|
||||
<button onClick={onClose} className={styles.CloseButton}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleJoin}
|
||||
disabled={!selectedAddress}
|
||||
|
|
@ -183,10 +205,6 @@ export function ServerBrowser({
|
|||
>
|
||||
Join
|
||||
</button>
|
||||
<button onClick={onClose} className={styles.CloseButton}>
|
||||
Cancel
|
||||
</button>
|
||||
<span className={styles.Hint}>Double-click a server to join</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ type SettingsContext = {
|
|||
setAudioEnabled: StateSetter<boolean>;
|
||||
animationEnabled: boolean;
|
||||
setAnimationEnabled: StateSetter<boolean>;
|
||||
warriorName: string;
|
||||
setWarriorName: StateSetter<string>;
|
||||
};
|
||||
|
||||
type DebugContext = {
|
||||
|
|
@ -52,6 +54,7 @@ type PersistedSettings = {
|
|||
animationEnabled?: boolean;
|
||||
debugMode?: boolean;
|
||||
touchMode?: TouchMode;
|
||||
warriorName?: string;
|
||||
};
|
||||
|
||||
export function useSettings() {
|
||||
|
|
@ -83,6 +86,7 @@ export function SettingsProvider({
|
|||
const [animationEnabled, setAnimationEnabled] = useState(true);
|
||||
const [debugMode, setDebugMode] = useState(false);
|
||||
const [touchMode, setTouchMode] = useState<TouchMode>("moveLookStick");
|
||||
const [warriorName, setWarriorName] = useState("MapGenius");
|
||||
|
||||
const setFogEnabledWithoutOverride: StateSetter<boolean> = useCallback(
|
||||
(value) => {
|
||||
|
|
@ -104,6 +108,8 @@ export function SettingsProvider({
|
|||
setAudioEnabled,
|
||||
animationEnabled,
|
||||
setAnimationEnabled,
|
||||
warriorName,
|
||||
setWarriorName,
|
||||
}),
|
||||
[
|
||||
fogEnabled,
|
||||
|
|
@ -113,6 +119,7 @@ export function SettingsProvider({
|
|||
fov,
|
||||
audioEnabled,
|
||||
animationEnabled,
|
||||
warriorName,
|
||||
],
|
||||
);
|
||||
|
||||
|
|
@ -158,6 +165,9 @@ export function SettingsProvider({
|
|||
if (savedSettings.touchMode != null) {
|
||||
setTouchMode(savedSettings.touchMode);
|
||||
}
|
||||
if (savedSettings.warriorName != null) {
|
||||
setWarriorName(savedSettings.warriorName);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Persist settings to localStorage with debouncing to avoid excessive writes
|
||||
|
|
@ -180,6 +190,7 @@ export function SettingsProvider({
|
|||
animationEnabled,
|
||||
debugMode,
|
||||
touchMode,
|
||||
warriorName,
|
||||
};
|
||||
try {
|
||||
localStorage.setItem("settings", JSON.stringify(settingsToSave));
|
||||
|
|
@ -202,6 +213,7 @@ export function SettingsProvider({
|
|||
animationEnabled,
|
||||
debugMode,
|
||||
touchMode,
|
||||
warriorName,
|
||||
]);
|
||||
|
||||
return (
|
||||
|
|
|
|||
9
src/components/StreamPlayback.tsx
Normal file
9
src/components/StreamPlayback.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { useRecording } from "./RecordingProvider";
|
||||
import { StreamingController } from "./StreamingController";
|
||||
|
||||
export function StreamPlayback() {
|
||||
const recording = useRecording();
|
||||
|
||||
if (!recording) return null;
|
||||
return <StreamingController recording={recording} />;
|
||||
}
|
||||
|
|
@ -48,6 +48,9 @@ function mutateRenderFields(
|
|||
const e = renderEntity as unknown as Record<string, unknown>;
|
||||
e.threads = stream.threads;
|
||||
e.weaponShape = stream.weaponShape;
|
||||
e.packShape = stream.packShape;
|
||||
e.falling = stream.falling;
|
||||
e.jetting = stream.jetting;
|
||||
e.weaponImageState = stream.weaponImageState;
|
||||
e.weaponImageStates = stream.weaponImageStates;
|
||||
e.playerName = stream.playerName;
|
||||
|
|
@ -96,7 +99,7 @@ const _orbitDir = new Vector3();
|
|||
const _orbitTarget = new Vector3();
|
||||
const _orbitCandidate = new Vector3();
|
||||
|
||||
export function DemoPlaybackController({ recording }: { recording: StreamRecording }) {
|
||||
export function StreamingController({ recording }: { recording: StreamRecording }) {
|
||||
const engineStore = useEngineStoreApi();
|
||||
const playbackClockRef = useRef(0);
|
||||
const prevTickSnapshotRef = useRef<StreamSnapshot | null>(null);
|
||||
|
|
@ -235,7 +238,13 @@ export function DemoPlaybackController({ recording }: { recording: StreamRecordi
|
|||
return;
|
||||
}
|
||||
|
||||
stream.reset();
|
||||
// Reset the stream cursor for demo playback (replay from the beginning).
|
||||
// For live streams, skip reset — the adapter is already receiving packets
|
||||
// and has accumulated protocol state (net strings, target info, sensor
|
||||
// group colors) that the server won't re-send.
|
||||
if (recording.source !== "live") {
|
||||
stream.reset();
|
||||
}
|
||||
// Preload weapon effect shapes (explosions) so they're cached before
|
||||
// the first projectile detonates -- otherwise the GLB fetch latency
|
||||
// causes the short-lived explosion entity to expire before it renders.
|
||||
|
|
@ -354,7 +363,7 @@ export function DemoPlaybackController({ recording }: { recording: StreamRecordi
|
|||
// ObserverControls drives the camera instead.
|
||||
const freeFly = streamPlaybackStore.getState().freeFlyCamera;
|
||||
// In live mode, LiveObserver owns camera rotation (client-side prediction).
|
||||
// DemoPlaybackController still handles position, FOV, and entity interpolation.
|
||||
// StreamingController still handles position, FOV, and entity interpolation.
|
||||
const isLive = recording.source === "live";
|
||||
|
||||
if (currentCamera && !freeFly) {
|
||||
|
|
@ -388,7 +397,6 @@ export function DemoPlaybackController({ recording }: { recording: StreamRecordi
|
|||
}
|
||||
|
||||
if (
|
||||
!isLive &&
|
||||
Number.isFinite(currentCamera.fov) &&
|
||||
"isPerspectiveCamera" in state.camera &&
|
||||
(state.camera as any).isPerspectiveCamera
|
||||
|
|
@ -476,6 +484,8 @@ export function DemoPlaybackController({ recording }: { recording: StreamRecordi
|
|||
}
|
||||
|
||||
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.
|
||||
if (!freeFly && !isLive && mode === "third-person" && root && currentCamera?.orbitTargetId) {
|
||||
const targetGroup = root.children.find(
|
||||
(child) => child.name === currentCamera.orbitTargetId,
|
||||
|
|
@ -490,7 +500,16 @@ export function DemoPlaybackController({ recording }: { recording: StreamRecordi
|
|||
}
|
||||
|
||||
let hasDirection = false;
|
||||
if (
|
||||
if (currentCamera.orbitDirection) {
|
||||
// Use explicit pullback direction (e.g. from full vehicle quaternion
|
||||
// including roll) when available.
|
||||
_orbitDir.set(
|
||||
currentCamera.orbitDirection[0],
|
||||
currentCamera.orbitDirection[1],
|
||||
currentCamera.orbitDirection[2],
|
||||
);
|
||||
hasDirection = _orbitDir.lengthSq() > 1e-8;
|
||||
} else if (
|
||||
typeof currentCamera.yaw === "number" &&
|
||||
typeof currentCamera.pitch === "number"
|
||||
) {
|
||||
|
|
@ -498,10 +517,10 @@ export function DemoPlaybackController({ recording }: { recording: StreamRecordi
|
|||
const cx = Math.cos(currentCamera.pitch);
|
||||
const sz = Math.sin(currentCamera.yaw);
|
||||
const cz = Math.cos(currentCamera.yaw);
|
||||
// Camera::validateEyePoint uses Camera::setPosition's column1 in
|
||||
// Torque space as the orbit pull-back direction. Converted to Three,
|
||||
// that target->camera vector is (-cx, -sz*sx, -cz*sx).
|
||||
_orbitDir.set(-cx, -sz * sx, -cz * sx);
|
||||
// Pull back behind the model. playerYawToQuaternion uses Ry(-yaw),
|
||||
// so model forward in Three.js is (cz, 0, sz) at pitch=0.
|
||||
// Behind = (-cz*cx, -sx, -sz*cx).
|
||||
_orbitDir.set(-cz * cx, -sx, -sz * cx);
|
||||
hasDirection = _orbitDir.lengthSq() > 1e-8;
|
||||
}
|
||||
if (!hasDirection) {
|
||||
|
|
@ -311,7 +311,7 @@ export const engineStore = createStore<EngineStoreState>()(
|
|||
// Components use effectNow() instead of performance.now() so that effect
|
||||
// timers (explosions, particles, shockwaves, animation threads) automatically
|
||||
// pause when the demo is paused and speed up / slow down with the playback
|
||||
// rate. The DemoPlaybackController component calls advanceEffectClock()
|
||||
// rate. The StreamingController component calls advanceEffectClock()
|
||||
// once per frame.
|
||||
|
||||
let _effectClockMs = 0;
|
||||
|
|
@ -327,7 +327,7 @@ export function effectNow(): number {
|
|||
|
||||
/**
|
||||
* Advance the effect clock. Called once per frame from
|
||||
* DemoPlaybackController before other useFrame callbacks run.
|
||||
* StreamingController before other useFrame callbacks run.
|
||||
*/
|
||||
export function advanceEffectClock(deltaSec: number, rate: number): void {
|
||||
_effectClockMs += deltaSec * rate * 1000;
|
||||
|
|
|
|||
|
|
@ -139,6 +139,9 @@ export interface PlayerEntity extends PositionedBase {
|
|||
shapeName?: string;
|
||||
dataBlock?: string;
|
||||
weaponShape?: string;
|
||||
packShape?: string;
|
||||
falling?: boolean;
|
||||
jetting?: boolean;
|
||||
playerName?: string;
|
||||
iffColor?: { r: number; g: number; b: number };
|
||||
threads?: ThreadState[];
|
||||
|
|
|
|||
249
src/state/liveConnectionStore.ts
Normal file
249
src/state/liveConnectionStore.ts
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
import { createStore } from "zustand/vanilla";
|
||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||
import { RelayClient } from "../stream/relayClient";
|
||||
import { LiveStreamAdapter } from "../stream/liveStreaming";
|
||||
import type {
|
||||
ClientMove,
|
||||
ServerInfo,
|
||||
ConnectionStatus,
|
||||
} from "../../relay/types";
|
||||
|
||||
export interface LiveConnectionState {
|
||||
relayConnected: boolean;
|
||||
gameStatus: ConnectionStatus | null;
|
||||
gameStatusMessage?: string;
|
||||
/** Map name from the server being joined (from GameInfoResponse or status). */
|
||||
mapName?: string;
|
||||
/** Display name of the joined server. */
|
||||
serverName?: string;
|
||||
/** Relay↔T2 server RTT in ms. */
|
||||
relayToGameServerPing: number | null;
|
||||
/** Browser↔relay WebSocket RTT in ms. */
|
||||
browserToRelayPing: number | null;
|
||||
servers: ServerInfo[];
|
||||
serversLoading: boolean;
|
||||
adapter: LiveStreamAdapter | null;
|
||||
/** True once the first ghost entity arrives (game is rendering). */
|
||||
liveReady: boolean;
|
||||
}
|
||||
|
||||
export interface LiveConnectionStore extends LiveConnectionState {
|
||||
// Non-reactive refs.
|
||||
_relay: RelayClient | null;
|
||||
_adapter: LiveStreamAdapter | null;
|
||||
_pending: Array<() => void>;
|
||||
_listInFlight: boolean;
|
||||
|
||||
connectRelay(url?: string): void;
|
||||
disconnectRelay(): void;
|
||||
listServers(): void;
|
||||
joinServer(address: string, warriorName?: string): void;
|
||||
disconnectServer(): void;
|
||||
sendMove(move: ClientMove): void;
|
||||
sendCommand(command: string, ...args: string[]): void;
|
||||
}
|
||||
|
||||
const DEFAULT_RELAY_URL =
|
||||
process.env.NEXT_PUBLIC_RELAY_URL || "ws://localhost:8765";
|
||||
|
||||
export const liveConnectionStore = createStore<LiveConnectionStore>(
|
||||
(set, get) => ({
|
||||
relayConnected: false,
|
||||
gameStatus: null,
|
||||
gameStatusMessage: undefined,
|
||||
mapName: undefined,
|
||||
serverName: undefined,
|
||||
relayToGameServerPing: null,
|
||||
browserToRelayPing: null,
|
||||
servers: [],
|
||||
serversLoading: false,
|
||||
adapter: null,
|
||||
liveReady: false,
|
||||
|
||||
_relay: null,
|
||||
_adapter: null,
|
||||
_pending: [],
|
||||
_listInFlight: false,
|
||||
|
||||
connectRelay(url = DEFAULT_RELAY_URL) {
|
||||
const s = get();
|
||||
if (s._relay) {
|
||||
s._relay.close();
|
||||
}
|
||||
|
||||
const relay = new RelayClient(url, {
|
||||
onOpen() {
|
||||
set({ relayConnected: true });
|
||||
const s = get();
|
||||
for (const fn of s._pending) fn();
|
||||
s._pending = [];
|
||||
},
|
||||
onStatus(status, message, _connectSequence, statusMapName) {
|
||||
console.log(
|
||||
`[relay] game status: ${status}${message ? ` — ${message}` : ""}${statusMapName ? ` map=${statusMapName}` : ""}`,
|
||||
);
|
||||
set({
|
||||
gameStatus: status,
|
||||
gameStatusMessage: message,
|
||||
...(statusMapName ? { mapName: statusMapName } : {}),
|
||||
});
|
||||
},
|
||||
onServerList(list) {
|
||||
get()._listInFlight = false;
|
||||
set({ servers: list, serversLoading: false });
|
||||
},
|
||||
onGamePacket(data) {
|
||||
const a = get()._adapter;
|
||||
if (!a) {
|
||||
console.warn(
|
||||
"[relay] received game packet but no adapter is active",
|
||||
);
|
||||
}
|
||||
a?.feedPacket(data);
|
||||
},
|
||||
onPing(ms) {
|
||||
set({ relayToGameServerPing: ms });
|
||||
},
|
||||
onWsPing(ms) {
|
||||
set({ browserToRelayPing: ms });
|
||||
},
|
||||
onError(message) {
|
||||
console.error("Relay error:", message);
|
||||
get()._listInFlight = false;
|
||||
set({ serversLoading: false });
|
||||
},
|
||||
onClose() {
|
||||
const s = get();
|
||||
if (s._relay === relay) {
|
||||
s._relay = null;
|
||||
s._adapter = null;
|
||||
s._pending = [];
|
||||
s._listInFlight = false;
|
||||
set({
|
||||
relayConnected: false,
|
||||
gameStatus: null,
|
||||
gameStatusMessage: undefined,
|
||||
mapName: undefined,
|
||||
serverName: undefined,
|
||||
relayToGameServerPing: null,
|
||||
browserToRelayPing: null,
|
||||
adapter: null,
|
||||
liveReady: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
relay.connect();
|
||||
get()._relay = relay;
|
||||
},
|
||||
|
||||
disconnectRelay() {
|
||||
const s = get();
|
||||
s._relay?.close();
|
||||
s._relay = null;
|
||||
s._adapter = null;
|
||||
s._pending = [];
|
||||
s._listInFlight = false;
|
||||
set({
|
||||
relayConnected: false,
|
||||
gameStatus: null,
|
||||
gameStatusMessage: undefined,
|
||||
mapName: undefined,
|
||||
serverName: undefined,
|
||||
relayToGameServerPing: null,
|
||||
browserToRelayPing: null,
|
||||
adapter: null,
|
||||
liveReady: false,
|
||||
});
|
||||
},
|
||||
|
||||
listServers() {
|
||||
const s = get();
|
||||
if (s._listInFlight) return;
|
||||
s._listInFlight = true;
|
||||
|
||||
const doList = () => {
|
||||
const s = get();
|
||||
s._relay?.sendWsPing();
|
||||
s._relay?.listServers();
|
||||
};
|
||||
|
||||
set({ serversLoading: true });
|
||||
|
||||
if (s._relay?.connected) {
|
||||
doList();
|
||||
} else {
|
||||
s._pending.push(doList);
|
||||
if (!s._relay) {
|
||||
get().connectRelay();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
joinServer(address, warriorName) {
|
||||
const s = get();
|
||||
if (!s._relay) return;
|
||||
|
||||
const cachedServer = s.servers.find((sv) => sv.address === address);
|
||||
const newAdapter = new LiveStreamAdapter(s._relay);
|
||||
newAdapter.onReady = () => set({ liveReady: true });
|
||||
s._adapter = newAdapter;
|
||||
|
||||
set({
|
||||
mapName: cachedServer?.mapName ?? s.mapName,
|
||||
serverName: cachedServer?.name,
|
||||
liveReady: false,
|
||||
gameStatus: null,
|
||||
adapter: newAdapter,
|
||||
});
|
||||
|
||||
s._relay.joinServer(address, warriorName);
|
||||
},
|
||||
|
||||
disconnectServer() {
|
||||
const s = get();
|
||||
s._relay?.disconnectServer();
|
||||
s._adapter?.reset();
|
||||
s._adapter = null;
|
||||
set({
|
||||
adapter: null,
|
||||
liveReady: false,
|
||||
gameStatus: null,
|
||||
mapName: undefined,
|
||||
serverName: undefined,
|
||||
relayToGameServerPing: null,
|
||||
});
|
||||
},
|
||||
|
||||
sendMove(move) {
|
||||
get()._relay?.sendMove(move);
|
||||
},
|
||||
|
||||
sendCommand(command, ...args) {
|
||||
get()._relay?.sendCommand(command, args);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
/** Select state from the live connection store with optional equality fn. */
|
||||
export function useLiveSelector<T>(
|
||||
selector: (state: LiveConnectionStore) => T,
|
||||
equality?: (a: T, b: T) => boolean,
|
||||
): T {
|
||||
return useStoreWithEqualityFn(liveConnectionStore, selector, equality);
|
||||
}
|
||||
|
||||
/** Effective RTT to the game server (relay↔T2 + browser↔relay). */
|
||||
export function selectPing(s: LiveConnectionStore): number | null {
|
||||
return s.relayToGameServerPing != null && s.browserToRelayPing != null
|
||||
? s.relayToGameServerPing + s.browserToRelayPing
|
||||
: s.relayToGameServerPing ?? null;
|
||||
}
|
||||
|
||||
/** Dispose the relay connection (for cleanup on unmount). */
|
||||
export function disposeLiveConnection(): void {
|
||||
const s = liveConnectionStore.getState();
|
||||
s._relay?.close();
|
||||
s._relay = null;
|
||||
}
|
||||
|
|
@ -18,6 +18,8 @@ import {
|
|||
yawPitchToQuaternion,
|
||||
playerYawToQuaternion,
|
||||
torqueQuatToThreeJS,
|
||||
torqueQuatHeading,
|
||||
torqueQuatPitch,
|
||||
isValidPosition,
|
||||
isVec3Like,
|
||||
isQuatLike,
|
||||
|
|
@ -94,6 +96,9 @@ export interface MutableEntity {
|
|||
weaponImageState?: WeaponImageState;
|
||||
weaponImageStates?: WeaponImageDataBlockState[];
|
||||
weaponImageStatesDbId?: number;
|
||||
packShape?: string;
|
||||
falling?: boolean;
|
||||
jetting?: boolean;
|
||||
headPitch?: number;
|
||||
headYaw?: number;
|
||||
targetRenderFlags?: number;
|
||||
|
|
@ -158,6 +163,7 @@ export abstract class StreamEngine implements StreamingPlayback {
|
|||
|
||||
// ── Chat & audio ──
|
||||
protected chatMessages: ChatMessage[] = [];
|
||||
protected chatMessageIdCounter = 0;
|
||||
protected audioEvents: PendingAudioEvent[] = [];
|
||||
|
||||
// ── Net strings ──
|
||||
|
|
@ -181,6 +187,17 @@ export abstract class StreamEngine implements StreamingPlayback {
|
|||
protected controlPlayerGhostId?: string;
|
||||
protected lastControlType: "camera" | "player" = "camera";
|
||||
protected isPiloting = false;
|
||||
protected lastPilotGhostIndex?: number;
|
||||
protected lastVehicleHeading = 0;
|
||||
protected lastVehiclePitch = 0;
|
||||
protected lastVehicleOrbitDir?: [number, number, number];
|
||||
/** Vehicle velocity in Torque space (estimated from linMomentum/mass). */
|
||||
protected lastVehicleVelocity?: [number, number, number];
|
||||
/** Time (sec) of last vehicle position update from controlObjectData. */
|
||||
protected lastVehiclePosTime = 0;
|
||||
/** Last known vehicle position in Torque space for extrapolation. */
|
||||
protected lastVehiclePos?: [number, number, number];
|
||||
protected firstPerson = true;
|
||||
protected lastCameraMode?: number;
|
||||
protected lastOrbitGhostIndex?: number;
|
||||
protected lastOrbitDistance?: number;
|
||||
|
|
@ -267,6 +284,7 @@ export abstract class StreamEngine implements StreamingPlayback {
|
|||
this.tickCount = 0;
|
||||
this.camera = null;
|
||||
this.chatMessages = [];
|
||||
this.chatMessageIdCounter = 0;
|
||||
this.audioEvents = [];
|
||||
this.netStrings.clear();
|
||||
this.targetNames.clear();
|
||||
|
|
@ -279,6 +297,14 @@ export abstract class StreamEngine implements StreamingPlayback {
|
|||
this.controlPlayerGhostId = undefined;
|
||||
this.lastControlType = "camera";
|
||||
this.isPiloting = false;
|
||||
this.lastPilotGhostIndex = undefined;
|
||||
this.lastVehicleHeading = 0;
|
||||
this.lastVehiclePitch = 0;
|
||||
this.lastVehicleOrbitDir = undefined;
|
||||
this.lastVehicleVelocity = undefined;
|
||||
this.lastVehiclePosTime = 0;
|
||||
this.lastVehiclePos = undefined;
|
||||
this.firstPerson = true;
|
||||
this.lastCameraMode = undefined;
|
||||
this.lastOrbitGhostIndex = undefined;
|
||||
this.lastOrbitDistance = undefined;
|
||||
|
|
@ -359,6 +385,17 @@ export abstract class StreamEngine implements StreamingPlayback {
|
|||
this.isPiloting = !!(
|
||||
controlData.pilot || controlData.controlObjectGhost != null
|
||||
);
|
||||
if (this.isPiloting && typeof controlData.controlObjectGhost === "number") {
|
||||
this.lastPilotGhostIndex = controlData.controlObjectGhost;
|
||||
} else if (!this.isPiloting) {
|
||||
this.lastPilotGhostIndex = undefined;
|
||||
this.lastVehicleHeading = 0;
|
||||
this.lastVehiclePitch = 0;
|
||||
this.lastVehicleOrbitDir = undefined;
|
||||
this.lastVehicleVelocity = undefined;
|
||||
this.lastVehiclePosTime = 0;
|
||||
this.lastVehiclePos = undefined;
|
||||
}
|
||||
} else {
|
||||
this.isPiloting = false;
|
||||
if (typeof controlData.cameraMode === "number") {
|
||||
|
|
@ -772,6 +809,9 @@ export abstract class StreamEngine implements StreamingPlayback {
|
|||
entity.sensorGroup = undefined;
|
||||
entity.playerName = undefined;
|
||||
entity.weaponShape = undefined;
|
||||
entity.packShape = undefined;
|
||||
entity.falling = undefined;
|
||||
entity.jetting = undefined;
|
||||
entity.weaponImageState = undefined;
|
||||
entity.weaponImageStates = undefined;
|
||||
entity.weaponImageStatesDbId = undefined;
|
||||
|
|
@ -903,6 +943,18 @@ export abstract class StreamEngine implements StreamingPlayback {
|
|||
entity.weaponImageStates = undefined;
|
||||
}
|
||||
|
||||
// Pack image (slot 2 = $BackpackSlot, mountPoint 1 = Mount1)
|
||||
const packImage = images.find((img) => img.index === 2);
|
||||
if (packImage?.dataBlockId && packImage.dataBlockId > 0) {
|
||||
const blockData = this.getDataBlockData(packImage.dataBlockId);
|
||||
const shape = resolveShapeName("ShapeBaseImageData", blockData);
|
||||
if (shape) {
|
||||
entity.packShape = shape;
|
||||
}
|
||||
} else if (packImage && !packImage.dataBlockId) {
|
||||
entity.packShape = undefined;
|
||||
}
|
||||
|
||||
// Flag tracking
|
||||
const flagImage = images.find((img) => img.index === 3);
|
||||
if (flagImage) {
|
||||
|
|
@ -1003,6 +1055,10 @@ export abstract class StreamEngine implements StreamingPlayback {
|
|||
}
|
||||
}
|
||||
|
||||
// Movement state flags (from Player MoveMask ghost data).
|
||||
if (typeof data.moveFlag0 === "boolean") entity.falling = data.moveFlag0;
|
||||
if (typeof data.moveFlag1 === "boolean") entity.jetting = data.moveFlag1;
|
||||
|
||||
// Item physics: when the server sends a position update with
|
||||
// atRest=false and a velocity, start client-side physics simulation.
|
||||
if (entity.type === "Item") {
|
||||
|
|
@ -1439,7 +1495,45 @@ export abstract class StreamEngine implements StreamingPlayback {
|
|||
this.removeExpiredExplosions();
|
||||
|
||||
if (control.position) {
|
||||
const { yaw, pitch } = this.getCameraYawPitch(data);
|
||||
let { yaw, pitch } = this.getCameraYawPitch(data);
|
||||
|
||||
// When piloting a vehicle (without freelook), mouse yaw goes to
|
||||
// vehicle steering (mRot.z) and the player's head rotation (mHead)
|
||||
// decays by 50% per tick — the camera is locked to the vehicle.
|
||||
// Use the vehicle's heading directly instead of move-accumulated yaw.
|
||||
// Verified against tribes2-engine Player::updateMove and Tribes2.exe.
|
||||
if (this.isPiloting) {
|
||||
if (data) {
|
||||
const nested = data.controlObjectData as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const ang = nested?.angPosition as
|
||||
| { x: number; y: number; z: number; w: number }
|
||||
| undefined;
|
||||
if (ang && typeof ang.w === "number") {
|
||||
this.lastVehicleHeading = torqueQuatHeading(ang);
|
||||
this.lastVehiclePitch = torqueQuatPitch(ang);
|
||||
// Compute pullback direction from full quaternion (preserves roll).
|
||||
// ShapeBase::getCameraTransform pulls back along the eye's -Y axis.
|
||||
// In Torque space, forward is +Y. Transform +Y by the quaternion,
|
||||
// convert to Three.js, then negate for pullback.
|
||||
const threeQ = torqueQuatToThreeJS(ang);
|
||||
if (threeQ) {
|
||||
// Rotate Three.js forward (+X, since model default is +X) by the
|
||||
// converted quaternion: v' = q * v * q^-1.
|
||||
// For unit vector (1,0,0), this simplifies to:
|
||||
const [qx, qy, qz, qw] = threeQ;
|
||||
const fx = 1 - 2 * (qy * qy + qz * qz);
|
||||
const fy = 2 * (qx * qy + qz * qw);
|
||||
const fz = 2 * (qx * qz - qy * qw);
|
||||
// Pullback = -forward
|
||||
this.lastVehicleOrbitDir = [-fx, -fy, -fz];
|
||||
}
|
||||
}
|
||||
}
|
||||
yaw = this.lastVehicleHeading;
|
||||
pitch = this.lastVehiclePitch;
|
||||
}
|
||||
|
||||
this.camera = {
|
||||
time: timeSec,
|
||||
|
|
@ -1476,32 +1570,126 @@ export abstract class StreamEngine implements StreamingPlayback {
|
|||
this.camera.mode = "observer";
|
||||
}
|
||||
} else {
|
||||
this.camera.mode = "first-person";
|
||||
// Player control object.
|
||||
if (control.ghostIndex >= 0) {
|
||||
this.controlPlayerGhostId =
|
||||
this.resolveEntityIdForGhostIndex(control.ghostIndex);
|
||||
}
|
||||
if (!this.firstPerson) {
|
||||
// Third-person: orbit the vehicle (if piloting) or the player.
|
||||
this.camera.mode = "third-person";
|
||||
if (this.isPiloting && this.lastPilotGhostIndex != null) {
|
||||
this.camera.orbitTargetId =
|
||||
this.resolveEntityIdForGhostIndex(this.lastPilotGhostIndex);
|
||||
this.camera.orbitDistance = 15;
|
||||
if (this.lastVehicleOrbitDir) {
|
||||
this.camera.orbitDirection = this.lastVehicleOrbitDir;
|
||||
}
|
||||
} else {
|
||||
this.camera.orbitTargetId = this.controlPlayerGhostId;
|
||||
// Player datablock cameraMaxDist is typically 3.
|
||||
this.camera.orbitDistance = 3;
|
||||
}
|
||||
} else {
|
||||
this.camera.mode = "first-person";
|
||||
}
|
||||
if (this.controlPlayerGhostId) {
|
||||
this.camera.controlEntityId = this.controlPlayerGhostId;
|
||||
}
|
||||
}
|
||||
|
||||
// Sync control player position
|
||||
if (
|
||||
controlType === "player" &&
|
||||
!this.isPiloting &&
|
||||
this.controlPlayerGhostId &&
|
||||
control.position
|
||||
) {
|
||||
const ghostEntity = this.entities.get(this.controlPlayerGhostId);
|
||||
if (ghostEntity) {
|
||||
ghostEntity.position = [
|
||||
control.position.x,
|
||||
control.position.y,
|
||||
control.position.z,
|
||||
];
|
||||
ghostEntity.rotation = playerYawToQuaternion(yaw);
|
||||
ghostEntity.headPitch = this.getControlPlayerHeadPitch(pitch);
|
||||
// Sync control object positions from controlObjectData.
|
||||
if (controlType === "player" && control.position) {
|
||||
if (this.isPiloting && this.lastPilotGhostIndex != null) {
|
||||
const vehicleId =
|
||||
this.resolveEntityIdForGhostIndex(this.lastPilotGhostIndex);
|
||||
const vehicleEntity = vehicleId
|
||||
? this.entities.get(vehicleId)
|
||||
: undefined;
|
||||
if (vehicleEntity) {
|
||||
const nested = data?.controlObjectData as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
if (nested) {
|
||||
// Fresh position from controlObjectData (linPosition →
|
||||
// compressionPoint → control.position).
|
||||
vehicleEntity.position = [
|
||||
control.position.x,
|
||||
control.position.y,
|
||||
control.position.z,
|
||||
];
|
||||
this.lastVehiclePos = vehicleEntity.position.slice() as [number, number, number];
|
||||
this.lastVehiclePosTime = timeSec;
|
||||
|
||||
// Extract velocity from linMomentum for interpolation between
|
||||
// the sparse position updates (~10 of ~62 packets contain data).
|
||||
const mom = nested.linMomentum as
|
||||
| { x: number; y: number; z: number }
|
||||
| undefined;
|
||||
if (mom && isValidPosition(mom)) {
|
||||
// linMomentum = mass * velocity; look up mass from datablock.
|
||||
const dbId = vehicleEntity.dataBlockId;
|
||||
const dbData = dbId != null ? this.getDataBlockData(dbId) : undefined;
|
||||
const mass = (dbData?.mass as number) ?? 200;
|
||||
const invMass = mass > 0 ? 1 / mass : 1 / 200;
|
||||
this.lastVehicleVelocity = [
|
||||
mom.x * invMass,
|
||||
mom.y * invMass,
|
||||
mom.z * invMass,
|
||||
];
|
||||
vehicleEntity.velocity = this.lastVehicleVelocity;
|
||||
}
|
||||
|
||||
// Sync vehicle rotation from nested angPosition quaternion.
|
||||
const ang = nested.angPosition as
|
||||
| { x: number; y: number; z: number; w: number }
|
||||
| undefined;
|
||||
if (ang && typeof ang.w === "number") {
|
||||
const converted = torqueQuatToThreeJS(ang);
|
||||
if (converted) vehicleEntity.rotation = converted;
|
||||
}
|
||||
} else if (
|
||||
this.lastVehiclePos &&
|
||||
this.lastVehicleVelocity &&
|
||||
this.lastVehiclePosTime > 0
|
||||
) {
|
||||
// No nested data this packet — extrapolate from last known
|
||||
// position + velocity to avoid stutter.
|
||||
const dt = timeSec - this.lastVehiclePosTime;
|
||||
if (dt > 0 && dt < 1) {
|
||||
const [vx, vy, vz] = this.lastVehicleVelocity;
|
||||
vehicleEntity.position = [
|
||||
this.lastVehiclePos[0] + vx * dt,
|
||||
this.lastVehiclePos[1] + vy * dt,
|
||||
this.lastVehiclePos[2] + vz * dt,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (this.controlPlayerGhostId) {
|
||||
const ghostEntity = this.entities.get(this.controlPlayerGhostId);
|
||||
if (ghostEntity) {
|
||||
ghostEntity.position = [
|
||||
control.position.x,
|
||||
control.position.y,
|
||||
control.position.z,
|
||||
];
|
||||
ghostEntity.rotation = playerYawToQuaternion(yaw);
|
||||
ghostEntity.headPitch = this.getControlPlayerHeadPitch(pitch);
|
||||
// Sync velocity from controlObjectData. Ghost updates skip the
|
||||
// control player (MoveMask is not read), so velocity and state
|
||||
// flags must come from here for movement animation selection.
|
||||
const vel = data?.velocity as
|
||||
| { x: number; y: number; z: number }
|
||||
| undefined;
|
||||
if (isVec3Like(vel)) {
|
||||
ghostEntity.velocity = [vel.x, vel.y, vel.z];
|
||||
// Approximate mFalling: engine sets it when no ground contact
|
||||
// and vz < sFallingThreshold (-10). controlObjectData lacks
|
||||
// the explicit flag, so use the velocity heuristic.
|
||||
ghostEntity.falling = vel.z < -10;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (this.camera) {
|
||||
|
|
@ -1575,8 +1763,8 @@ export abstract class StreamEngine implements StreamingPlayback {
|
|||
|
||||
// ── Chat + HUD ──
|
||||
|
||||
protected pushChatMessage(msg: ChatMessage): void {
|
||||
this.chatMessages.push(msg);
|
||||
protected pushChatMessage(msg: Omit<ChatMessage, "id">): void {
|
||||
this.chatMessages.push({ ...msg, id: ++this.chatMessageIdCounter });
|
||||
if (this.chatMessages.length > 200) {
|
||||
this.chatMessages.splice(0, this.chatMessages.length - 200);
|
||||
}
|
||||
|
|
@ -1771,6 +1959,9 @@ export abstract class StreamEngine implements StreamingPlayback {
|
|||
shapeHint: entity.shapeHint,
|
||||
dataBlock: entity.dataBlock,
|
||||
weaponShape: entity.weaponShape,
|
||||
packShape: entity.packShape,
|
||||
falling: entity.falling,
|
||||
jetting: entity.jetting,
|
||||
playerName: entity.playerName,
|
||||
targetRenderFlags: renderFlags,
|
||||
iffColor:
|
||||
|
|
@ -1857,9 +2048,7 @@ export abstract class StreamEngine implements StreamingPlayback {
|
|||
chatMessages: ChatMessage[];
|
||||
audioEvents: PendingAudioEvent[];
|
||||
} {
|
||||
const chatMessages = this.chatMessages.filter(
|
||||
(m) => m.timeSec > timeSec - 15,
|
||||
);
|
||||
const chatMessages = this.chatMessages.slice();
|
||||
const audioEvents = this.audioEvents.filter(
|
||||
(e) => e.timeSec > timeSec - 0.5 && e.timeSec <= timeSec,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -18,6 +18,9 @@ import {
|
|||
detectControlObjectType,
|
||||
parseColorSegments,
|
||||
backpackBitmapToIndex,
|
||||
torqueQuatHeading,
|
||||
torqueQuatPitch,
|
||||
torqueQuatToThreeJS,
|
||||
} from "./streamHelpers";
|
||||
import type { Vec3 } from "./streamHelpers";
|
||||
import type {
|
||||
|
|
@ -276,6 +279,7 @@ class StreamingPlayback extends StreamEngine {
|
|||
parsedData?: Record<string, unknown>;
|
||||
}>;
|
||||
demoValues: string[];
|
||||
firstPerson: boolean;
|
||||
};
|
||||
// Demo-specific: move delta tracking for V12-style camera rotation
|
||||
private moveTicks = 0;
|
||||
|
|
@ -326,6 +330,7 @@ class StreamingPlayback extends StreamEngine {
|
|||
taggedStrings: initial.taggedStrings,
|
||||
initialEvents: initial.initialEvents,
|
||||
demoValues: initial.demoValues,
|
||||
firstPerson: initial.firstPerson,
|
||||
};
|
||||
|
||||
this.reset();
|
||||
|
|
@ -368,7 +373,9 @@ class StreamingPlayback extends StreamEngine {
|
|||
protected getCameraYawPitch(
|
||||
_data: Record<string, unknown> | undefined,
|
||||
): { yaw: number; pitch: number } {
|
||||
const hasMoves = !this.isPiloting && this.lastControlType === "player";
|
||||
// Move-derived angles are valid when the control object is a Player
|
||||
// (including when piloting a vehicle — moves still drive the camera).
|
||||
const hasMoves = this.lastControlType === "player";
|
||||
const yaw = hasMoves ? this.absoluteYaw : this.lastAbsYaw;
|
||||
const pitch = hasMoves ? this.absolutePitch : this.lastAbsPitch;
|
||||
|
||||
|
|
@ -445,6 +452,7 @@ class StreamingPlayback extends StreamEngine {
|
|||
this.absolutePitch = 0;
|
||||
this.lastAbsYaw = 0;
|
||||
this.lastAbsPitch = 0;
|
||||
this.firstPerson = this.initialBlock.firstPerson;
|
||||
this.lastControlType =
|
||||
detectControlObjectType(this.initialBlock.controlObjectData) ?? "player";
|
||||
this.isPiloting =
|
||||
|
|
@ -454,6 +462,31 @@ class StreamingPlayback extends StreamEngine {
|
|||
this.initialBlock.controlObjectData?.controlObjectGhost != null
|
||||
)
|
||||
: false;
|
||||
this.lastPilotGhostIndex =
|
||||
this.isPiloting &&
|
||||
typeof this.initialBlock.controlObjectData?.controlObjectGhost === "number"
|
||||
? this.initialBlock.controlObjectData.controlObjectGhost
|
||||
: undefined;
|
||||
if (this.isPiloting) {
|
||||
const nested = this.initialBlock.controlObjectData?.controlObjectData as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const ang = nested?.angPosition as
|
||||
| { x: number; y: number; z: number; w: number }
|
||||
| undefined;
|
||||
if (ang && typeof ang.w === "number") {
|
||||
this.lastVehicleHeading = torqueQuatHeading(ang);
|
||||
this.lastVehiclePitch = torqueQuatPitch(ang);
|
||||
const threeQ = torqueQuatToThreeJS(ang);
|
||||
if (threeQ) {
|
||||
const [qx, qy, qz, qw] = threeQ;
|
||||
const fx = 1 - 2 * (qy * qy + qz * qz);
|
||||
const fy = 2 * (qx * qy + qz * qw);
|
||||
const fz = 2 * (qx * qz - qy * qw);
|
||||
this.lastVehicleOrbitDir = [-fx, -fy, -fz];
|
||||
}
|
||||
}
|
||||
}
|
||||
this.lastCameraMode =
|
||||
this.lastControlType === "camera" &&
|
||||
typeof this.initialBlock.controlObjectData?.cameraMode === "number"
|
||||
|
|
@ -609,7 +642,7 @@ class StreamingPlayback extends StreamEngine {
|
|||
const isPlayerChat = hasChatColor && fullText.includes(": ");
|
||||
if (isPlayerChat) {
|
||||
const colonIdx = fullText.indexOf(": ");
|
||||
this.chatMessages.push({
|
||||
this.pushChatMessage({
|
||||
timeSec: 0,
|
||||
sender: fullText.slice(0, colonIdx),
|
||||
text: fullText.slice(colonIdx + 2),
|
||||
|
|
@ -618,7 +651,7 @@ class StreamingPlayback extends StreamEngine {
|
|||
segments,
|
||||
});
|
||||
} else {
|
||||
this.chatMessages.push({
|
||||
this.pushChatMessage({
|
||||
timeSec: 0,
|
||||
sender: "",
|
||||
text: fullText,
|
||||
|
|
@ -749,6 +782,10 @@ class StreamingPlayback extends StreamEngine {
|
|||
|
||||
// Apply ghost rotation to absolute tracking. This must happen before
|
||||
// the next move delta so that our tracking stays calibrated to V12.
|
||||
// During piloting, rotationZ/headX are relative to the vehicle (reset
|
||||
// to 0 on mount). We still accept the reset so move deltas accumulate
|
||||
// from the correct base; the vehicle heading offset is added later in
|
||||
// updateCameraAndHud.
|
||||
const controlData = packet.gameState.controlObjectData;
|
||||
if (controlData) {
|
||||
const absRot = this.getAbsoluteRotation(controlData);
|
||||
|
|
@ -773,6 +810,9 @@ class StreamingPlayback extends StreamEngine {
|
|||
}
|
||||
|
||||
if (block.type === BlockTypeInfo && this.isInfoData(block.parsed)) {
|
||||
// InfoBlock: value1 byte 0 = $firstPerson flag, value2 = FOV.
|
||||
// Verified against Tribes2.exe GameConnection::handleRecordedBlock.
|
||||
this.firstPerson = (block.parsed.value1 & 0xff) !== 0;
|
||||
if (Number.isFinite(block.parsed.value2)) {
|
||||
this.latestFov = block.parsed.value2;
|
||||
}
|
||||
|
|
@ -919,10 +959,14 @@ class StreamingPlayback extends StreamEngine {
|
|||
return !!parsed && typeof parsed === "object" && "yaw" in parsed;
|
||||
}
|
||||
|
||||
private isInfoData(parsed: unknown): parsed is { value2: number } {
|
||||
private isInfoData(
|
||||
parsed: unknown,
|
||||
): parsed is { value1: number; value2: number } {
|
||||
return (
|
||||
!!parsed &&
|
||||
typeof parsed === "object" &&
|
||||
"value1" in parsed &&
|
||||
typeof (parsed as { value1?: unknown }).value1 === "number" &&
|
||||
"value2" in parsed &&
|
||||
typeof (parsed as { value2?: unknown }).value2 === "number"
|
||||
);
|
||||
|
|
|
|||
|
|
@ -101,6 +101,9 @@ export function streamEntityToGameEntity(
|
|||
shapeName: entity.dataBlock,
|
||||
dataBlock: entity.dataBlock,
|
||||
weaponShape: entity.weaponShape,
|
||||
packShape: entity.packShape,
|
||||
falling: entity.falling,
|
||||
jetting: entity.jetting,
|
||||
playerName: entity.playerName,
|
||||
iffColor: entity.iffColor,
|
||||
threads: entity.threads,
|
||||
|
|
|
|||
|
|
@ -354,16 +354,19 @@ export class LiveStreamAdapter extends StreamEngine {
|
|||
this.handleGhostingMessage(event.parsedData);
|
||||
const type = event.parsedData.type as string;
|
||||
|
||||
// Log events in early packets
|
||||
// Always log RemoteCommandEvents (chat, server messages, HUD).
|
||||
if (type === "RemoteCommandEvent") {
|
||||
const funcName = this.resolveNetString(event.parsedData.funcName as string ?? "");
|
||||
console.log(`[live] remote: ${funcName}`);
|
||||
}
|
||||
// Log other events in early packets
|
||||
if (isEarlyPacket) {
|
||||
if (type !== "NetStringEvent") {
|
||||
if (type !== "NetStringEvent" && type !== "RemoteCommandEvent") {
|
||||
console.log(
|
||||
`[live] event: ${type}`,
|
||||
type === "RemoteCommandEvent"
|
||||
? { funcName: this.resolveNetString(event.parsedData.funcName as string ?? "") }
|
||||
: type === "SimDataBlockEvent"
|
||||
? { id: event.parsedData.objectId, className: event.parsedData.dataBlockClassName }
|
||||
: undefined,
|
||||
type === "SimDataBlockEvent"
|
||||
? { id: event.parsedData.objectId, className: event.parsedData.dataBlockClassName }
|
||||
: undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -491,8 +494,18 @@ export class LiveStreamAdapter extends StreamEngine {
|
|||
);
|
||||
}
|
||||
|
||||
const prevMode = this.camera?.mode;
|
||||
this.updateCameraAndHud();
|
||||
|
||||
// Log camera mode transitions (always, not just early packets).
|
||||
if (this.camera && this.camera.mode !== prevMode) {
|
||||
console.log(
|
||||
`[live] camera mode: ${prevMode ?? "none"} → ${this.camera.mode}` +
|
||||
(this.camera.mode === "third-person"
|
||||
? ` orbit=${this.camera.orbitTargetId ?? "?"} dist=${this.camera.orbitDistance ?? "?"}`
|
||||
: ""),
|
||||
);
|
||||
}
|
||||
// Log camera position for early packets
|
||||
if (this.tickCount <= 5 && this.camera) {
|
||||
const [cx, cy, cz] = this.camera.position;
|
||||
|
|
|
|||
|
|
@ -1,17 +1,22 @@
|
|||
/**
|
||||
* Movement animation selection logic replicating Torque's
|
||||
* Player::pickActionAnimation() (player.cc:2280).
|
||||
* Player::pickActionAnimation() (Tribes2.exe FUN_005d6210).
|
||||
*
|
||||
* The server does NOT transmit table animation indices (0-7) over the
|
||||
* network. Each client independently derives the movement animation from
|
||||
* the ghost's velocity, body rotation, and state flags (mFalling, jetting).
|
||||
*/
|
||||
/** Torque falling threshold: Z velocity below this = falling. */
|
||||
const FALLING_THRESHOLD = -10;
|
||||
|
||||
/** Minimum velocity dot product to count as intentional movement. */
|
||||
const MOVE_THRESHOLD = 0.1;
|
||||
|
||||
export interface MoveAnimationResult {
|
||||
/** Engine alias name (e.g. "root", "run", "back", "side", "fall"). */
|
||||
/** Engine alias name (e.g. "root", "run", "back", "side", "fall", "jet"). */
|
||||
animation: string;
|
||||
/** 1 for forward playback, -1 for reversed (right strafe). */
|
||||
timeScale: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract body yaw (Torque rotationZ) from a Three.js quaternion produced by
|
||||
* `playerYawToQuaternion()`. That function builds a Y-axis rotation:
|
||||
|
|
@ -21,41 +26,65 @@ export interface MoveAnimationResult {
|
|||
function quaternionToBodyYaw(q: [number, number, number, number]): number {
|
||||
return -2 * Math.atan2(q[1], q[3]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the movement animation for a player based on their velocity and
|
||||
* body orientation, matching Torque's pickActionAnimation().
|
||||
* Pick the movement animation for a player based on their velocity, body
|
||||
* orientation, and movement state flags.
|
||||
*
|
||||
* @param velocity Torque world-space velocity [x, y, z], or undefined for idle.
|
||||
* @param rotation Three.js quaternion from playerYawToQuaternion().
|
||||
* Matches the Tribes2.exe binary (build 25034) pickActionAnimation at
|
||||
* 0x005d6210. The binary checks in order:
|
||||
* 1. mFalling → FallAnim (4)
|
||||
* 2. contactTimer < 30 → velocity-based selection (run/back/side/root)
|
||||
* 3. jetting → JetAnim (5)
|
||||
* 4. else → RootAnim (0)
|
||||
*
|
||||
* Since we don't have contactTimer, falling=false + no velocity uses root.
|
||||
*/
|
||||
export function pickMoveAnimation(
|
||||
velocity: [number, number, number] | undefined,
|
||||
rotation: [number, number, number, number],
|
||||
falling?: boolean,
|
||||
jetting?: boolean,
|
||||
): MoveAnimationResult {
|
||||
if (!velocity) {
|
||||
return { animation: "root", timeScale: 1 };
|
||||
}
|
||||
const [vx, vy, vz] = velocity;
|
||||
// Falling: Torque Z velocity below threshold.
|
||||
if (vz < FALLING_THRESHOLD) {
|
||||
// Falling overrides everything.
|
||||
if (falling) {
|
||||
return { animation: "fall", timeScale: 1 };
|
||||
}
|
||||
|
||||
if (!velocity) {
|
||||
// No velocity data at all — use jetting or idle.
|
||||
if (jetting) return { animation: "jet", timeScale: 1 };
|
||||
return { animation: "root", timeScale: 1 };
|
||||
}
|
||||
|
||||
const [vx, vy, _vz] = velocity;
|
||||
|
||||
// Convert world velocity to player object space using body yaw.
|
||||
// mWorldToObj.mulV(mVelocity) with a pure Z-axis rotation:
|
||||
// localX = vx*cos(rotZ) + vy*sin(rotZ)
|
||||
// localY = -vx*sin(rotZ) + vy*cos(rotZ)
|
||||
const yaw = quaternionToBodyYaw(rotation);
|
||||
const cosY = Math.cos(yaw);
|
||||
const sinY = Math.sin(yaw);
|
||||
// Torque object space: localY = forward, localX = right.
|
||||
const localX = vx * cosY + vy * sinY;
|
||||
const localY = -vx * sinY + vy * cosY;
|
||||
// Pick direction with largest dot product.
|
||||
|
||||
// Dot products against animation direction vectors:
|
||||
// run dir = (0, 1, 0) → dot = localY
|
||||
// back dir = (0,-1, 0) → dot = -localY
|
||||
// side dir = (-1,0, 0) → dot = -localX (left), +localX (right reversed)
|
||||
const forwardDot = localY;
|
||||
const backDot = -localY;
|
||||
const leftDot = -localX;
|
||||
const rightDot = localX;
|
||||
|
||||
const maxDot = Math.max(forwardDot, backDot, leftDot, rightDot);
|
||||
if (maxDot < MOVE_THRESHOLD) {
|
||||
// Below movement threshold — jetting or idle.
|
||||
if (jetting) return { animation: "jet", timeScale: 1 };
|
||||
return { animation: "root", timeScale: 1 };
|
||||
}
|
||||
|
||||
if (maxDot === forwardDot) {
|
||||
return { animation: "run", timeScale: 1 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -116,9 +116,9 @@ export class RelayClient {
|
|||
}
|
||||
|
||||
/** Join a specific game server. */
|
||||
joinServer(address: string): void {
|
||||
joinServer(address: string, warriorName?: string): void {
|
||||
console.log("[relay] Joining server:", address);
|
||||
this.send({ type: "joinServer", address });
|
||||
this.send({ type: "joinServer", address, warriorName });
|
||||
}
|
||||
|
||||
/** Disconnect from the current game server. */
|
||||
|
|
|
|||
|
|
@ -83,6 +83,31 @@ export function torqueQuatToThreeJS(q: {
|
|||
return [x * invLen, y * invLen, z * invLen, w * invLen];
|
||||
}
|
||||
|
||||
/** Extract heading (yaw around Torque Z axis) from a Torque quaternion. */
|
||||
export function torqueQuatHeading(q: {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
w: number;
|
||||
}): number {
|
||||
return Math.atan2(
|
||||
2 * (q.w * q.z + q.x * q.y),
|
||||
q.w * q.w + q.x * q.x - q.y * q.y - q.z * q.z,
|
||||
);
|
||||
}
|
||||
|
||||
/** Extract pitch (rotation around Torque X axis) from a Torque quaternion. */
|
||||
export function torqueQuatPitch(q: {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
w: number;
|
||||
}): number {
|
||||
const sinp = 2 * (q.w * q.x - q.y * q.z);
|
||||
// Clamp for numerical stability near poles.
|
||||
return Math.asin(Math.max(-1, Math.min(1, sinp)));
|
||||
}
|
||||
|
||||
// ── Position / type guards ──
|
||||
|
||||
export function isValidPosition(
|
||||
|
|
|
|||
|
|
@ -130,6 +130,12 @@ export interface StreamEntity {
|
|||
weaponImageState?: WeaponImageState;
|
||||
/** Weapon image state machine states from the ShapeBaseImageData datablock. */
|
||||
weaponImageStates?: WeaponImageDataBlockState[];
|
||||
/** DTS shape name for the mounted pack (slot 2, Mount1 bone). */
|
||||
packShape?: string;
|
||||
/** True when the player has no ground contact and is falling. */
|
||||
falling?: boolean;
|
||||
/** True when the player is using jetpack thrust. */
|
||||
jetting?: boolean;
|
||||
/** Head pitch for blend animations, normalized [-1,1]. -1 = max down, 1 = max up. */
|
||||
headPitch?: number;
|
||||
/** Head yaw for blend animations (freelook), normalized [-1,1]. -1 = max right, 1 = max left. */
|
||||
|
|
@ -166,6 +172,8 @@ export interface StreamCamera {
|
|||
yaw?: number;
|
||||
/** Absolute control-object pitch in Torque radians (rotX/headX). */
|
||||
pitch?: number;
|
||||
/** Explicit orbit pullback direction in Three.js space (overrides yaw/pitch). */
|
||||
orbitDirection?: [number, number, number];
|
||||
}
|
||||
|
||||
/** A colored text segment from inline \c color switching. */
|
||||
|
|
@ -176,6 +184,7 @@ export interface ChatSegment {
|
|||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
id: number;
|
||||
timeSec: number;
|
||||
sender: string;
|
||||
text: string;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue