bug fixes, add player name support

This commit is contained in:
Brian Beck 2026-03-09 23:19:14 -07:00
parent e4ae265184
commit d9b5e30831
75 changed files with 1139 additions and 544 deletions

View file

@ -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} />;
}

View file

@ -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.

View file

@ -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>

View file

@ -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;
}

View file

@ -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(() => {

View file

@ -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;

View file

@ -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) ── */

View file

@ -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>
);
}

View file

@ -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

View file

@ -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} />

View file

@ -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) {

View file

@ -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>

View file

@ -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 (

View 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} />;
}

View file

@ -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) {

View file

@ -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;

View file

@ -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[];

View 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;
}

View file

@ -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,
);

View file

@ -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"
);

View file

@ -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,

View file

@ -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;

View file

@ -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 };
}

View file

@ -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. */

View file

@ -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(

View file

@ -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;