new UI, unify map/demo/live architecture more, cleanup

This commit is contained in:
Brian Beck 2026-03-12 16:25:04 -07:00
parent d9b5e30831
commit 4741f59582
146 changed files with 5477 additions and 3005 deletions

View file

@ -24,3 +24,9 @@ T2_ACCOUNT_PASSWORD=
# Relay URL for the browser client (default: ws://localhost:8765) # Relay URL for the browser client (default: ws://localhost:8765)
# NEXT_PUBLIC_RELAY_URL=ws://localhost:8765 # NEXT_PUBLIC_RELAY_URL=ws://localhost:8765
# Logging (comma-separated). Bare level sets global default; module:level
# pairs override specific modules. If only modules given, rest are silent.
# NEXT_PUBLIC_LOG=debug
# NEXT_PUBLIC_LOG=liveStreaming:debug,DebugSuspense:trace
# NEXT_PUBLIC_LOG=warn,liveStreaming:debug

View file

@ -120,7 +120,7 @@ This only needs to be done once — the volume persists across deploys.
**Environment variables** (all optional, with defaults): **Environment variables** (all optional, with defaults):
| Variable | Default | Description | | Variable | Default | Description |
| --- | --- | --- | | ------------------ | ----------------------------------------------- | ------------------------------------- |
| `RELAY_PORT` | `8765` | WebSocket listen port | | `RELAY_PORT` | `8765` | WebSocket listen port |
| `GAME_BASE_PATH` | `docs/base` relative to relay | Path to extracted game assets | | `GAME_BASE_PATH` | `docs/base` relative to relay | Path to extracted game assets |
| `MANIFEST_PATH` | `public/manifest.json` relative to project root | Path to resource manifest | | `MANIFEST_PATH` | `public/manifest.json` relative to project root | Path to resource manifest |

View file

@ -18,11 +18,7 @@ export default function RootLayout({ children }: { children: ReactNode }) {
return ( return (
<html lang="en"> <html lang="en">
<body> <body>
<NuqsAdapter <NuqsAdapter defaultOptions={{ clearOnDefault: false }}>
defaultOptions={{
clearOnDefault: false,
}}
>
{children} {children}
</NuqsAdapter> </NuqsAdapter>
</body> </body>

View file

@ -1,521 +1,21 @@
"use client"; "use client";
import { import { Suspense } from "react";
useState,
useEffect,
useCallback,
Suspense,
useRef,
lazy,
} from "react";
import { Canvas, GLProps } from "@react-three/fiber";
import { NoToneMapping, SRGBColorSpace, PCFShadowMap, Camera } from "three";
import { Mission } from "@/src/components/Mission";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { // import { LiveConnectionProvider } from "@/src/components/LiveConnection";
ObserverControls, import { FeaturesProvider } from "@/src/components/FeaturesProvider";
KEYBOARD_CONTROLS, import { MapInspector } from "@/src/components/MapInspector";
} from "@/src/components/ObserverControls";
import { KeyboardOverlay } from "@/src/components/KeyboardOverlay";
import {
TouchJoystick,
TouchCameraMovement,
type JoystickState,
} from "@/src/components/TouchControls";
import { KeyboardControls } from "@react-three/drei";
import { InspectorControls } from "@/src/components/InspectorControls";
import { useTouchDevice } from "@/src/components/useTouchDevice";
import {
SettingsProvider,
useSettings,
} from "@/src/components/SettingsProvider";
import { ObserverCamera } from "@/src/components/ObserverCamera";
import { AudioProvider } from "@/src/components/AudioContext";
import { DebugElements } from "@/src/components/DebugElements";
import { CamerasProvider } from "@/src/components/CamerasProvider";
import {
RecordingProvider,
usePlaybackActions,
useRecording,
} from "@/src/components/RecordingProvider";
import { EntityScene } from "@/src/components/EntityScene";
import { TickProvider } from "@/src/components/TickProvider";
import { SceneLighting } from "@/src/components/SceneLighting";
import { PlayerHUD } from "@/src/components/PlayerHUD";
import { LiveConnectionProvider } from "@/src/components/LiveConnection";
import { useLiveSelector } from "@/src/state/liveConnectionStore";
import { ServerBrowser } from "@/src/components/ServerBrowser";
import {
FeaturesProvider,
useFeatures,
} from "@/src/components/FeaturesProvider";
// Lazy-load demo and live streaming modules — they pull in heavy dependencies
// (demo parser, streaming engine, particles) that aren't needed for mission-only mode.
const StreamPlayback = lazy(() =>
import("@/src/components/StreamPlayback").then((mod) => ({
default: mod.StreamPlayback,
})),
);
const DemoPlaybackControls = lazy(() =>
import("@/src/components/DemoPlaybackControls").then((mod) => ({
default: mod.DemoPlaybackControls,
})),
);
const LiveObserver = lazy(() =>
import("@/src/components/LiveObserver").then((mod) => ({
default: mod.LiveObserver,
})),
);
const ChatSoundPlayer = lazy(() =>
import("@/src/components/ChatSoundPlayer").then((mod) => ({
default: mod.ChatSoundPlayer,
})),
);
import {
getMissionList,
getMissionInfo,
} from "@/src/manifest";
import { createParser, parseAsBoolean, useQueryState } from "nuqs";
import styles from "./page.module.css";
const MapInfoDialog = lazy(() =>
import("@/src/components/MapInfoDialog").then((mod) => ({
default: mod.MapInfoDialog,
})),
);
// Three.js has its own loaders for textures and models, but we need to load other // Three.js has its own loaders for textures and models, but we need to load other
// stuff too, e.g. missions, terrains, and more. This client is used for those. // stuff too, e.g. missions, terrains, and more. This client is used for those.
const queryClient = new QueryClient(); const queryClient = new QueryClient();
// Renderer settings to match Tribes 2's simple rendering pipeline.
// Tribes 2 (Torque engine, 2001) worked entirely in gamma/sRGB space with no HDR
// or tone mapping. We disable tone mapping and ensure proper sRGB output.
const glSettings: GLProps = {
toneMapping: NoToneMapping,
outputColorSpace: SRGBColorSpace,
};
type CurrentMission = {
missionName: string;
missionType?: string;
};
const defaultMission: CurrentMission = {
missionName: "RiverDance",
missionType: "CTF",
};
const parseAsMissionWithType = createParser<CurrentMission>({
parse(query: string) {
const [missionName, missionType] = query.split("~");
let selectedMissionType = missionType;
const availableMissionTypes = getMissionInfo(missionName).missionTypes;
if (!missionType || !availableMissionTypes.includes(missionType)) {
selectedMissionType = availableMissionTypes[0];
}
return { missionName, missionType: selectedMissionType };
},
serialize({ missionName, missionType }): string {
const availableMissionTypes = getMissionInfo(missionName).missionTypes;
if (availableMissionTypes.length === 1) {
return missionName;
}
return `${missionName}~${missionType}`;
},
eq(a, b) {
return a.missionName === b.missionName && a.missionType === b.missionType;
},
}).withDefault(defaultMission);
function MapInspector() {
const [currentMission, setCurrentMission] = useQueryState(
"mission",
parseAsMissionWithType,
);
const [fogEnabledOverride, setFogEnabledOverride] = useQueryState(
"fog",
parseAsBoolean,
);
const clearFogEnabledOverride = useCallback(() => {
setFogEnabledOverride(null);
}, [setFogEnabledOverride]);
const changeMission = useCallback(
(mission: CurrentMission) => {
window.location.hash = "";
clearFogEnabledOverride();
setCurrentMission(mission);
},
[setCurrentMission, clearFogEnabledOverride],
);
const isTouch = useTouchDevice();
const features = useFeatures();
const hasLiveAdapter = useLiveSelector((s) => s.adapter != null);
const liveReady = useLiveSelector((s) => s.liveReady);
const gameStatus = useLiveSelector((s) => s.gameStatus);
const { missionName, missionType } = currentMission;
const [mapInfoOpen, setMapInfoOpen] = useState(false);
const [serverBrowserOpen, setServerBrowserOpen] = useState(false);
const [missionLoadingProgress, setMissionLoadingProgress] = useState(0);
const [showLoadingIndicator, setShowLoadingIndicator] = useState(true);
// During live join, show progress based on connection status.
// Relay status order: connecting → challenging → authenticating → connected.
// Once liveReady (first ghost arrives), loading is complete.
const liveLoadingProgress = hasLiveAdapter
? liveReady
? 1
: gameStatus === "connected" ? 0.8
: gameStatus === "authenticating" ? 0.6
: gameStatus === "challenging" ? 0.3
: gameStatus === "connecting" ? 0.2
: 0.1
: null;
// Reset stale mission progress when live mode takes over, so it can't
// flash through if liveLoadingProgress briefly becomes null.
useEffect(() => {
if (liveLoadingProgress != null) {
setMissionLoadingProgress(0);
}
}, [liveLoadingProgress != null]); // eslint-disable-line react-hooks/exhaustive-deps
const loadingProgress = liveLoadingProgress ?? missionLoadingProgress;
const isLoading = loadingProgress < 1;
// Keep the loading indicator visible briefly after reaching 100%
useEffect(() => {
if (isLoading) {
setShowLoadingIndicator(true);
} else {
const timer = setTimeout(() => setShowLoadingIndicator(false), 500);
return () => clearTimeout(timer);
}
}, [isLoading]);
useEffect(() => {
// For automation, like the t2-maps app!
window.setMissionName = (missionName: string) => {
const availableMissionTypes = getMissionInfo(missionName).missionTypes;
changeMission({
missionName,
missionType: availableMissionTypes[0],
});
};
window.getMissionList = getMissionList;
window.getMissionInfo = getMissionInfo;
return () => {
delete window.setMissionName;
delete window.getMissionList;
delete window.getMissionInfo;
};
}, [changeMission]);
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.code !== "KeyI" || e.metaKey || e.ctrlKey || e.altKey) return;
const target = e.target as HTMLElement;
if (
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.isContentEditable
) {
return;
}
setMapInfoOpen(true);
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, []);
const handleLoadingChange = useCallback(
(_loading: boolean, progress: number = 0) => {
setMissionLoadingProgress(progress);
},
[],
);
const cameraRef = useRef<Camera | null>(null);
const joystickStateRef = useRef<JoystickState>({ angle: 0, force: 0 });
const joystickZoneRef = useRef<HTMLDivElement | null>(null);
const lookJoystickStateRef = useRef<JoystickState>({ angle: 0, force: 0 });
const lookJoystickZoneRef = useRef<HTMLDivElement | null>(null);
return (
<QueryClientProvider client={queryClient}>
<main>
<RecordingProvider>
<SettingsProvider
fogEnabledOverride={fogEnabledOverride}
onClearFogEnabledOverride={clearFogEnabledOverride}
>
<KeyboardControls map={KEYBOARD_CONTROLS}>
<div id="canvasContainer" className={styles.CanvasContainer}>
{showLoadingIndicator && (
<div
id="loadingIndicator"
className={styles.LoadingIndicator}
data-complete={!isLoading}
>
<div className={styles.Spinner} />
<div className={styles.Progress}>
<div
className={styles.ProgressBar}
style={{ width: `${loadingProgress * 100}%` }}
/>
</div>
<div className={styles.ProgressText}>
{Math.round(loadingProgress * 100)}%
</div>
</div>
)}
<Canvas
frameloop="always"
gl={glSettings}
shadows={{ type: PCFShadowMap }}
onCreated={(state) => {
cameraRef.current = state.camera;
}}
>
<TickProvider>
<CamerasProvider>
<AudioProvider>
<MissionWhenIdle
missionName={missionName}
missionType={missionType}
onLoadingChange={handleLoadingChange}
/>
<SceneLighting />
<EntityScene missionType={missionType} />
<ObserverCamera />
<DebugElements />
<StreamingComponents
isTouch={isTouch}
joystickStateRef={joystickStateRef}
joystickZoneRef={joystickZoneRef}
lookJoystickStateRef={lookJoystickStateRef}
lookJoystickZoneRef={lookJoystickZoneRef}
/>
</AudioProvider>
</CamerasProvider>
</TickProvider>
</Canvas>
</div>
<StreamingHUD />
{isTouch && (
<TouchJoystick
joystickState={joystickStateRef}
joystickZone={joystickZoneRef}
lookJoystickState={lookJoystickStateRef}
lookJoystickZone={lookJoystickZoneRef}
/>
)}
{isTouch === false && <KeyboardOverlay />}
<InspectorControls
missionName={missionName}
missionType={missionType}
onChangeMission={changeMission}
onOpenMapInfo={() => setMapInfoOpen(true)}
onOpenServerBrowser={features.live ? () => setServerBrowserOpen(true) : undefined}
cameraRef={cameraRef}
isTouch={isTouch}
/>
{mapInfoOpen && (
<Suspense fallback={null}>
<MapInfoDialog
open={mapInfoOpen}
onClose={() => setMapInfoOpen(false)}
missionName={missionName}
missionType={missionType ?? ""}
/>
</Suspense>
)}
<ServerBrowserDialog
open={serverBrowserOpen}
onClose={() => setServerBrowserOpen(false)}
/>
<StreamingOverlay />
<DemoWindowAPI />
</KeyboardControls>
</SettingsProvider>
</RecordingProvider>
</main>
</QueryClientProvider>
);
}
/**
* Only mount Mission (TorqueScript runtime, .mis loading) when NOT streaming.
* During demo/live playback, all scene data comes from ghosts no need for
* the heavy TorqueScript execution pipeline.
*/
function MissionWhenIdle({
missionName,
missionType,
onLoadingChange,
}: {
missionName: string;
missionType: string;
onLoadingChange: (isLoading: boolean, progress?: number) => void;
}) {
const recording = useRecording();
const hasLiveAdapter = useLiveSelector((s) => s.adapter != null);
const isStreaming = recording != null || hasLiveAdapter;
if (isStreaming) return null;
return (
<Mission
key={`${missionName}~${missionType}`}
name={missionName}
missionType={missionType}
onLoadingChange={onLoadingChange}
/>
);
}
/**
* In-Canvas components that depend on streaming mode. Mounts the appropriate
* controller (StreamPlayback or LiveObserver) and disables observer controls
* during streaming.
*/
function StreamingComponents({
isTouch,
joystickStateRef,
joystickZoneRef,
lookJoystickStateRef,
lookJoystickZoneRef,
}: {
isTouch: boolean | null;
joystickStateRef: React.RefObject<JoystickState>;
joystickZoneRef: React.RefObject<HTMLDivElement | null>;
lookJoystickStateRef: React.RefObject<JoystickState>;
lookJoystickZoneRef: React.RefObject<HTMLDivElement | null>;
}) {
const recording = useRecording();
const isLive = useLiveSelector((s) => s.adapter != null);
const isStreaming = recording != null || isLive;
// Show ObserverControls for: non-streaming mode, OR live mode.
// During live, ObserverControls provides the same camera controls
// (pointer lock, drag-to-rotate, WASD fly) and LiveObserver intercepts
// click-while-locked to cycle observed players instead of nextCamera.
// During demo playback, the demo drives the camera so no controls needed.
const showObserverControls = !isStreaming || isLive;
return (
<>
{recording && (
<Suspense fallback={null}>
<StreamPlayback />
</Suspense>
)}
{isLive && (
<Suspense fallback={null}>
<LiveObserver />
</Suspense>
)}
{isStreaming && (
<Suspense fallback={null}>
<ChatSoundPlayer />
</Suspense>
)}
{showObserverControls && isTouch !== null && (
isTouch ? (
<TouchCameraMovement
joystickState={joystickStateRef}
joystickZone={joystickZoneRef}
lookJoystickState={lookJoystickStateRef}
lookJoystickZone={lookJoystickZoneRef}
/>
) : (
<ObserverControls />
)
)}
</>
);
}
/** HUD overlay — shown during streaming (demo or live). */
function StreamingHUD() {
const recording = useRecording();
const hasLiveAdapter = useLiveSelector((s) => s.adapter != null);
if (!recording && !hasLiveAdapter) return null;
return <PlayerHUD isLive={hasLiveAdapter} />;
}
/** Playback controls overlay — only shown during demo playback. */
function StreamingOverlay() {
const recording = useRecording();
const hasLiveAdapter = useLiveSelector((s) => s.adapter != null);
if (!recording || hasLiveAdapter) return null;
return (
<Suspense fallback={null}>
<DemoPlaybackControls />
</Suspense>
);
}
/** Server browser dialog connected to live state. */
function ServerBrowserDialog({
open,
onClose,
}: {
open: boolean;
onClose: () => void;
}) {
const servers = useLiveSelector((s) => s.servers);
const serversLoading = useLiveSelector((s) => s.serversLoading);
const browserToRelayPing = useLiveSelector((s) => s.browserToRelayPing);
const listServers = useLiveSelector((s) => s.listServers);
const joinServer = useLiveSelector((s) => s.joinServer);
const settings = useSettings();
const handleJoin = useCallback(
(address: string) => {
joinServer(address, settings?.warriorName);
},
[joinServer, settings?.warriorName],
);
return (
<ServerBrowser
open={open}
onClose={onClose}
servers={servers}
loading={serversLoading}
onRefresh={listServers}
onJoin={handleJoin}
wsPing={browserToRelayPing}
warriorName={settings?.warriorName ?? ""}
onWarriorNameChange={(name) => settings?.setWarriorName(name)}
/>
);
}
/** Exposes `window.loadDemoRecording` for automation/testing. */
function DemoWindowAPI() {
const { setRecording } = usePlaybackActions();
useEffect(() => {
window.loadDemoRecording = setRecording;
return () => {
delete window.loadDemoRecording;
};
}, [setRecording]);
return null;
}
export default function HomePage() { export default function HomePage() {
return ( return (
<Suspense> <Suspense>
<FeaturesProvider> <FeaturesProvider>
<LiveConnectionProvider> <QueryClientProvider client={queryClient}>
<MapInspector /> <MapInspector />
</LiveConnectionProvider> </QueryClientProvider>
</FeaturesProvider> </FeaturesProvider>
</Suspense> </Suspense>
); );

View file

@ -13,16 +13,13 @@ import * as THREE from "three";
import { NoToneMapping, SRGBColorSpace, PCFShadowMap } from "three"; import { NoToneMapping, SRGBColorSpace, PCFShadowMap } from "three";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { OrbitControls, Center, Bounds, useBounds } from "@react-three/drei"; import { OrbitControls, Center, Bounds, useBounds } from "@react-three/drei";
import { import { SettingsProvider, useDebug } from "@/src/components/SettingsProvider";
SettingsProvider,
useDebug,
} from "@/src/components/SettingsProvider";
import { ShapeRenderer, useStaticShape } from "@/src/components/GenericShape"; import { ShapeRenderer, useStaticShape } from "@/src/components/GenericShape";
import { ShapeInfoProvider } from "@/src/components/ShapeInfoProvider"; import { ShapeInfoProvider } from "@/src/components/ShapeInfoProvider";
import { DebugElements } from "@/src/components/DebugElements"; import { DebugElements } from "@/src/components/DebugElements";
import { TickProvider } from "@/src/components/TickProvider"; import { TickProvider } from "@/src/components/TickProvider";
import { ShapeSelect } from "@/src/components/ShapeSelect"; import { ShapeSelect } from "@/src/components/ShapeSelect";
import { engineStore, useEngineSelector } from "@/src/state"; import { engineStore, useEngineSelector } from "@/src/state/engineStore";
import { import {
getResourceList, getResourceList,
getResourceMap, getResourceMap,
@ -328,7 +325,7 @@ function ShapeInspector() {
<TickProvider> <TickProvider>
<SceneLighting /> <SceneLighting />
<Bounds fit clip observe margin={1.5}> <Bounds fit clip observe margin={1.5}>
<Suspense fallback={null}> <Suspense>
<ShapeViewer <ShapeViewer
key={currentShape} key={currentShape}
shapeName={currentShape} shapeName={currentShape}

View file

@ -39,11 +39,6 @@ body {
overflow: hidden; overflow: hidden;
} }
main {
width: 100dvw;
height: 100dvh;
}
input[type="range"] { input[type="range"] {
max-width: 80px; max-width: 80px;
} }

View file

@ -10,6 +10,7 @@ export default defineConfig(
...nextConfig, ...nextConfig,
{ {
rules: { rules: {
"@next/next/no-img-element": "off",
"@typescript-eslint/no-explicit-any": "off", "@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": [ "@typescript-eslint/no-unused-vars": [
"error", "error",

2
next-env.d.ts vendored
View file

@ -1,6 +1,6 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts"; import "./docs/dev/types/routes.d.ts";
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View file

@ -10,6 +10,7 @@ const nextConfig = (phase, { defaultConfig }): NextConfig => {
assetPrefix: "/t2-mapper/", assetPrefix: "/t2-mapper/",
trailingSlash: true, trailingSlash: true,
reactCompiler: true, reactCompiler: true,
experimental: { viewTransition: true },
headers: headers:
// TorqueScript files should be served as text. This won't affect what // TorqueScript files should be served as text. This won't affect what
// GitHub Pages does with the static export, but it'll at least improve // GitHub Pages does with the static export, but it'll at least improve

274
package-lock.json generated
View file

@ -10,6 +10,7 @@
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@ariakit/react": "^0.4.21", "@ariakit/react": "^0.4.21",
"@radix-ui/react-accordion": "^1.2.12",
"@react-three/drei": "^10.7.7", "@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.5.0", "@react-three/fiber": "^9.5.0",
"@tanstack/react-query": "^5.90.21", "@tanstack/react-query": "^5.90.21",
@ -1964,6 +1965,279 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/@radix-ui/primitive": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
"license": "MIT"
},
"node_modules/@radix-ui/react-accordion": {
"version": "1.2.12",
"resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.12.tgz",
"integrity": "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-collapsible": "1.1.12",
"@radix-ui/react-collection": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-controllable-state": "1.2.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-collapsible": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz",
"integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-collection": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
"integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-context": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-direction": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
"integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-id": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
"integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-presence": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
"integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-primitive": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-controllable-state": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
"integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-effect-event": "0.0.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-effect-event": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz",
"integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
"integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@react-three/drei": { "node_modules/@react-three/drei": {
"version": "10.7.7", "version": "10.7.7",
"resolved": "https://registry.npmjs.org/@react-three/drei/-/drei-10.7.7.tgz", "resolved": "https://registry.npmjs.org/@react-three/drei/-/drei-10.7.7.tgz",

View file

@ -30,6 +30,7 @@
}, },
"dependencies": { "dependencies": {
"@ariakit/react": "^0.4.21", "@ariakit/react": "^0.4.21",
"@radix-ui/react-accordion": "^1.2.12",
"@react-three/drei": "^10.7.7", "@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.5.0", "@react-three/fiber": "^9.5.0",
"@tanstack/react-query": "^5.90.21", "@tanstack/react-query": "^5.90.21",

View file

@ -2,32 +2,24 @@ import { BitStreamWriter } from "./BitStreamWriter.js";
/** Hardcoded character frequency table from the V12 engine (bitStream.cc). */ /** Hardcoded character frequency table from the V12 engine (bitStream.cc). */
const CSM_CHAR_FREQS: number[] = [ const CSM_CHAR_FREQS: number[] = [
0, 0, 0, 0, 0, 0, 0, 0, 0, 329, 21, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 329, 21, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2809, 68, 0, 27, 0, 58, 3, 62, 4, 7, 0, 0, 15, 65, 554,
2809, 68, 0, 27, 0, 58, 3, 62, 4, 7, 0, 0, 15, 65, 554, 3, 3, 394, 404, 189, 117, 30, 51, 27, 15, 34, 32, 80, 1, 142, 3, 142, 39, 0, 144,
394, 404, 189, 117, 30, 51, 27, 15, 34, 32, 80, 1, 142, 3, 142, 39, 125, 44, 122, 275, 70, 135, 61, 127, 8, 12, 113, 246, 122, 36, 185, 1, 149,
0, 144, 125, 44, 122, 275, 70, 135, 61, 127, 8, 12, 113, 246, 122, 36, 309, 335, 12, 11, 14, 54, 151, 0, 0, 2, 0, 0, 211, 0, 2090, 344, 736, 993,
185, 1, 149, 309, 335, 12, 11, 14, 54, 151, 0, 0, 2, 0, 0, 211, 2872, 701, 605, 646, 1552, 328, 305, 1240, 735, 1533, 1713, 562, 3, 1775,
0, 2090, 344, 736, 993, 2872, 701, 605, 646, 1552, 328, 305, 1240, 735, 1533, 1713, 1149, 1469, 979, 407, 553, 59, 279, 31, 0, 0, 0, 68, 0, 0, 0, 0, 0, 0, 0, 0,
562, 3, 1775, 1149, 1469, 979, 407, 553, 59, 279, 31, 0, 0, 0, 68, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
]; ];
const PROB_BOOST = 1; const PROB_BOOST = 1;
function isAlphaNumeric(c: number): boolean { function isAlphaNumeric(c: number): boolean {
return ( return (c >= 48 && c <= 57) || (c >= 65 && c <= 90) || (c >= 97 && c <= 122);
(c >= 48 && c <= 57) ||
(c >= 65 && c <= 90) ||
(c >= 97 && c <= 122)
);
} }
interface HuffLeaf { interface HuffLeaf {
@ -62,7 +54,8 @@ function buildTables(): void {
leaves = []; leaves = [];
for (let i = 0; i < 256; i++) { for (let i = 0; i < 256; i++) {
leaves.push({ leaves.push({
pop: CSM_CHAR_FREQS[i] + (isAlphaNumeric(i) ? PROB_BOOST : 0) + PROB_BOOST, pop:
CSM_CHAR_FREQS[i] + (isAlphaNumeric(i) ? PROB_BOOST : 0) + PROB_BOOST,
symbol: i, symbol: i,
numBits: 0, numBits: 0,
code: 0, code: 0,

View file

@ -317,7 +317,10 @@ export class T2csriAuth {
// Sanitize: must be hex only // Sanitize: must be hex only
const challenge = this.encryptedChallenge.toLowerCase(); const challenge = this.encryptedChallenge.toLowerCase();
authLog.info( authLog.info(
{ challengeLen: challenge.length, clientChallengeLen: this.clientChallenge.length }, {
challengeLen: challenge.length,
clientChallengeLen: this.clientChallenge.length,
},
"Auth: starting challenge decryption", "Auth: starting challenge decryption",
); );
for (let i = 0; i < challenge.length; i++) { for (let i = 0; i < challenge.length; i++) {
@ -339,7 +342,11 @@ export class T2csriAuth {
const modulusHex = fields[3]; const modulusHex = fields[3];
authLog.debug( authLog.debug(
{ encryptedLen: challenge.length, modulusLen: modulusHex?.length, privateKeyLen: this.credentials.privateKey.length }, {
encryptedLen: challenge.length,
modulusLen: modulusHex?.length,
privateKeyLen: this.credentials.privateKey.length,
},
"Auth: RSA parameters", "Auth: RSA parameters",
); );

View file

@ -49,7 +49,9 @@ export interface CRCDataBlock {
} }
// Manifest types (mirrored from src/manifest.ts) // Manifest types (mirrored from src/manifest.ts)
type SourceTuple = [sourcePath: string] | [sourcePath: string, actualPath: string]; type SourceTuple =
| [sourcePath: string]
| [sourcePath: string, actualPath: string];
type ResourceEntry = [firstSeenPath: string, ...SourceTuple[]]; type ResourceEntry = [firstSeenPath: string, ...SourceTuple[]];
interface Manifest { interface Manifest {
resources: Record<string, ResourceEntry>; resources: Record<string, ResourceEntry>;

View file

@ -58,7 +58,10 @@ export class GameConnection extends EventEmitter<GameConnectionEvents> {
private nextSendEventSeq = 0; private nextSendEventSeq = 0;
private pendingEvents: ClientEvent[] = []; private pendingEvents: ClientEvent[] = [];
/** Events sent but not yet acked, keyed by packet sequence number. */ /** Events sent but not yet acked, keyed by packet sequence number. */
private sentEventsByPacket = new Map<number, { seq: number; event: ClientEvent }[]>(); private sentEventsByPacket = new Map<
number,
{ seq: number; event: ClientEvent }[]
>();
/** Events waiting to be sent (new or retransmitted from lost packets). */ /** Events waiting to be sent (new or retransmitted from lost packets). */
private eventSendQueue: { seq: number; event: ClientEvent }[] = []; private eventSendQueue: { seq: number; event: ClientEvent }[] = [];
private stringTable = new ClientNetStringTable(); private stringTable = new ClientNetStringTable();
@ -178,7 +181,11 @@ export class GameConnection extends EventEmitter<GameConnectionEvents> {
this.rawMessageCount++; this.rawMessageCount++;
if (this.rawMessageCount <= 30 || this.rawMessageCount % 50 === 0) { if (this.rawMessageCount <= 30 || this.rawMessageCount % 50 === 0) {
connLog.debug( connLog.debug(
{ bytes: msg.length, firstByte: msg[0], rawTotal: this.rawMessageCount }, {
bytes: msg.length,
firstByte: msg[0],
rawTotal: this.rawMessageCount,
},
"Raw UDP message received", "Raw UDP message received",
); );
} }
@ -225,11 +232,16 @@ export class GameConnection extends EventEmitter<GameConnectionEvents> {
case 36: // ConnectAccept case 36: // ConnectAccept
this.handleConnectAccept(msg); this.handleConnectAccept(msg);
break; break;
case 38: { // Disconnect — U8(type) + U32(seq1) + U32(seq2) + HuffString(reason) case 38: {
// Disconnect — U8(type) + U32(seq1) + U32(seq2) + HuffString(reason)
let reason = "Server disconnected"; let reason = "Server disconnected";
if (msg.length > 9) { if (msg.length > 9) {
try { try {
const data = new Uint8Array(msg.buffer, msg.byteOffset, msg.byteLength); const data = new Uint8Array(
msg.buffer,
msg.byteOffset,
msg.byteLength,
);
// Skip 9-byte header (1 type + 4 connectSeq + 4 connectSeq2). // Skip 9-byte header (1 type + 4 connectSeq + 4 connectSeq2).
// Reason is Huffman-encoded via BitStream::writeString (no stringBuffer). // Reason is Huffman-encoded via BitStream::writeString (no stringBuffer).
const bs = new BitStream(data.subarray(9)); const bs = new BitStream(data.subarray(9));
@ -239,7 +251,10 @@ export class GameConnection extends EventEmitter<GameConnectionEvents> {
// Fall back to default reason // Fall back to default reason
} }
} }
connLog.warn({ reason, bytes: msg.length }, "Server sent Disconnect packet"); connLog.warn(
{ reason, bytes: msg.length },
"Server sent Disconnect packet",
);
this.setStatus("disconnected", reason); this.setStatus("disconnected", reason);
this.disconnect(); this.disconnect();
break; break;
@ -249,16 +264,24 @@ export class GameConnection extends EventEmitter<GameConnectionEvents> {
} }
} }
/** Handle ChallengeReject (type 28): U8(28) + U32(connectSeq) + ASCII reason. */ /** Handle ChallengeReject (type 28): U8(28) + U32(clientSeq) + HuffString(reason). */
private handleChallengeReject(msg: Buffer): void { private handleChallengeReject(msg: Buffer): void {
if (msg.length < 5) return;
const dv = new DataView(msg.buffer, msg.byteOffset, msg.byteLength);
const seq = dv.getUint32(1, true);
if (seq !== this.clientConnectSequence) {
connLog.debug({ expected: this.clientConnectSequence, got: seq }, "ChallengeReject sequence mismatch, ignoring");
return;
}
let reason = "Challenge rejected"; let reason = "Challenge rejected";
if (msg.length > 5) { if (msg.length > 5) {
const chars: number[] = []; try {
for (let i = 5; i < msg.length && msg[i] !== 0; i++) { const data = new Uint8Array(msg.buffer, msg.byteOffset, msg.byteLength);
chars.push(msg[i]); const bs = new BitStream(data.subarray(5));
} const parsed = bs.readString();
if (chars.length > 0) { if (parsed) reason = parsed;
reason = String.fromCharCode(...chars); } catch {
// Fall back to default reason
} }
} }
connLog.warn({ reason }, "ChallengeReject received"); connLog.warn({ reason }, "ChallengeReject received");
@ -269,18 +292,11 @@ export class GameConnection extends EventEmitter<GameConnectionEvents> {
/** Handle ConnectChallengeResponse. */ /** Handle ConnectChallengeResponse. */
private handleChallengeResponse(msg: Buffer): void { private handleChallengeResponse(msg: Buffer): void {
if (msg.length < 14) { if (msg.length < 14) {
connLog.error( connLog.error({ bytes: msg.length }, "ChallengeResponse too short");
{ bytes: msg.length },
"ChallengeResponse too short",
);
return; return;
} }
const dv = new DataView( const dv = new DataView(msg.buffer, msg.byteOffset, msg.byteLength);
msg.buffer,
msg.byteOffset,
msg.byteLength,
);
const serverProtocolVersion = dv.getUint32(1, true); const serverProtocolVersion = dv.getUint32(1, true);
this.serverConnectSequence = dv.getUint32(5, true); this.serverConnectSequence = dv.getUint32(5, true);
const echoedClientSeq = dv.getUint32(9, true); const echoedClientSeq = dv.getUint32(9, true);
@ -354,14 +370,30 @@ export class GameConnection extends EventEmitter<GameConnectionEvents> {
} }
/** Handle ConnectReject. */ /** Handle ConnectReject. */
/** Handle ConnectReject (type 34): U8(34) + U32(serverSeq) + U32(clientSeq) + HuffString(reason). */
private handleConnectReject(msg: Buffer): void { private handleConnectReject(msg: Buffer): void {
let reason = "Connection rejected"; if (msg.length < 9) return;
if (msg.length > 1) { const dv = new DataView(msg.buffer, msg.byteOffset, msg.byteLength);
const chars: number[] = []; const serverSeq = dv.getUint32(1, true);
for (let i = 1; i < msg.length && msg[i] !== 0; i++) { const clientSeq = dv.getUint32(5, true);
chars.push(msg[i]); if (serverSeq !== this.serverConnectSequence || clientSeq !== this.clientConnectSequence) {
connLog.debug(
{ expectedServer: this.serverConnectSequence, gotServer: serverSeq,
expectedClient: this.clientConnectSequence, gotClient: clientSeq },
"ConnectReject sequence mismatch, ignoring",
);
return;
}
let reason = "Connection rejected";
if (msg.length > 9) {
try {
const data = new Uint8Array(msg.buffer, msg.byteOffset, msg.byteLength);
const bs = new BitStream(data.subarray(9));
const parsed = bs.readString();
if (parsed) reason = parsed;
} catch {
// Fall back to default reason
} }
reason = String.fromCharCode(...chars);
} }
connLog.warn({ reason }, "ConnectReject received"); connLog.warn({ reason }, "ConnectReject received");
this.setStatus("disconnected", reason); this.setStatus("disconnected", reason);
@ -385,7 +417,6 @@ export class GameConnection extends EventEmitter<GameConnectionEvents> {
// We still need to process the dnet header locally to track ack state // We still need to process the dnet header locally to track ack state
this.processPacketForAcks(data); this.processPacketForAcks(data);
} }
/** Process a packet's dnet header to maintain ack state. */ /** Process a packet's dnet header to maintain ack state. */
@ -415,7 +446,10 @@ export class GameConnection extends EventEmitter<GameConnectionEvents> {
// The server's processRawPacket calls sendPingResponse on receiving a // The server's processRawPacket calls sendPingResponse on receiving a
// PingPacket. Without this response, the server may time us out. // PingPacket. Without this response, the server may time us out.
if (packetType === 1) { if (packetType === 1) {
connLog.debug({ seq: seqNumber }, "Received PingPacket, sending ping response"); connLog.debug(
{ seq: seqNumber },
"Received PingPacket, sending ping response",
);
const pingResponse = this.protocol.buildPingPacket(); const pingResponse = this.protocol.buildPingPacket();
this.sendRaw(pingResponse); this.sendRaw(pingResponse);
} }
@ -472,19 +506,15 @@ export class GameConnection extends EventEmitter<GameConnectionEvents> {
} }
/** Handle a parsed T2csri event from the browser. */ /** Handle a parsed T2csri event from the browser. */
handleAuthEvent( handleAuthEvent(eventName: string, args: string[]): void {
eventName: string,
args: string[],
): void {
if (!this.auth) return; if (!this.auth) return;
switch (eventName) { switch (eventName) {
case "t2csri_pokeClient": { case "t2csri_pokeClient": {
connLog.info("Auth: received pokeClient, sending certificate + challenge"); connLog.info(
const result = this.auth.onPokeClient( "Auth: received pokeClient, sending certificate + challenge",
args[0] || "",
this.host,
); );
const result = this.auth.onPokeClient(args[0] || "", this.host);
for (const cmd of result.commands) { for (const cmd of result.commands) {
this.sendCommand(cmd.name, ...cmd.args); this.sendCommand(cmd.name, ...cmd.args);
} }
@ -512,10 +542,7 @@ export class GameConnection extends EventEmitter<GameConnectionEvents> {
this.authDelayTimer = setTimeout(() => { this.authDelayTimer = setTimeout(() => {
this.authDelayTimer = null; this.authDelayTimer = null;
if (this._status !== "authenticating") return; if (this._status !== "authenticating") return;
this.sendCommand( this.sendCommand(result.command.name, ...result.command.args);
result.command.name,
...result.command.args,
);
this.enforceObserver(); this.enforceObserver();
this.setStatus("connected"); this.setStatus("connected");
}, delay); }, delay);
@ -553,11 +580,20 @@ export class GameConnection extends EventEmitter<GameConnectionEvents> {
basePath: string, basePath: string,
): Promise<void> { ): Promise<void> {
connLog.info( connLog.info(
{ seed: `0x${(seed >>> 0).toString(16)}`, datablocks: datablocks.length, includeTextures }, {
seed: `0x${(seed >>> 0).toString(16)}`,
datablocks: datablocks.length,
includeTextures,
},
"Computing CRC over game files", "Computing CRC over game files",
); );
try { try {
const { crc, totalSize } = await computeGameCRC(seed, datablocks, basePath, includeTextures); const { crc, totalSize } = await computeGameCRC(
seed,
datablocks,
basePath,
includeTextures,
);
connLog.info( connLog.info(
{ crc: `0x${(crc >>> 0).toString(16)}`, totalSize }, { crc: `0x${(crc >>> 0).toString(16)}`, totalSize },
"CRC computed, sending response", "CRC computed, sending response",
@ -586,7 +622,10 @@ export class GameConnection extends EventEmitter<GameConnectionEvents> {
/** Send a commandToServer as a RemoteCommandEvent. */ /** Send a commandToServer as a RemoteCommandEvent. */
sendCommand(command: string, ...args: string[]): void { sendCommand(command: string, ...args: string[]): void {
connLog.debug({ command, args, eventSeq: this.nextSendEventSeq }, "Sending commandToServer"); connLog.debug(
{ command, args, eventSeq: this.nextSendEventSeq },
"Sending commandToServer",
);
const events = buildRemoteCommandEvent(this.stringTable, command, ...args); const events = buildRemoteCommandEvent(this.stringTable, command, ...args);
this.pendingEvents.push(...events); this.pendingEvents.push(...events);
this.flushEvents(); this.flushEvents();
@ -608,9 +647,7 @@ export class GameConnection extends EventEmitter<GameConnectionEvents> {
* Build and send a data packet that includes events from the send queue. * Build and send a data packet that includes events from the send queue.
* Events stay tracked per-packet so they can be re-queued on loss. * Events stay tracked per-packet so they can be re-queued on loss.
*/ */
private sendDataPacketWithEvents( private sendDataPacketWithEvents(move?: ClientMoveData): void {
move?: ClientMoveData,
): void {
const events = this.eventSendQueue.splice(0); const events = this.eventSendQueue.splice(0);
if (events.length === 0) return; if (events.length === 0) return;
@ -631,8 +668,12 @@ export class GameConnection extends EventEmitter<GameConnectionEvents> {
this.sentEventsByPacket.set(packetSeq, events); this.sentEventsByPacket.set(packetSeq, events);
const moveData = move ?? { const moveData = move ?? {
x: 0, y: 0, z: 0, x: 0,
yaw: 0, pitch: 0, roll: 0, y: 0,
z: 0,
yaw: 0,
pitch: 0,
roll: 0,
freeLook: false, freeLook: false,
trigger: [false, false, false, false, false, false], trigger: [false, false, false, false, false, false],
}; };
@ -702,7 +743,14 @@ export class GameConnection extends EventEmitter<GameConnectionEvents> {
this.sendMoveCount++; this.sendMoveCount++;
if (this.sendMoveCount <= 5 || this.sendMoveCount % 100 === 0) { if (this.sendMoveCount <= 5 || this.sendMoveCount % 100 === 0) {
connLog.debug( connLog.debug(
{ yaw: move.yaw, pitch: move.pitch, x: move.x, y: move.y, z: move.z, total: this.sendMoveCount }, {
yaw: move.yaw,
pitch: move.pitch,
x: move.x,
y: move.y,
z: move.z,
total: this.sendMoveCount,
},
"Sending move", "Sending move",
); );
} }
@ -779,7 +827,8 @@ export class GameConnection extends EventEmitter<GameConnectionEvents> {
let keepaliveCount = 0; let keepaliveCount = 0;
this.keepaliveTimer = setInterval(() => { this.keepaliveTimer = setInterval(() => {
keepaliveCount++; keepaliveCount++;
if (keepaliveCount % 300 === 0) { // ~10s at 32ms tick rate if (keepaliveCount % 300 === 0) {
// ~10s at 32ms tick rate
connLog.info( connLog.info(
{ {
dataPackets: this.dataPacketCount, dataPackets: this.dataPacketCount,

View file

@ -133,7 +133,12 @@ async function queryServers(addresses: string[]): Promise<ServerInfo[]> {
if (info) { if (info) {
pingResults.set(addr, info); pingResults.set(addr, info);
masterLog.debug( masterLog.debug(
{ addr, name: info.name, build: info.buildVersion, ping: info.ping }, {
addr,
name: info.name,
build: info.buildVersion,
ping: info.ping,
},
"Ping response", "Ping response",
); );
} }
@ -246,10 +251,7 @@ async function queryServers(addresses: string[]): Promise<ServerInfo[]> {
* U32 buildVersion (e.g. 25034) * U32 buildVersion (e.g. 25034)
* HuffString serverName (24 chars max) * HuffString serverName (24 chars max)
*/ */
function parsePingResponse( function parsePingResponse(data: Buffer, sendTime?: number): PingInfo | null {
data: Buffer,
sendTime?: number,
): PingInfo | null {
if (data.length < 7 || data[0] !== 16) return null; if (data.length < 7 || data[0] !== 16) return null;
try { try {
const bs = new BitStream( const bs = new BitStream(
@ -301,7 +303,15 @@ function parseInfoResponse(data: Buffer): GameInfo | null {
const playerCount = bs.readU8(); const playerCount = bs.readU8();
const maxPlayers = bs.readU8(); const maxPlayers = bs.readU8();
const botCount = bs.readU8(); const botCount = bs.readU8();
return { mod, gameType, mapName, status, playerCount, maxPlayers, botCount }; return {
mod,
gameType,
mapName,
status,
playerCount,
maxPlayers,
botCount,
};
} catch { } catch {
return null; return null;
} }

View file

@ -29,9 +29,7 @@ export class ConnectionProtocol {
private _sendCount = 0; private _sendCount = 0;
buildSendPacketHeader( buildSendPacketHeader(packetType: number = DataPacket): BitStreamWriter {
packetType: number = DataPacket,
): BitStreamWriter {
const bs = new BitStreamWriter(1500); const bs = new BitStreamWriter(1500);
// gameFlag — always true for data connection packets // gameFlag — always true for data connection packets
@ -42,8 +40,7 @@ export class ConnectionProtocol {
// Increment send sequence // Increment send sequence
this.lastSendSeq = (this.lastSendSeq + 1) >>> 0; this.lastSendSeq = (this.lastSendSeq + 1) >>> 0;
this.lastSeqRecvdAtSend[this.lastSendSeq & 0x1f] = this.lastSeqRecvdAtSend[this.lastSendSeq & 0x1f] = this.lastSeqRecvd >>> 0;
this.lastSeqRecvd >>> 0;
// seqNumber (9 bits) // seqNumber (9 bits)
bs.writeInt(this.lastSendSeq & 0x1ff, 9); bs.writeInt(this.lastSendSeq & 0x1ff, 9);
@ -71,7 +68,8 @@ export class ConnectionProtocol {
this._sendCount++; this._sendCount++;
if (this._sendCount <= 30 || this._sendCount % 50 === 0) { if (this._sendCount <= 30 || this._sendCount % 50 === 0) {
const typeName = packetType === 0 ? "data" : packetType === 1 ? "ping" : "ack"; const typeName =
packetType === 0 ? "data" : packetType === 1 ? "ping" : "ack";
console.log( console.log(
`[proto] SEND #${this._sendCount} seq=${this.lastSendSeq} ` + `[proto] SEND #${this._sendCount} seq=${this.lastSendSeq} ` +
`highestAck=${this.lastSeqRecvd} type=${typeName} ` + `highestAck=${this.lastSeqRecvd} type=${typeName} ` +
@ -131,8 +129,7 @@ export class ConnectionProtocol {
const isAcked = const isAcked =
(header.ackMask & (1 << ((highestAck - ackSeq) & 0x1f))) !== 0; (header.ackMask & (1 << ((highestAck - ackSeq) & 0x1f))) !== 0;
if (isAcked) { if (isAcked) {
this.lastRecvAckAck = this.lastRecvAckAck = this.lastSeqRecvdAtSend[ackSeq & 0x1f] >>> 0;
this.lastSeqRecvdAtSend[ackSeq & 0x1f] >>> 0;
} }
if (this.onNotify) { if (this.onNotify) {
this.onNotify(ackSeq, isAcked); this.onNotify(ackSeq, isAcked);
@ -144,8 +141,7 @@ export class ConnectionProtocol {
this.highestAckedSeq = highestAck; this.highestAckedSeq = highestAck;
const dispatchData = const dispatchData =
this.lastSeqRecvd !== seqNumber && this.lastSeqRecvd !== seqNumber && header.packetType === DataPacket;
header.packetType === DataPacket;
this.lastSeqRecvd = seqNumber; this.lastSeqRecvd = seqNumber;
return { accepted: true, dispatchData }; return { accepted: true, dispatchData };
@ -168,9 +164,7 @@ export class ConnectionProtocol {
* The caller provides a callback that writes game data to the stream * The caller provides a callback that writes game data to the stream
* after the dnet header. * after the dnet header.
*/ */
buildDataPacket( buildDataPacket(writePayload: (bs: BitStreamWriter) => void): Uint8Array {
writePayload: (bs: BitStreamWriter) => void,
): Uint8Array {
const bs = this.buildSendPacketHeader(DataPacket); const bs = this.buildSendPacketHeader(DataPacket);
writePayload(bs); writePayload(bs);
return bs.getBuffer(); return bs.getBuffer();
@ -343,10 +337,7 @@ export class ClientNetStringTable {
} }
/** Build a NetStringEvent to register a string with the server. */ /** Build a NetStringEvent to register a string with the server. */
export function buildNetStringEvent( export function buildNetStringEvent(id: number, value: string): ClientEvent {
id: number,
value: string,
): ClientEvent {
return { return {
classId: NetStringEventClassId, classId: NetStringEventClassId,
write(bs: BitStreamWriter) { write(bs: BitStreamWriter) {
@ -489,9 +480,7 @@ export function buildConnectRequest(
} }
/** Build a Disconnect (type 38) OOB packet. */ /** Build a Disconnect (type 38) OOB packet. */
export function buildDisconnectPacket( export function buildDisconnectPacket(connectSequence: number): Uint8Array {
connectSequence: number,
): Uint8Array {
const bs = new BitStreamWriter(64); const bs = new BitStreamWriter(64);
bs.writeU8(38); // Disconnect type bs.writeU8(38); // Disconnect type
bs.writeU32(connectSequence); bs.writeU32(connectSequence);

View file

@ -19,8 +19,7 @@ const MANIFEST_PATH =
path.resolve(GAME_BASE_PATH, "..", "..", "public", "manifest.json"); path.resolve(GAME_BASE_PATH, "..", "..", "public", "manifest.json");
const RELAY_PORT = parseInt(process.env.RELAY_PORT || "8765", 10); const RELAY_PORT = parseInt(process.env.RELAY_PORT || "8765", 10);
const MASTER_SERVER = const MASTER_SERVER = process.env.T2_MASTER_SERVER || "master.tribesnext.com";
process.env.T2_MASTER_SERVER || "master.tribesnext.com";
/** HTTP server for health checks; WebSocket upgrades are handled separately. */ /** HTTP server for health checks; WebSocket upgrades are handled separately. */
const httpServer = http.createServer(async (req, res) => { const httpServer = http.createServer(async (req, res) => {
@ -58,7 +57,9 @@ const httpServer = http.createServer(async (req, res) => {
const allOk = Object.values(checks).every((c) => c.ok); const allOk = Object.values(checks).every((c) => c.ok);
res.writeHead(allOk ? 200 : 503, { "Content-Type": "application/json" }); res.writeHead(allOk ? 200 : 503, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: allOk ? "ok" : "degraded", checks }, null, 2)); res.end(
JSON.stringify({ status: allOk ? "ok" : "degraded", checks }, null, 2),
);
return; return;
} }
@ -87,7 +88,11 @@ wss.on("connection", (ws) => {
const RETRY_DELAY_MS = 6000; const RETRY_DELAY_MS = 6000;
const RETRYABLE_REASONS = ["Server is cycling mission"]; const RETRYABLE_REASONS = ["Server is cycling mission"];
async function connectToServer(ws: WebSocket, address: string, warriorName?: string): Promise<void> { async function connectToServer(
ws: WebSocket,
address: string,
warriorName?: string,
): Promise<void> {
if (gameConnection) { if (gameConnection) {
gameConnection.disconnect(); gameConnection.disconnect();
} }
@ -95,9 +100,7 @@ wss.on("connection", (ws) => {
gameConnection = new GameConnection(address, { warriorName }); gameConnection = new GameConnection(address, { warriorName });
// Set mapName from the cached server list if available. // Set mapName from the cached server list if available.
const cachedServer = cachedServers.find( const cachedServer = cachedServers.find((s) => s.address === address);
(s) => s.address === address,
);
if (cachedServer?.mapName) { if (cachedServer?.mapName) {
gameConnection.setMapName(cachedServer.mapName); gameConnection.setMapName(cachedServer.mapName);
} }
@ -123,7 +126,11 @@ wss.on("connection", (ws) => {
) { ) {
retryCount++; retryCount++;
relayLog.info( relayLog.info(
{ attempt: retryCount, maxRetries: MAX_RETRIES, delay: RETRY_DELAY_MS }, {
attempt: retryCount,
maxRetries: MAX_RETRIES,
delay: RETRY_DELAY_MS,
},
"Retryable disconnect — will reconnect", "Retryable disconnect — will reconnect",
); );
sendToClient(ws, { sendToClient(ws, {
@ -243,7 +250,10 @@ wss.on("connection", (ws) => {
} }
case "joinServer": { case "joinServer": {
relayLog.info({ address: message.address, warriorName: message.warriorName }, "Join server requested"); relayLog.info(
{ address: message.address, warriorName: message.warriorName },
"Join server requested",
);
if (gameConnection) { if (gameConnection) {
relayLog.info("Disconnecting existing game connection"); relayLog.info("Disconnecting existing game connection");
gameConnection.disconnect(); gameConnection.disconnect();
@ -285,10 +295,7 @@ wss.on("connection", (ws) => {
{ event: message.command }, { event: message.command },
"Forwarding auth event from browser", "Forwarding auth event from browser",
); );
gameConnection.handleAuthEvent( gameConnection.handleAuthEvent(message.command, message.args);
message.command,
message.args,
);
} else { } else {
relayLog.debug( relayLog.debug(
{ command: message.command }, { command: message.command },
@ -315,7 +322,10 @@ wss.on("connection", (ws) => {
case "sendCRCCompute": { case "sendCRCCompute": {
if (gameConnection) { if (gameConnection) {
relayLog.info( relayLog.info(
{ datablocks: message.datablocks.length, includeTextures: message.includeTextures }, {
datablocks: message.datablocks.length,
includeTextures: message.includeTextures,
},
"Computing CRC from game files", "Computing CRC from game files",
); );
gameConnection.computeAndSendCRC( gameConnection.computeAndSendCRC(

View file

@ -5,15 +5,32 @@ export type ClientMessage =
| { type: "disconnect" } | { type: "disconnect" }
| { type: "sendMove"; move: ClientMove } | { type: "sendMove"; move: ClientMove }
| { type: "sendCommand"; command: string; args: string[] } | { type: "sendCommand"; command: string; args: string[] }
| { type: "sendCRCResponse"; crcValue: number; field1: number; field2: number } | {
| { type: "sendCRCCompute"; seed: number; field2: number; includeTextures: boolean; datablocks: { objectId: number; className: string; shapeName: string }[] } type: "sendCRCResponse";
crcValue: number;
field1: number;
field2: number;
}
| {
type: "sendCRCCompute";
seed: number;
field2: number;
includeTextures: boolean;
datablocks: { objectId: number; className: string; shapeName: string }[];
}
| { type: "sendGhostAck"; sequence: number; ghostCount: number } | { type: "sendGhostAck"; sequence: number; ghostCount: number }
| { type: "wsPing"; ts: number }; | { type: "wsPing"; ts: number };
/** Messages from relay server to browser client. */ /** Messages from relay server to browser client. */
export type ServerMessage = export type ServerMessage =
| { type: "serverList"; servers: ServerInfo[] } | { type: "serverList"; servers: ServerInfo[] }
| { type: "status"; status: ConnectionStatus; message?: string; connectSequence?: number; mapName?: string } | {
type: "status";
status: ConnectionStatus;
message?: string;
connectSequence?: number;
mapName?: string;
}
| { type: "gamePacket"; data: Uint8Array } | { type: "gamePacket"; data: Uint8Array }
| { type: "ping"; ms: number } | { type: "ping"; ms: number }
| { type: "wsPong"; ts: number } | { type: "wsPong"; ts: number }

View file

@ -215,7 +215,9 @@ for (const rel of weaponModels) {
}); });
if (mpIdx === -1) { if (mpIdx === -1) {
console.log(`${name}: NO Mountpoint node. Nodes: [${nodeNames.join(", ")}]`); console.log(
`${name}: NO Mountpoint node. Nodes: [${nodeNames.join(", ")}]`,
);
continue; continue;
} }

View file

@ -111,10 +111,10 @@ function fmt(v: number[]): string {
async function main() { async function main() {
const playerBuf = await fs.readFile( const playerBuf = await fs.readFile(
"docs/base/@vl2/shapes.vl2/shapes/light_male.glb" "docs/base/@vl2/shapes.vl2/shapes/light_male.glb",
); );
const weaponBuf = await fs.readFile( const weaponBuf = await fs.readFile(
"docs/base/@vl2/shapes.vl2/shapes/weapon_disc.glb" "docs/base/@vl2/shapes.vl2/shapes/weapon_disc.glb",
); );
const playerDoc = parseGlb(playerBuf); const playerDoc = parseGlb(playerBuf);

View file

@ -26,7 +26,9 @@ async function run({
try { try {
await fs.stat(oggFile); await fs.stat(oggFile);
continue; // .ogg already exists, skip continue; // .ogg already exists, skip
} catch { /* expected */ } } catch {
/* expected */
}
} }
inputFiles.push(wavFile); inputFiles.push(wavFile);
} }

View file

@ -72,7 +72,7 @@ function printNodeTree(
doc: GltfDocument, doc: GltfDocument,
nodeIndex: number, nodeIndex: number,
depth: number, depth: number,
visited: Set<number> visited: Set<number>,
): void { ): void {
if (visited.has(nodeIndex)) return; if (visited.has(nodeIndex)) return;
visited.add(nodeIndex); visited.add(nodeIndex);
@ -101,12 +101,16 @@ function printNodeTree(
// Only show scale if non-identity // Only show scale if non-identity
const s = node.scale; const s = node.scale;
if (s && (s[0] !== 1 || s[1] !== 1 || s[2] !== 1)) { if (s && (s[0] !== 1 || s[1] !== 1 || s[2] !== 1)) {
console.log(`${indent} S: ${formatVec3(node.scale as [number, number, number])}`); console.log(
`${indent} S: ${formatVec3(node.scale as [number, number, number])}`,
);
} }
// Show matrix if present // Show matrix if present
if (node.matrix) { if (node.matrix) {
console.log(`${indent} Matrix: [${node.matrix.map((n) => n.toFixed(4)).join(", ")}]`); console.log(
`${indent} Matrix: [${node.matrix.map((n) => n.toFixed(4)).join(", ")}]`,
);
} }
if (node.children) { if (node.children) {
@ -130,18 +134,20 @@ async function inspectGlb(filePath: string): Promise<void> {
const skinCount = doc.skins?.length ?? 0; const skinCount = doc.skins?.length ?? 0;
const animCount = doc.animations?.length ?? 0; const animCount = doc.animations?.length ?? 0;
console.log(`Nodes: ${nodeCount}, Meshes: ${meshCount}, Skins: ${skinCount}, Animations: ${animCount}`); console.log(
`Nodes: ${nodeCount}, Meshes: ${meshCount}, Skins: ${skinCount}, Animations: ${animCount}`,
);
// Show skins (skeletons) // Show skins (skeletons)
if (doc.skins && doc.skins.length > 0) { if (doc.skins && doc.skins.length > 0) {
console.log(`\n--- Skins ---`); console.log(`\n--- Skins ---`);
for (let i = 0; i < doc.skins.length; i++) { for (let i = 0; i < doc.skins.length; i++) {
const skin = doc.skins[i]; const skin = doc.skins[i];
console.log(`Skin ${i}: "${skin.name ?? "(unnamed)"}" - ${skin.joints.length} joints`);
console.log(` Root skeleton node: ${skin.skeleton ?? "unset"}`);
console.log( console.log(
` Joint node indices: [${skin.joints.join(", ")}]` `Skin ${i}: "${skin.name ?? "(unnamed)"}" - ${skin.joints.length} joints`,
); );
console.log(` Root skeleton node: ${skin.skeleton ?? "unset"}`);
console.log(` Joint node indices: [${skin.joints.join(", ")}]`);
} }
} }
@ -150,7 +156,9 @@ async function inspectGlb(filePath: string): Promise<void> {
console.log(`\n--- Animations ---`); console.log(`\n--- Animations ---`);
for (let i = 0; i < doc.animations.length; i++) { for (let i = 0; i < doc.animations.length; i++) {
const anim = doc.animations[i]; const anim = doc.animations[i];
console.log(` [${i}] "${anim.name ?? "(unnamed)"}" (${anim.channels?.length ?? 0} channels)`); console.log(
` [${i}] "${anim.name ?? "(unnamed)"}" (${anim.channels?.length ?? 0} channels)`,
);
} }
} }
@ -178,7 +186,18 @@ async function inspectGlb(filePath: string): Promise<void> {
} }
// Highlight interesting nodes // Highlight interesting nodes
const keywords = ["eye", "mount", "hand", "cam", "head", "weapon", "muzzle", "node", "jet", "contrail"]; const keywords = [
"eye",
"mount",
"hand",
"cam",
"head",
"weapon",
"muzzle",
"node",
"jet",
"contrail",
];
const interesting: { index: number; name: string; node: GltfNode }[] = []; const interesting: { index: number; name: string; node: GltfNode }[] = [];
if (doc.nodes) { if (doc.nodes) {
for (let i = 0; i < doc.nodes.length; i++) { for (let i = 0; i < doc.nodes.length; i++) {
@ -191,7 +210,9 @@ async function inspectGlb(filePath: string): Promise<void> {
} }
if (interesting.length > 0) { if (interesting.length > 0) {
console.log(`\n--- Interesting Nodes (matching: ${keywords.join(", ")}) ---`); console.log(
`\n--- Interesting Nodes (matching: ${keywords.join(", ")}) ---`,
);
for (const { index, name, node } of interesting) { for (const { index, name, node } of interesting) {
console.log(` [${index}] "${name}"`); console.log(` [${index}] "${name}"`);
console.log(` Translation: ${formatVec3(node.translation)}`); console.log(` Translation: ${formatVec3(node.translation)}`);
@ -199,13 +220,17 @@ async function inspectGlb(filePath: string): Promise<void> {
console.log(` Rotation: ${formatQuat(node.rotation)}`); console.log(` Rotation: ${formatQuat(node.rotation)}`);
} }
if (node.scale) { if (node.scale) {
console.log(` Scale: ${formatVec3(node.scale as [number, number, number])}`); console.log(
` Scale: ${formatVec3(node.scale as [number, number, number])}`,
);
} }
// Find parent // Find parent
if (doc.nodes) { if (doc.nodes) {
for (let j = 0; j < doc.nodes.length; j++) { for (let j = 0; j < doc.nodes.length; j++) {
if (doc.nodes[j].children?.includes(index)) { if (doc.nodes[j].children?.includes(index)) {
console.log(` Parent: [${j}] "${doc.nodes[j].name || "(unnamed)"}"`); console.log(
` Parent: [${j}] "${doc.nodes[j].name || "(unnamed)"}"`,
);
break; break;
} }
} }
@ -218,7 +243,9 @@ async function inspectGlb(filePath: string): Promise<void> {
if (doc.nodes) { if (doc.nodes) {
for (let i = 0; i < doc.nodes.length; i++) { for (let i = 0; i < doc.nodes.length; i++) {
const node = doc.nodes[i]; const node = doc.nodes[i];
console.log(` [${i}] "${node.name || "(unnamed)"}" T:${formatVec3(node.translation)}`); console.log(
` [${i}] "${node.name || "(unnamed)"}" T:${formatVec3(node.translation)}`,
);
} }
} }
} }

View file

@ -38,7 +38,9 @@ if (!demoPath) {
console.error(); console.error();
console.error("Options:"); console.error("Options:");
console.error(" --no-headless Show the browser window"); console.error(" --no-headless Show the browser window");
console.error(" --wait, -w <s> Seconds to wait after loading (default: 10)"); console.error(
" --wait, -w <s> Seconds to wait after loading (default: 10)",
);
console.error(" --screenshot, -s Take a screenshot after loading"); console.error(" --screenshot, -s Take a screenshot after loading");
console.error(); console.error();
console.error("Examples:"); console.error("Examples:");

View file

@ -185,7 +185,9 @@ async function downloadAccount(
const trimmed = buffer.trim(); const trimmed = buffer.trim();
if (trimmed === "RECOVERERROR") { if (trimmed === "RECOVERERROR") {
reject(new Error("Auth server returned RECOVERERROR (malformed request)")); reject(
new Error("Auth server returned RECOVERERROR (malformed request)"),
);
return; return;
} }
if (trimmed === "NOTFOUND") { if (trimmed === "NOTFOUND") {
@ -202,7 +204,11 @@ async function downloadAccount(
// Line 2: EXP: <encrypted_private_exponent> // Line 2: EXP: <encrypted_private_exponent>
const lines = trimmed.split("\n"); const lines = trimmed.split("\n");
if (lines.length < 2) { if (lines.length < 2) {
reject(new Error(`Unexpected response from auth server: ${trimmed.slice(0, 200)}`)); reject(
new Error(
`Unexpected response from auth server: ${trimmed.slice(0, 200)}`,
),
);
return; return;
} }
@ -300,7 +306,9 @@ async function main() {
const existingLines = await readEnvLines(envFilePath); const existingLines = await readEnvLines(envFilePath);
const updatedLines = updateEnvLines( const updatedLines = updateEnvLines(
existingLines.length > 0 ? existingLines : ["# Generated by scripts/t2-login.ts"], existingLines.length > 0
? existingLines
: ["# Generated by scripts/t2-login.ts"],
{ {
T2_ACCOUNT_NAME: username, T2_ACCOUNT_NAME: username,
T2_ACCOUNT_PASSWORD: password, T2_ACCOUNT_PASSWORD: password,

View file

@ -9,8 +9,7 @@
import { queryServerList } from "../relay/masterQuery.js"; import { queryServerList } from "../relay/masterQuery.js";
const MASTER_SERVER = const MASTER_SERVER = process.env.T2_MASTER_SERVER || "master.tribesnext.com";
process.env.T2_MASTER_SERVER || "master.tribesnext.com";
async function main() { async function main() {
console.log(`Master server: ${MASTER_SERVER}`); console.log(`Master server: ${MASTER_SERVER}`);
@ -27,10 +26,7 @@ async function main() {
console.log(`Found ${servers.length} server(s):\n`); console.log(`Found ${servers.length} server(s):\n`);
// Print as a table // Print as a table
const nameWidth = Math.max( const nameWidth = Math.max(11, ...servers.map((s) => s.name.length));
11,
...servers.map((s) => s.name.length),
);
const header = [ const header = [
"Server Name".padEnd(nameWidth), "Server Name".padEnd(nameWidth),
"Map".padEnd(20), "Map".padEnd(20),

View file

@ -0,0 +1,67 @@
.AccordionGroup {
display: flex;
flex-direction: column;
gap: 1px;
}
.Trigger {
display: flex;
align-items: center;
width: 100%;
background: rgb(255, 255, 255, 0.1);
padding: 6px 8px;
color: #fff;
font-family: inherit;
font-size: 12px;
font-weight: normal;
text-align: left;
border: 0;
text-transform: uppercase;
letter-spacing: 0.0417em;
gap: 4px;
}
.TriggerIcon {
font-size: 12px;
transition: transform 0.2s;
transform: rotate(0deg);
opacity: 0.5;
}
.Trigger[data-state="open"] .TriggerIcon {
transform: rotate(90deg);
}
.Content {
overflow: hidden;
}
.Content[data-state="open"] {
animation: slideDown 300ms;
}
.Content[data-state="closed"] {
animation: slideUp 300ms;
}
.Body {
padding: 10px;
}
@keyframes slideDown {
from {
height: 0;
}
to {
height: var(--radix-accordion-content-height);
}
}
@keyframes slideUp {
from {
height: var(--radix-accordion-content-height);
}
to {
height: 0;
}
}

View file

@ -0,0 +1,29 @@
import * as RadixAccordion from "@radix-ui/react-accordion";
import { ReactNode } from "react";
import { IoCaretForward } from "react-icons/io5";
import styles from "./Accordion.module.css";
export function AccordionGroup(props) {
return <RadixAccordion.Root className={styles.AccordionGroup} {...props} />;
}
export function Accordion({
value,
label,
children,
}: {
value: string;
label: ReactNode;
children: ReactNode;
}) {
return (
<RadixAccordion.Item value={value}>
<RadixAccordion.Trigger className={styles.Trigger}>
<IoCaretForward className={styles.TriggerIcon} /> {label}
</RadixAccordion.Trigger>
<RadixAccordion.Content className={styles.Content}>
<div className={styles.Body}>{children}</div>
</RadixAccordion.Content>
</RadixAccordion.Item>
);
}

View file

@ -7,7 +7,8 @@ import {
} from "react"; } from "react";
import { useThree } from "@react-three/fiber"; import { useThree } from "@react-three/fiber";
import { AudioListener, AudioLoader } from "three"; import { AudioListener, AudioLoader } from "three";
import { engineStore } from "../state"; import { engineStore } from "../state/engineStore";
import { useSettings } from "./SettingsProvider";
interface AudioContextType { interface AudioContextType {
audioLoader: AudioLoader | null; audioLoader: AudioLoader | null;
@ -21,7 +22,8 @@ const AudioContext = createContext<AudioContextType | undefined>(undefined);
* Must be rendered inside the Canvas component. * Must be rendered inside the Canvas component.
*/ */
export function AudioProvider({ children }: { children: ReactNode }) { export function AudioProvider({ children }: { children: ReactNode }) {
const { camera } = useThree(); const camera = useThree((state) => state.camera);
const { audioVolume } = useSettings();
const [audioContext, setAudioContext] = useState<AudioContextType>({ const [audioContext, setAudioContext] = useState<AudioContextType>({
audioLoader: null, audioLoader: null,
audioListener: null, audioListener: null,
@ -41,8 +43,6 @@ export function AudioProvider({ children }: { children: ReactNode }) {
camera.add(listener); camera.add(listener);
} }
listener.setMasterVolume(0.8);
setAudioContext({ setAudioContext({
audioLoader, audioLoader,
audioListener: listener, audioListener: listener,
@ -51,7 +51,7 @@ export function AudioProvider({ children }: { children: ReactNode }) {
// Resume the AudioContext on user interaction to satisfy browser autoplay // Resume the AudioContext on user interaction to satisfy browser autoplay
// policy. Without this, sounds won't play until the user clicks/taps. // policy. Without this, sounds won't play until the user clicks/taps.
const resumeOnGesture = () => { const resumeOnGesture = () => {
const ctx = listener?.context; const ctx = listener.context;
if (!ctx || ctx.state !== "suspended") return; if (!ctx || ctx.state !== "suspended") return;
ctx.resume().finally(() => { ctx.resume().finally(() => {
document.removeEventListener("click", resumeOnGesture); document.removeEventListener("click", resumeOnGesture);
@ -67,7 +67,7 @@ export function AudioProvider({ children }: { children: ReactNode }) {
const unsubscribe = engineStore.subscribe( const unsubscribe = engineStore.subscribe(
(state) => state.playback.status, (state) => state.playback.status,
(status) => { (status) => {
const ctx = listener?.context; const ctx = listener.context;
if (!ctx) return; if (!ctx) return;
if (status === "paused") { if (status === "paused") {
ctx.suspend(); ctx.suspend();
@ -85,6 +85,10 @@ export function AudioProvider({ children }: { children: ReactNode }) {
}; };
}, [camera]); }, [camera]);
useEffect(() => {
audioContext.audioListener?.setMasterVolume(audioVolume);
}, [audioVolume, audioContext.audioListener]);
return ( return (
<AudioContext.Provider value={audioContext}> <AudioContext.Provider value={audioContext}>
{children} {children}

View file

@ -8,11 +8,15 @@ import {
PositionalAudio, PositionalAudio,
Vector3, Vector3,
} from "three"; } from "three";
import { createLogger } from "../logger";
import { audioToUrl } from "../loaders"; import { audioToUrl } from "../loaders";
import { useAudio } from "./AudioContext"; import { useAudio } from "./AudioContext";
import { useDebug, useSettings } from "./SettingsProvider"; import { useDebug, useSettings } from "./SettingsProvider";
import { FloatingLabel } from "./FloatingLabel"; import { FloatingLabel } from "./FloatingLabel";
import { engineStore } from "../state"; import { engineStore } from "../state/engineStore";
import { AudioEmitterEntity } from "../state/gameEntityTypes";
const log = createLogger("AudioEmitter");
// Global audio buffer cache shared across all audio components. // Global audio buffer cache shared across all audio components.
export const audioBufferCache = new Map<string, AudioBuffer>(); export const audioBufferCache = new Map<string, AudioBuffer>();
@ -50,8 +54,16 @@ export function getSoundGeneration(): number {
export function stopAllTrackedSounds(): void { export function stopAllTrackedSounds(): void {
_soundGeneration++; _soundGeneration++;
for (const [sound] of _activeSounds) { for (const [sound] of _activeSounds) {
try { sound.stop(); } catch { /* already stopped */ } try {
try { sound.disconnect(); } catch { /* already disconnected */ } sound.stop();
} catch {
/* already stopped */
}
try {
sound.disconnect();
} catch {
/* already disconnected */
}
} }
_activeSounds.clear(); _activeSounds.clear();
} }
@ -148,7 +160,11 @@ export function playOneShotSound(
sound.play(); sound.play();
sound.source!.onended = () => { sound.source!.onended = () => {
_activeSounds.delete(sound); _activeSounds.delete(sound);
try { sound.disconnect(); } catch { /* already disconnected */ } try {
sound.disconnect();
} catch {
/* already disconnected */
}
parent.remove(sound); parent.remove(sound);
}; };
} else { } else {
@ -160,7 +176,11 @@ export function playOneShotSound(
sound.play(); sound.play();
sound.source!.onended = () => { sound.source!.onended = () => {
_activeSounds.delete(sound); _activeSounds.delete(sound);
try { sound.disconnect(); } catch { /* already disconnected */ } try {
sound.disconnect();
} catch {
/* already disconnected */
}
}; };
} }
} catch { } catch {
@ -185,7 +205,7 @@ export function getCachedAudioBuffer(
}, },
undefined, undefined,
(err: any) => { (err: any) => {
console.error("Audio load error", audioUrl, err); log.error("Audio load error %s: %o", audioUrl, err);
}, },
); );
} }
@ -194,17 +214,7 @@ export function getCachedAudioBuffer(
export const AudioEmitter = memo(function AudioEmitter({ export const AudioEmitter = memo(function AudioEmitter({
entity, entity,
}: { }: {
entity: { entity: AudioEmitterEntity;
audioFileName?: string;
audioVolume?: number;
audioMinDistance?: number;
audioMaxDistance?: number;
audioMinLoopGap?: number;
audioMaxLoopGap?: number;
audioIs3D?: boolean;
audioIsLooping?: boolean;
position?: [number, number, number];
};
}) { }) {
const { debugMode } = useDebug(); const { debugMode } = useDebug();
const fileName = entity.audioFileName ?? ""; const fileName = entity.audioFileName ?? "";
@ -217,7 +227,8 @@ export const AudioEmitter = memo(function AudioEmitter({
const isLooping = entity.audioIsLooping ?? true; const isLooping = entity.audioIsLooping ?? true;
const [x, y, z] = entity.position ?? [0, 0, 0]; const [x, y, z] = entity.position ?? [0, 0, 0];
const { scene, camera } = useThree(); const scene = useThree((state) => state.scene);
const camera = useThree((state) => state.camera);
const { audioLoader, audioListener } = useAudio(); const { audioLoader, audioListener } = useAudio();
const { audioEnabled } = useSettings(); const { audioEnabled } = useSettings();
@ -268,8 +279,16 @@ export const AudioEmitter = memo(function AudioEmitter({
return () => { return () => {
clearTimers(); clearTimers();
try { sound.stop(); } catch { /* already stopped */ } try {
try { sound.disconnect(); } catch { /* already disconnected */ } sound.stop();
} catch {
/* already stopped */
}
try {
sound.disconnect();
} catch {
/* already disconnected */
}
if (is3D) scene.remove(sound); if (is3D) scene.remove(sound);
soundRef.current = null; soundRef.current = null;
isLoadedRef.current = false; isLoadedRef.current = false;
@ -306,7 +325,9 @@ export const AudioEmitter = memo(function AudioEmitter({
try { try {
sound.play(); sound.play();
setupLooping(sound, gen); setupLooping(sound, gen);
} catch { /* expected */ } } catch {
/* expected */
}
}, gap); }, gap);
} else { } else {
loopGapIntervalRef.current = setTimeout(checkLoop, 100); loopGapIntervalRef.current = setTimeout(checkLoop, 100);
@ -337,7 +358,9 @@ export const AudioEmitter = memo(function AudioEmitter({
try { try {
sound.play(); sound.play();
setupLooping(sound, gen); setupLooping(sound, gen);
} catch { /* expected */ } } catch {
/* expected */
}
} }
}); });
} else { } else {
@ -346,7 +369,9 @@ export const AudioEmitter = memo(function AudioEmitter({
sound.play(); sound.play();
setupLooping(sound, gen); setupLooping(sound, gen);
} }
} catch { /* expected */ } } catch {
/* expected */
}
} }
}; };
@ -373,7 +398,11 @@ export const AudioEmitter = memo(function AudioEmitter({
} else if (!isNowInRange && wasInRange) { } else if (!isNowInRange && wasInRange) {
isInRangeRef.current = false; isInRangeRef.current = false;
clearTimers(); clearTimers();
try { sound.stop(); } catch { /* expected */ } try {
sound.stop();
} catch {
/* expected */
}
} }
}); });
@ -384,7 +413,11 @@ export const AudioEmitter = memo(function AudioEmitter({
if (!audioEnabled) { if (!audioEnabled) {
clearTimers(); clearTimers();
try { sound.stop(); } catch { /* expected */ } try {
sound.stop();
} catch {
/* expected */
}
isInRangeRef.current = false; isInRangeRef.current = false;
} }
}, [audioEnabled]); }, [audioEnabled]);

View file

@ -0,0 +1,7 @@
import { ReactNode, Suspense } from "react";
import { useSettings } from "./SettingsProvider";
export function AudioEnabled({ children }: { children: ReactNode }) {
const { audioEnabled } = useSettings();
return audioEnabled ? <Suspense>{children}</Suspense> : null;
}

View file

@ -9,17 +9,12 @@ export function Camera({ entity }: { entity: CameraEntity }) {
const dataBlock = entity.cameraDataBlock; const dataBlock = entity.cameraDataBlock;
const position = useMemo( const position = useMemo(
() => () => (entity.position ? new Vector3(...entity.position) : new Vector3()),
entity.position
? new Vector3(...entity.position)
: new Vector3(),
[entity.position], [entity.position],
); );
const rotation = useMemo( const rotation = useMemo(
() => () =>
entity.rotation entity.rotation ? new Quaternion(...entity.rotation) : new Quaternion(),
? new Quaternion(...entity.rotation)
: new Quaternion(),
[entity.rotation], [entity.rotation],
); );

View file

@ -35,7 +35,7 @@ export function useCameras() {
} }
export function CamerasProvider({ children }: { children: ReactNode }) { export function CamerasProvider({ children }: { children: ReactNode }) {
const { camera } = useThree(); const camera = useThree((state) => state.camera);
const [cameraIndex, setCameraIndex] = useState(-1); const [cameraIndex, setCameraIndex] = useState(-1);
const [cameraMap, setCameraMap] = useState<Record<string, CameraEntry>>({}); const [cameraMap, setCameraMap] = useState<Record<string, CameraEntry>>({});
const [initialViewState, setInitialViewState] = useState(() => ({ const [initialViewState, setInitialViewState] = useState(() => ({
@ -53,7 +53,8 @@ export function CamerasProvider({ children }: { children: ReactNode }) {
const unregisterCamera = useCallback((camera: CameraEntry) => { const unregisterCamera = useCallback((camera: CameraEntry) => {
setCameraMap((prevCameraMap) => { setCameraMap((prevCameraMap) => {
const { [camera.id]: _removedCamera, ...remainingCameras } = prevCameraMap; const { [camera.id]: _removedCamera, ...remainingCameras } =
prevCameraMap;
return remainingCameras; return remainingCameras;
}); });
}, []); }, []);

View file

@ -0,0 +1,26 @@
.InputForm {
display: flex;
}
.Input {
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;
line-height: 1.25;
margin: 0;
padding: 6px;
outline: none;
}
.Input::placeholder {
color: rgba(44, 172, 181, 0.5);
}
.Input:focus {
background: rgba(0, 50, 60, 0.9);
}

View file

@ -0,0 +1,33 @@
import { useCallback, useState } from "react";
import { liveConnectionStore } from "../state/liveConnectionStore";
import styles from "./ChatInput.module.css";
export function ChatInput() {
const [chatText, setChatText] = useState("");
const handleSubmit = useCallback(
(e: React.FormEvent) => {
e.preventDefault();
const text = chatText.trim();
if (!text) return;
liveConnectionStore.getState().sendCommand("messageSent", text);
setChatText("");
},
[chatText],
);
return (
<form className={styles.InputForm} onSubmit={handleSubmit}>
<input
className={styles.Input}
type="text"
placeholder="Say something…"
value={chatText}
onChange={(e) => setChatText(e.target.value)}
onKeyDown={(e) => e.stopPropagation()}
onKeyUp={(e) => e.stopPropagation()}
maxLength={255}
/>
</form>
);
}

View file

@ -2,9 +2,14 @@ import { useEffect, useRef } from "react";
import { Audio } from "three"; import { Audio } from "three";
import { audioToUrl } from "../loaders"; import { audioToUrl } from "../loaders";
import { useAudio } from "./AudioContext"; import { useAudio } from "./AudioContext";
import { getCachedAudioBuffer, getSoundGeneration, trackSound, untrackSound } from "./AudioEmitter"; import {
getCachedAudioBuffer,
getSoundGeneration,
trackSound,
untrackSound,
} from "./AudioEmitter";
import { useSettings } from "./SettingsProvider"; import { useSettings } from "./SettingsProvider";
import { engineStore, useEngineSelector } from "../state"; import { engineStore, useEngineSelector } from "../state/engineStore";
import type { ChatMessage } from "../stream/types"; import type { ChatMessage } from "../stream/types";
/** /**
@ -13,8 +18,7 @@ import type { ChatMessage } from "../stream/types";
*/ */
export function ChatSoundPlayer() { export function ChatSoundPlayer() {
const { audioLoader, audioListener } = useAudio(); const { audioLoader, audioListener } = useAudio();
const settings = useSettings(); const { audioEnabled } = useSettings();
const audioEnabled = settings?.audioEnabled ?? false;
const messages = useEngineSelector( const messages = useEngineSelector(
(state) => state.playback.streamSnapshot?.chatMessages, (state) => state.playback.streamSnapshot?.chatMessages,
); );
@ -24,9 +28,7 @@ export function ChatSoundPlayer() {
const playedSetRef = useRef(new WeakSet<ChatMessage>()); const playedSetRef = useRef(new WeakSet<ChatMessage>());
// Track active voice chat sound per sender so a new voice bind from the // Track active voice chat sound per sender so a new voice bind from the
// same player stops their previous one (matching Tribes 2 behavior). // same player stops their previous one (matching Tribes 2 behavior).
const activeBySenderRef = useRef( const activeBySenderRef = useRef(new Map<string, Audio<GainNode>>());
new Map<string, Audio<GainNode>>(),
);
useEffect(() => { useEffect(() => {
if ( if (
@ -58,9 +60,17 @@ export function ChatSoundPlayer() {
if (sender) { if (sender) {
const prev = activeBySender.get(sender); const prev = activeBySender.get(sender);
if (prev) { if (prev) {
try { prev.stop(); } catch { /* already stopped */ } try {
prev.stop();
} catch {
/* already stopped */
}
untrackSound(prev); untrackSound(prev);
try { prev.disconnect(); } catch { /* already disconnected */ } try {
prev.disconnect();
} catch {
/* already disconnected */
}
activeBySender.delete(sender); activeBySender.delete(sender);
} }
} }
@ -75,7 +85,11 @@ export function ChatSoundPlayer() {
// Clean up the source node once playback finishes. // Clean up the source node once playback finishes.
sound.source!.onended = () => { sound.source!.onended = () => {
untrackSound(sound); untrackSound(sound);
try { sound.disconnect(); } catch { /* already disconnected */ } try {
sound.disconnect();
} catch {
/* already disconnected */
}
if (sender && activeBySender.get(sender) === sound) { if (sender && activeBySender.get(sender) === sound) {
activeBySender.delete(sender); activeBySender.delete(sender);
} }

View file

@ -0,0 +1,63 @@
.ChatContainer {
position: absolute;
top: 8px;
left: 8px;
width: 400px;
max-width: 50%;
display: flex;
flex-direction: column;
pointer-events: auto;
border: 1px solid rgba(44, 172, 181, 0.4);
}
.ChatWindow {
max-height: 12.5em;
min-height: 4em;
overflow-y: auto;
background: rgba(0, 50, 60, 0.65);
padding: 6px;
user-select: text;
font-size: 12px;
line-height: 1.25;
/* Thin scrollbar that doesn't take much space. */
scrollbar-width: thin;
scrollbar-color: rgba(44, 172, 181, 0.4) transparent;
}
.ChatMessage {
/* Default to \c0 (GuiChatHudProfile fontColor) for untagged messages. */
color: rgb(44, 172, 181);
padding: 2px 0;
}
/* 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);
}

View file

@ -0,0 +1,84 @@
import { lazy, memo, Suspense, useEffect, useRef } from "react";
import { useEngineSelector } from "../state/engineStore";
import { ChatMessage, ChatSegment } from "../stream/types";
import styles from "./ChatWindow.module.css";
const ChatInput = lazy(() =>
import("./ChatInput").then((mod) => ({ default: mod.ChatInput })),
);
const EMPTY_MESSAGES: ChatMessage[] = [];
/** Map a colorCode to a CSS module class name (c0c9 GuiChatHudProfile). */
const CHAT_COLOR_CLASSES: Record<number, string> = {
0: styles.ChatColor0,
1: styles.ChatColor1,
2: styles.ChatColor2,
3: styles.ChatColor3,
4: styles.ChatColor4,
5: styles.ChatColor5,
6: styles.ChatColor6,
7: styles.ChatColor7,
8: styles.ChatColor8,
9: styles.ChatColor9,
};
function segmentColorClass(colorCode: number): string {
return CHAT_COLOR_CLASSES[colorCode] ?? CHAT_COLOR_CLASSES[0];
}
function chatColorClass(msg: ChatMessage): string {
if (msg.colorCode != null && CHAT_COLOR_CLASSES[msg.colorCode]) {
return CHAT_COLOR_CLASSES[msg.colorCode];
}
// Fallback: default to \c0 (teal). Messages with detected codes (like \c2
// for flag events) will match above; \c0 kill messages may lose their null
// byte color code, so the correct default for server messages is c0.
return CHAT_COLOR_CLASSES[0];
}
export const ChatWindow = memo(function ChatWindow() {
const isLive = useEngineSelector(
(state) => state.playback.recording?.source === "live",
);
const messages = useEngineSelector(
(state) => state.playback.streamSnapshot?.chatMessages ?? EMPTY_MESSAGES,
);
const scrollRef = useRef<HTMLDivElement>(null);
const lastMessageId = messages[messages.length - 1]?.id;
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [lastMessageId]);
return (
<div className={styles.ChatContainer}>
<div ref={scrollRef} className={styles.ChatWindow}>
{messages.map((msg: ChatMessage) => (
<div key={msg.id} className={styles.ChatMessage} hidden={!msg.text}>
{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>
)}
</div>
))}
</div>
{isLive && (
<Suspense>
<ChatInput />
</Suspense>
)}
</div>
);
});

View file

@ -471,16 +471,19 @@ export function CloudLayers({ scene }: CloudLayersProps) {
const { data: detailMapList } = useDetailMapList(materialList); const { data: detailMapList } = useDetailMapList(materialList);
// From Tribes 2 sky.cc line 1170: mRadius = visibleDistance * 0.95 // From Tribes 2 sky.cc line 1170: mRadius = visibleDistance * 0.95
const visibleDistance = scene.visibleDistance > 0 ? scene.visibleDistance : 500; const visibleDistance =
scene.visibleDistance > 0 ? scene.visibleDistance : 500;
const radius = visibleDistance * 0.95; const radius = visibleDistance * 0.95;
const cloudSpeeds = useMemo( const cloudSpeeds = useMemo(
() => scene.cloudLayers.map((l, i) => l.speed || [0.0001, 0.0002, 0.0003][i]), () =>
scene.cloudLayers.map((l, i) => l.speed || [0.0001, 0.0002, 0.0003][i]),
[scene.cloudLayers], [scene.cloudLayers],
); );
const cloudHeights = useMemo( const cloudHeights = useMemo(
() => scene.cloudLayers.map((l, i) => l.heightPercent || [0.35, 0.25, 0.2][i]), () =>
scene.cloudLayers.map((l, i) => l.heightPercent || [0.35, 0.25, 0.2][i]),
[scene.cloudLayers], [scene.cloudLayers],
); );
@ -536,7 +539,7 @@ export function CloudLayers({ scene }: CloudLayersProps) {
{layers.map((layer, i) => { {layers.map((layer, i) => {
const url = textureToUrl(layer.texture); const url = textureToUrl(layer.texture);
return ( return (
<Suspense key={i} fallback={null}> <Suspense key={i}>
<CloudLayer <CloudLayer
textureUrl={url} textureUrl={url}
radius={radius} radius={radius}

View file

@ -22,10 +22,12 @@ export function CopyCoordinatesButton({
cameraRef, cameraRef,
missionName, missionName,
missionType, missionType,
disabled,
}: { }: {
cameraRef: RefObject<Camera | null>; cameraRef: RefObject<Camera | null>;
missionName: string; missionName: string;
missionType: string; missionType: string;
disabled?: boolean;
}) { }) {
const { fogEnabled } = useSettings(); const { fogEnabled } = useSettings();
const [showCopied, setShowCopied] = useState(false); const [showCopied, setShowCopied] = useState(false);
@ -57,15 +59,16 @@ export function CopyCoordinatesButton({
<button <button
type="button" type="button"
className={styles.Root} className={styles.Root}
aria-label="Copy coordinates URL" aria-label="Link to coordinates"
title="Copy coordinates URL" title="Copy the current coordinates to URL"
onClick={handleCopyLink} onClick={handleCopyLink}
disabled={disabled}
data-copied={showCopied ? "true" : "false"} data-copied={showCopied ? "true" : "false"}
id="copyCoordinatesButton" id="copyCoordinatesButton"
> >
<FaMapPin className={styles.MapPin} /> <FaMapPin className={styles.MapPin} />
<FaClipboardCheck className={styles.ClipboardCheck} /> <FaClipboardCheck className={styles.ClipboardCheck} />
<span className={styles.ButtonLabel}> Copy coordinates URL</span> <span className={styles.ButtonLabel}> Link to coordinates</span>
</button> </button>
); );
} }

View file

@ -1,11 +1,9 @@
import { Stats, Html } from "@react-three/drei"; import { Stats, Html } from "@react-three/drei";
import { useDebug } from "./SettingsProvider";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { AxesHelper } from "three"; import { AxesHelper } from "three";
import styles from "./DebugElements.module.css"; import styles from "./DebugElements.module.css";
export function DebugElements() { export function DebugElements() {
const { debugMode } = useDebug();
const axesRef = useRef<AxesHelper>(null); const axesRef = useRef<AxesHelper>(null);
useEffect(() => { useEffect(() => {
@ -16,7 +14,7 @@ export function DebugElements() {
axes.setColors("rgb(153, 255, 0)", "rgb(0, 153, 255)", "rgb(255, 153, 0)"); axes.setColors("rgb(153, 255, 0)", "rgb(0, 153, 255)", "rgb(255, 153, 0)");
}); });
return debugMode ? ( return (
<> <>
<Stats className={styles.StatsPanel} /> <Stats className={styles.StatsPanel} />
<axesHelper ref={axesRef} args={[70]} renderOrder={999}> <axesHelper ref={axesRef} args={[70]} renderOrder={999}>
@ -43,5 +41,5 @@ export function DebugElements() {
</span> </span>
</Html> </Html>
</> </>
) : null; );
} }

View file

@ -0,0 +1,8 @@
import { ReactNode, Suspense } from "react";
import { useDebug } from "./SettingsProvider";
export function DebugEnabled({ children }: { children: ReactNode }) {
const { debugMode } = useDebug();
return debugMode ? <Suspense>{children}</Suspense> : null;
}

View file

@ -0,0 +1,50 @@
import { Suspense, useEffect, type ReactNode } from "react";
import { createLogger } from "../logger";
const log = createLogger("DebugSuspense");
/**
* Suspense wrapper that logs when a component suspends and resolves.
* Use in place of `<Suspense>` during debugging to track async loading.
*/
export function DebugSuspense({
name,
fallback = null,
children,
}: {
name: string;
fallback?: ReactNode;
children: ReactNode;
}) {
return (
<Suspense
name={name}
fallback={
<DebugSuspenseFallback name={name}>{fallback}</DebugSuspenseFallback>
}
>
<DebugSuspenseResolved name={name} />
{children}
</Suspense>
);
}
function DebugSuspenseFallback({
name,
children,
}: {
name: string;
children: ReactNode;
}) {
useEffect(() => {
log.debug("🛑 SUSPENDED: %s", name);
}, [name]);
return children;
}
function DebugSuspenseResolved({ name }: { name: string }) {
useEffect(() => {
log.debug("✅ RESOLVED: %s", name);
}, [name]);
return null;
}

View file

@ -1,14 +1,8 @@
.Root { .Root {
position: fixed;
bottom: 0;
left: 0;
right: 0;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
padding: 8px 12px; padding: 8px 12px;
background: rgba(0, 0, 0, 0.7);
color: #fff;
font-size: 13px; font-size: 13px;
z-index: 2; z-index: 2;
} }

View file

@ -78,6 +78,7 @@ export function DemoPlaybackControls() {
className={styles.PlayPause} className={styles.PlayPause}
onClick={isPlaying ? pause : play} onClick={isPlaying ? pause : play}
aria-label={isPlaying ? "Pause" : "Play"} aria-label={isPlaying ? "Pause" : "Play"}
autoFocus
> >
{isPlaying ? "\u275A\u275A" : "\u25B6"} {isPlaying ? "\u275A\u275A" : "\u25B6"}
</button> </button>

View file

@ -1,20 +1,15 @@
import { lazy, memo, Suspense, useRef } from "react"; import { lazy, memo, useRef } from "react";
import { useFrame } from "@react-three/fiber"; import { useFrame } from "@react-three/fiber";
import type { Group } from "three"; import type { Group } from "three";
import type { import type {
GameEntity, GameEntity,
ShapeEntity as ShapeEntityType, ShapeEntity as ShapeEntityType,
ForceFieldBareEntity as ForceFieldBareEntityType,
PlayerEntity as PlayerEntityType,
ExplosionEntity as ExplosionEntityType,
TracerEntity as TracerEntityType,
SpriteEntity as SpriteEntityType,
AudioEmitterEntity as AudioEmitterEntityType,
} from "../state/gameEntityTypes"; } from "../state/gameEntityTypes";
import { streamPlaybackStore } from "../state/streamPlaybackStore"; import { ShapeRenderer, ShapePlaceholder } from "./GenericShape";
import { ShapeRenderer } from "./GenericShape";
import { ShapeInfoProvider } from "./ShapeInfoProvider"; import { ShapeInfoProvider } from "./ShapeInfoProvider";
import type { StaticShapeType } from "./ShapeInfoProvider"; import type { StaticShapeType } from "./ShapeInfoProvider";
import { DebugSuspense } from "./DebugSuspense";
import { ShapeErrorBoundary } from "./ShapeErrorBoundary";
import { FloatingLabel } from "./FloatingLabel"; import { FloatingLabel } from "./FloatingLabel";
import { useSettings } from "./SettingsProvider"; import { useSettings } from "./SettingsProvider";
import { Camera } from "./Camera"; import { Camera } from "./Camera";
@ -22,44 +17,51 @@ import { WayPoint } from "./WayPoint";
import { TerrainBlock } from "./TerrainBlock"; import { TerrainBlock } from "./TerrainBlock";
import { InteriorInstance } from "./InteriorInstance"; import { InteriorInstance } from "./InteriorInstance";
import { Sky } from "./Sky"; import { Sky } from "./Sky";
import { AudioEnabled } from "./AudioEnabled";
import type { TorqueObject } from "../torqueScript"; import type { TorqueObject } from "../torqueScript";
// Lazy-loaded heavy renderers function createLazy<K extends string>(
const PlayerModel = lazy(() => name: K,
import("./PlayerModel").then((mod) => ({ default: mod.PlayerModel })), loader: () => Promise<Record<K, React.ComponentType<any>>>,
): React.ComponentType<{ entity: GameEntity }> {
const LazyComponent = lazy(() =>
loader().then((mod) => {
const NamedComponent = mod[name];
return { default: NamedComponent };
}),
); );
const LazyComponentWithSuspense = ({ entity }: { entity: GameEntity }) => {
return (
<DebugSuspense name={`${name}:${entity.id}`}>
<LazyComponent entity={entity} />
</DebugSuspense>
);
};
const ExplosionShape = lazy(() => LazyComponentWithSuspense.displayName = `createLazy(${name})`;
import("./ShapeModel").then((mod) => ({ return LazyComponentWithSuspense;
default: mod.ExplosionShape, }
})),
);
const TracerProjectile = lazy(() => const PlayerModel = createLazy("PlayerModel", () => import("./PlayerModel"));
import("./Projectiles").then((mod) => ({ const ExplosionShape = createLazy(
default: mod.TracerProjectile, "ExplosionShape",
})), () => import("./ShapeModel"),
); );
const TracerProjectile = createLazy(
const SpriteProjectile = lazy(() => "TracerProjectile",
import("./Projectiles").then((mod) => ({ () => import("./Projectiles"),
default: mod.SpriteProjectile,
})),
); );
const SpriteProjectile = createLazy(
const ForceFieldBareRenderer = lazy(() => "SpriteProjectile",
import("./ForceFieldBare").then((mod) => ({ () => import("./Projectiles"),
default: mod.ForceFieldBare,
})),
); );
const ForceFieldBare = createLazy(
const AudioEmitter = lazy(() => "ForceFieldBare",
import("./AudioEmitter").then((mod) => ({ default: mod.AudioEmitter })), () => import("./ForceFieldBare"),
);
const WaterBlock = lazy(() =>
import("./WaterBlock").then((mod) => ({ default: mod.WaterBlock })),
); );
const AudioEmitter = createLazy("AudioEmitter", () => import("./AudioEmitter"));
const WaterBlock = createLazy("WaterBlock", () => import("./WaterBlock"));
const WeaponModel = createLazy("WeaponModel", () => import("./ShapeModel"));
const TEAM_NAMES: Record<number, string> = { const TEAM_NAMES: Record<number, string> = {
1: "Storm", 1: "Storm",
@ -81,17 +83,21 @@ export const EntityRenderer = memo(function EntityRenderer({
case "Shape": case "Shape":
return <ShapeEntity entity={entity} />; return <ShapeEntity entity={entity} />;
case "ForceFieldBare": case "ForceFieldBare":
return <ForceFieldBareEntity entity={entity} />; return <ForceFieldBare entity={entity} />;
case "Player": case "Player":
return <PlayerEntity entity={entity} />; return <PlayerModel entity={entity} />;
case "Explosion": case "Explosion":
return <ExplosionEntity entity={entity} />; return <ExplosionShape entity={entity} />;
case "Tracer": case "Tracer":
return <TracerEntity entity={entity} />; return <TracerProjectile entity={entity} />;
case "Sprite": case "Sprite":
return <SpriteEntity entity={entity} />; return <SpriteProjectile entity={entity} />;
case "AudioEmitter": case "AudioEmitter":
return <AudioEntity entity={entity} />; return (
<AudioEnabled>
<AudioEmitter entity={entity} />
</AudioEnabled>
);
case "Camera": case "Camera":
return <Camera entity={entity} />; return <Camera entity={entity} />;
case "WayPoint": case "WayPoint":
@ -101,25 +107,21 @@ export const EntityRenderer = memo(function EntityRenderer({
case "InteriorInstance": case "InteriorInstance":
return <InteriorInstance scene={entity.interiorData} />; return <InteriorInstance scene={entity.interiorData} />;
case "Sky": case "Sky":
return <Sky scene={entity.skyData} />; return <Sky entity={entity} />;
case "Sun": case "Sun":
// Sun lighting is handled by SceneLighting (rendered outside EntityScene) // Sun lighting is handled by SceneLighting (rendered outside EntityScene)
return null; return null;
case "WaterBlock": case "WaterBlock":
return ( return <WaterBlock entity={entity} />;
<Suspense fallback={null}>
<WaterBlock scene={entity.waterData} />
</Suspense>
);
case "MissionArea": case "MissionArea":
return null; return null;
case "None": case "None":
return null; return null;
default:
return null;
} }
}); });
// ── Shape Entity ──
function ShapeEntity({ entity }: { entity: ShapeEntityType }) { function ShapeEntity({ entity }: { entity: ShapeEntityType }) {
const { animationEnabled } = useSettings(); const { animationEnabled } = useSettings();
const groupRef = useRef<Group>(null); const groupRef = useRef<Group>(null);
@ -131,7 +133,9 @@ function ShapeEntity({ entity }: { entity: ShapeEntityType }) {
groupRef.current.rotation.y = (t / 3.0) * Math.PI * 2; groupRef.current.rotation.y = (t / 3.0) * Math.PI * 2;
}); });
if (!entity.shapeName) return null; if (!entity.shapeName) {
throw new Error(`Shape entity missing shapeName: ${entity.id}`);
}
const torqueObject = entity.runtimeObject as TorqueObject | undefined; const torqueObject = entity.runtimeObject as TorqueObject | undefined;
const shapeType = (entity.shapeType ?? "StaticShape") as StaticShapeType; const shapeType = (entity.shapeType ?? "StaticShape") as StaticShapeType;
@ -156,7 +160,10 @@ function ShapeEntity({ entity }: { entity: ShapeEntityType }) {
type={shapeType} type={shapeType}
> >
<group ref={entity.rotate ? groupRef : undefined}> <group ref={entity.rotate ? groupRef : undefined}>
<ShapeRenderer loadingColor={loadingColor} streamEntity={torqueObject ? undefined : entity}> <ShapeRenderer
loadingColor={loadingColor}
streamEntity={torqueObject ? undefined : entity}
>
{flagLabel ? ( {flagLabel ? (
<FloatingLabel opacity={0.6}>{flagLabel}</FloatingLabel> <FloatingLabel opacity={0.6}>{flagLabel}</FloatingLabel>
) : null} ) : null}
@ -172,92 +179,26 @@ function ShapeEntity({ entity }: { entity: ShapeEntityType }) {
</group> </group>
</ShapeInfoProvider> </ShapeInfoProvider>
)} )}
{entity.weaponShape && (
<ShapeErrorBoundary
fallback={
<ShapePlaceholder color="red" label={entity.weaponShape} />
}
>
<DebugSuspense
name={`Weapon:${entity.id}/${entity.weaponShape}`}
fallback={
<ShapePlaceholder color="cyan" label={entity.weaponShape} />
}
>
<WeaponModel
shapeName={entity.weaponShape}
playerShapeName={entity.shapeName}
/>
</DebugSuspense>
</ShapeErrorBoundary>
)}
</group> </group>
</ShapeInfoProvider> </ShapeInfoProvider>
); );
} }
// ── Force Field Entity ──
function ForceFieldBareEntity({ entity }: { entity: ForceFieldBareEntityType }) {
if (!entity.forceFieldData) return null;
return (
<Suspense fallback={null}>
<ForceFieldBareRenderer
data={entity.forceFieldData}
scale={entity.forceFieldData.dimensions}
/>
</Suspense>
);
}
// ── Player Entity ──
function PlayerEntity({ entity }: { entity: PlayerEntityType }) {
if (!entity.shapeName) return null;
return (
<Suspense fallback={null}>
<PlayerModel entity={entity} />
</Suspense>
);
}
// ── Explosion Entity ──
function ExplosionEntity({ entity }: { entity: ExplosionEntityType }) {
const playback = streamPlaybackStore.getState().playback;
// ExplosionShape still expects a StreamEntity-shaped object.
// Adapt minimally until that component is also refactored.
const streamEntity = {
id: entity.id,
type: "Explosion" as const,
dataBlock: entity.shapeName,
position: entity.position,
rotation: entity.rotation,
faceViewer: entity.faceViewer,
explosionDataBlockId: entity.explosionDataBlockId,
};
if (!entity.shapeName || !playback) return null;
return (
<Suspense fallback={null}>
<ExplosionShape entity={streamEntity as any} playback={playback} />
</Suspense>
);
}
// ── Tracer Entity ──
function TracerEntity({ entity }: { entity: TracerEntityType }) {
return (
<Suspense fallback={null}>
<TracerProjectile entity={entity} visual={entity.visual} />
</Suspense>
);
}
// ── Sprite Entity ──
function SpriteEntity({ entity }: { entity: SpriteEntityType }) {
return (
<Suspense fallback={null}>
<SpriteProjectile visual={entity.visual} />
</Suspense>
);
}
// ── Audio Entity ──
function AudioEntity({ entity }: { entity: AudioEmitterEntityType }) {
const { audioEnabled } = useSettings();
if (!entity.audioFileName || !audioEnabled) return null;
return (
<Suspense fallback={null}>
<AudioEmitter entity={entity} />
</Suspense>
);
}

View file

@ -1,25 +1,21 @@
import { lazy, memo, Suspense, useCallback, useMemo, useRef, useState } from "react"; import { memo, useCallback, useRef, useState, useMemo } from "react";
import { Quaternion } from "three"; import { Quaternion } from "three";
import type { Group } from "three"; import type { Group } from "three";
import { useFrame } from "@react-three/fiber"; import { useFrame } from "@react-three/fiber";
import { useAllGameEntities } from "../state"; import { useAllGameEntities } from "../state/gameEntityStore";
import type { GameEntity, PositionedEntity, PlayerEntity } from "../state/gameEntityTypes"; import type {
GameEntity,
PositionedEntity,
PlayerEntity,
} from "../state/gameEntityTypes";
import { isSceneEntity } from "../state/gameEntityTypes"; import { isSceneEntity } from "../state/gameEntityTypes";
import { streamPlaybackStore } from "../state/streamPlaybackStore"; import { streamPlaybackStore } from "../state/streamPlaybackStore";
import { EntityRenderer } from "./EntityRenderer"; import { EntityRenderer } from "./EntityRenderer";
import { ShapeErrorBoundary } from "./ShapeErrorBoundary";
import { PlayerNameplate } from "./PlayerNameplate"; import { PlayerNameplate } from "./PlayerNameplate";
import { FlagMarker } from "./FlagMarker"; import { FlagMarker } from "./FlagMarker";
import { FloatingLabel } from "./FloatingLabel";
import { entityTypeColor } from "../stream/playbackUtils"; import { entityTypeColor } from "../stream/playbackUtils";
import { useDebug } from "./SettingsProvider"; import { useEngineSelector } from "../state/engineStore";
import { useEngineSelector } from "../state";
const WeaponModel = lazy(() =>
import("./ShapeModel").then((mod) => ({
default: mod.WeaponModel,
})),
);
/** /**
* The ONE rendering component tree for all game entities. * The ONE rendering component tree for all game entities.
@ -27,39 +23,28 @@ const WeaponModel = lazy(() =>
* Data sources (mission .mis, demo .rec, live server) are controllers that * Data sources (mission .mis, demo .rec, live server) are controllers that
* populate the store this component doesn't know or care which is active. * populate the store this component doesn't know or care which is active.
*/ */
export function EntityScene({ missionType }: { missionType?: string }) { export function EntityScene() {
const debug = useDebug();
const debugMode = debug?.debugMode ?? false;
const rootRef = useCallback((node: Group | null) => { const rootRef = useCallback((node: Group | null) => {
streamPlaybackStore.setState({ root: node }); streamPlaybackStore.setState({ root: node });
}, []); }, []);
return ( return (
<group ref={rootRef}> <group ref={rootRef}>
<EntityLayer missionType={missionType} debugMode={debugMode} /> <EntityLayer />
</group> </group>
); );
} }
/** Renders all game entities. Uses an ID-stable selector so the component /** Renders all game entities. Uses an ID-stable selector so the component
* only re-renders when entities are added or removed, not when their * only re-renders when entities are added or removed, not when their
* fields change. Entity references are cached so that once an entity * fields change. */
* renders and loads resources via Suspense, it keeps its reference stable. */ const EntityLayer = memo(function EntityLayer() {
const EntityLayer = memo(function EntityLayer({
missionType,
debugMode,
}: {
missionType?: string;
debugMode: boolean;
}) {
const entities = useAllGameEntities(); const entities = useAllGameEntities();
// Cache entity references by ID so that in-place field mutations // Cache entity references by ID so that in-place field mutations
// (threads, colors, weapon shape) don't cause React to see a new // (threads, colors, weapon shape) don't cause unnecessary remounts.
// object and remount Suspense boundaries. The cache IS updated when // The cache IS updated when the store provides a genuinely new object
// the store provides a genuinely new object reference (identity // reference (identity rebuild: armor change, datablock change, etc.).
// rebuild: armor change, datablock change, etc.).
const cacheRef = useRef(new Map<string, GameEntity>()); const cacheRef = useRef(new Map<string, GameEntity>());
const cache = cacheRef.current; const cache = cacheRef.current;
@ -75,29 +60,10 @@ const EntityLayer = memo(function EntityLayer({
} }
} }
const filtered = useMemo(() => {
const result: GameEntity[] = [];
const lowerType = missionType?.toLowerCase();
for (const entity of cache.values()) {
if (lowerType && entity.missionTypesList) {
const types = new Set(
entity.missionTypesList
.toLowerCase()
.split(/\s+/)
.filter(Boolean),
);
if (types.size > 0 && !types.has(lowerType)) continue;
}
result.push(entity);
}
return result;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [entities, missionType]);
return ( return (
<> <>
{filtered.map((entity) => ( {[...cache.values()].map((entity) => (
<EntityWrapper key={entity.id} entity={entity} debugMode={debugMode} /> <EntityWrapper key={entity.id} entity={entity} />
))} ))}
</> </>
); );
@ -105,13 +71,11 @@ const EntityLayer = memo(function EntityLayer({
const EntityWrapper = memo(function EntityWrapper({ const EntityWrapper = memo(function EntityWrapper({
entity, entity,
debugMode,
}: { }: {
entity: GameEntity; entity: GameEntity;
debugMode: boolean;
}) { }) {
// Scene infrastructure handles its own positioning — render directly. // Scene infrastructure handles its own positioning and Suspense — render
// The named group allows the interpolation loop to identify and skip them. // directly. The named group allows the interpolation loop to skip them.
if (isSceneEntity(entity)) { if (isSceneEntity(entity)) {
return ( return (
<group name={entity.id}> <group name={entity.id}>
@ -123,14 +87,13 @@ const EntityWrapper = memo(function EntityWrapper({
if (entity.renderType === "None") return null; if (entity.renderType === "None") return null;
// From here, entity is a PositionedEntity // From here, entity is a PositionedEntity
return <PositionedEntityWrapper entity={entity} debugMode={debugMode} />; return <PositionedEntityWrapper entity={entity} />;
}); });
/** Renders the player nameplate, subscribing to controlPlayerGhostId /** Renders the player nameplate, subscribing to controlPlayerGhostId
* internally so that PositionedEntityWrapper doesn't need to. This keeps * internally so that PositionedEntityWrapper doesn't need to. Keeps
* engine store mutations from triggering synchronous selector evaluations * engine store mutations from triggering synchronous selector evaluations
* on every positioned entity (which was starving Suspense retries for * on every positioned entity. */
* shape GLB loading). */
function PlayerNameplateIfVisible({ entity }: { entity: PlayerEntity }) { function PlayerNameplateIfVisible({ entity }: { entity: PlayerEntity }) {
const controlPlayerGhostId = useEngineSelector( const controlPlayerGhostId = useEngineSelector(
(state) => state.playback.streamSnapshot?.controlPlayerGhostId, (state) => state.playback.streamSnapshot?.controlPlayerGhostId,
@ -146,13 +109,19 @@ function PlayerNameplateIfVisible({ entity }: { entity: PlayerEntity }) {
function FlagMarkerSlot({ entity }: { entity: GameEntity }) { function FlagMarkerSlot({ entity }: { entity: GameEntity }) {
const flagRef = useRef(false); const flagRef = useRef(false);
const [isFlag, setIsFlag] = useState(() => { const [isFlag, setIsFlag] = useState(() => {
const flags = "targetRenderFlags" in entity ? (entity.targetRenderFlags as number | undefined) : undefined; const flags =
"targetRenderFlags" in entity
? (entity.targetRenderFlags as number | undefined)
: undefined;
return ((flags ?? 0) & 0x2) !== 0; return ((flags ?? 0) & 0x2) !== 0;
}); });
flagRef.current = isFlag; flagRef.current = isFlag;
useFrame(() => { useFrame(() => {
const flags = "targetRenderFlags" in entity ? (entity.targetRenderFlags as number | undefined) : undefined; const flags =
"targetRenderFlags" in entity
? (entity.targetRenderFlags as number | undefined)
: undefined;
const nowFlag = ((flags ?? 0) & 0x2) !== 0; const nowFlag = ((flags ?? 0) & 0x2) !== 0;
if (nowFlag !== flagRef.current) { if (nowFlag !== flagRef.current) {
flagRef.current = nowFlag; flagRef.current = nowFlag;
@ -161,19 +130,13 @@ function FlagMarkerSlot({ entity }: { entity: GameEntity }) {
}); });
if (!isFlag) return null; if (!isFlag) return null;
return ( return <FlagMarker entity={entity} />;
<Suspense fallback={null}>
<FlagMarker entity={entity} />
</Suspense>
);
} }
function PositionedEntityWrapper({ function PositionedEntityWrapper({
entity, entity,
debugMode,
}: { }: {
entity: PositionedEntity; entity: PositionedEntity;
debugMode: boolean;
}) { }) {
const position = entity.position; const position = entity.position;
const scale = entity.scale; const scale = entity.scale;
@ -187,7 +150,12 @@ function PositionedEntityWrapper({
// Entities without a resolved shape get a wireframe placeholder. // Entities without a resolved shape get a wireframe placeholder.
if (entity.renderType === "Shape" && !entity.shapeName) { if (entity.renderType === "Shape" && !entity.shapeName) {
return ( return (
<group name={entity.id} position={position} quaternion={quaternion} scale={scale}> <group
name={entity.id}
position={position}
quaternion={quaternion}
scale={scale}
>
<mesh> <mesh>
<sphereGeometry args={[0.3, 6, 4]} /> <sphereGeometry args={[0.3, 6, 4]} />
<meshBasicMaterial <meshBasicMaterial
@ -195,7 +163,6 @@ function PositionedEntityWrapper({
wireframe wireframe
/> />
</mesh> </mesh>
{debugMode && <MissingShapeLabel entity={entity} />}
<FlagMarkerSlot entity={entity} /> <FlagMarkerSlot entity={entity} />
</group> </group>
); );
@ -212,82 +179,22 @@ function PositionedEntityWrapper({
</mesh> </mesh>
); );
const shapeName = "shapeName" in entity ? entity.shapeName : undefined;
const weaponShape = "weaponShape" in entity ? entity.weaponShape : undefined;
return ( return (
<group name={entity.id} position={position} quaternion={quaternion} scale={scale}> <group
name={entity.id}
position={position}
quaternion={quaternion}
scale={scale}
>
<group name="model"> <group name="model">
<ShapeErrorBoundary fallback={fallback}> <ShapeErrorBoundary fallback={fallback}>
<Suspense fallback={fallback}>
<EntityRenderer entity={entity} /> <EntityRenderer entity={entity} />
</Suspense>
</ShapeErrorBoundary> </ShapeErrorBoundary>
{isPlayer && ( {isPlayer && (
<Suspense fallback={null}>
<PlayerNameplateIfVisible entity={entity as PlayerEntity} /> <PlayerNameplateIfVisible entity={entity as PlayerEntity} />
</Suspense>
)} )}
<FlagMarkerSlot entity={entity} /> <FlagMarkerSlot entity={entity} />
{debugMode && !shapeName && entity.renderType !== "Shape" && (
<MissingShapeLabel entity={entity} />
)}
</group> </group>
{weaponShape && shapeName && !isPlayer && (
<group name="weapon">
<ShapeErrorBoundary fallback={null}>
<Suspense fallback={null}>
<WeaponModel
shapeName={weaponShape}
playerShapeName={shapeName}
/>
</Suspense>
</ShapeErrorBoundary>
</group>
)}
</group> </group>
); );
} }
function MissingShapeLabel({ entity }: { entity: GameEntity }) {
const bits: string[] = [];
bits.push(`${entity.id} (${entity.className})`);
if (typeof entity.ghostIndex === "number") bits.push(`ghost ${entity.ghostIndex}`);
if (typeof entity.dataBlockId === "number") bits.push(`db ${entity.dataBlockId}`);
bits.push(
entity.shapeHint
? `shapeHint ${entity.shapeHint}`
: "shapeHint <none resolved>",
);
return <FloatingLabel color="#ff6688">{bits.join(" | ")}</FloatingLabel>;
}
/** Error boundary that renders a fallback when shape loading fails. */
import { Component } from "react";
import type { ErrorInfo, ReactNode } from "react";
export class ShapeErrorBoundary extends Component<
{ children: ReactNode; fallback: ReactNode },
{ hasError: boolean }
> {
state = { hasError: false };
static getDerivedStateFromError() {
return { hasError: true };
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.warn(
"[entity] Shape load failed:",
error.message,
info.componentStack,
);
}
render() {
if (this.state.hasError) {
return this.props.fallback;
}
return this.props.children;
}
}

View file

@ -3,6 +3,7 @@ import { useFrame, useThree } from "@react-three/fiber";
import { Html } from "@react-three/drei"; import { Html } from "@react-three/drei";
import { Group, Vector3 } from "three"; import { Group, Vector3 } from "three";
import { textureToUrl } from "../loaders"; import { textureToUrl } from "../loaders";
interface FlagEntity { interface FlagEntity {
id: string; id: string;
iffColor?: { r: number; g: number; b: number }; iffColor?: { r: number; g: number; b: number };
@ -10,7 +11,6 @@ interface FlagEntity {
import styles from "./FlagMarker.module.css"; import styles from "./FlagMarker.module.css";
const FLAG_ICON_HEIGHT = 1.5; const FLAG_ICON_HEIGHT = 1.5;
const FLAG_ICON_URL = textureToUrl("commander/MiniIcons/com_flag_grey"); const FLAG_ICON_URL = textureToUrl("commander/MiniIcons/com_flag_grey");
const _tmpVec = new Vector3(); const _tmpVec = new Vector3();
@ -24,7 +24,7 @@ export function FlagMarker({ entity }: { entity: FlagEntity }) {
const markerRef = useRef<Group>(null); const markerRef = useRef<Group>(null);
const iconRef = useRef<HTMLDivElement>(null); const iconRef = useRef<HTMLDivElement>(null);
const distRef = useRef<HTMLSpanElement>(null); const distRef = useRef<HTMLSpanElement>(null);
const { camera } = useThree(); const camera = useThree((state) => state.camera);
useFrame(() => { useFrame(() => {
// Tint imperatively — iffColor is mutated in-place by streaming playback. // Tint imperatively — iffColor is mutated in-place by streaming playback.
@ -52,10 +52,12 @@ export function FlagMarker({ entity }: { entity: FlagEntity }) {
<div <div
ref={iconRef} ref={iconRef}
className={styles.Icon} className={styles.Icon}
style={{ style={
{
backgroundColor: initialColor, backgroundColor: initialColor,
"--flag-icon-url": `url(${FLAG_ICON_URL})`, "--flag-icon-url": `url(${FLAG_ICON_URL})`,
} as React.CSSProperties} } as React.CSSProperties
}
/> />
</div> </div>
</Html> </Html>

View file

@ -208,7 +208,14 @@ export function fogStateFromScene(sky: SceneSky): FogState {
const enabled = visibleDistance > fogDistance; const enabled = visibleDistance > fogDistance;
return { fogDistance, visibleDistance, fogColor, fogVolumes, fogLine, enabled }; return {
fogDistance,
visibleDistance,
fogColor,
fogVolumes,
fogLine,
enabled,
};
} }
/** /**

View file

View file

@ -1,4 +1,5 @@
import { Suspense, useEffect, useMemo, useRef } from "react"; import { useEffect, useMemo, useRef } from "react";
import { DebugSuspense } from "./DebugSuspense";
import { useTexture } from "@react-three/drei"; import { useTexture } from "@react-three/drei";
import { useFrame } from "@react-three/fiber"; import { useFrame } from "@react-three/fiber";
import { import {
@ -10,7 +11,10 @@ import {
RepeatWrapping, RepeatWrapping,
} from "three"; } from "three";
import type { Texture } from "three"; import type { Texture } from "three";
import type { ForceFieldData } from "../state/gameEntityTypes"; import type {
ForceFieldBareEntity,
ForceFieldData,
} from "../state/gameEntityTypes";
import { textureToUrl } from "../loaders"; import { textureToUrl } from "../loaders";
import { useSettings } from "./SettingsProvider"; import { useSettings } from "./SettingsProvider";
import { import {
@ -126,13 +130,10 @@ function ForceFieldMesh({
* Renders a ForceFieldBare from pre-resolved ForceFieldData. * Renders a ForceFieldBare from pre-resolved ForceFieldData.
* Used by the unified EntityRenderer does NOT read from TorqueObject/datablock. * Used by the unified EntityRenderer does NOT read from TorqueObject/datablock.
*/ */
export function ForceFieldBare({ export function ForceFieldBare({ entity }: { entity: ForceFieldBareEntity }) {
data, const data = entity.forceFieldData;
scale, const scale = data.dimensions;
}: {
data: ForceFieldData;
scale: [number, number, number];
}) {
const textureUrls = useMemo( const textureUrls = useMemo(
() => data.textures.map((t) => textureToUrl(t)), () => data.textures.map((t) => textureToUrl(t)),
[data.textures], [data.textures],
@ -149,7 +150,8 @@ export function ForceFieldBare({
} }
return ( return (
<Suspense <DebugSuspense
name={`ForceField`}
fallback={ fallback={
<ForceFieldFallback <ForceFieldFallback
scale={scale} scale={scale}
@ -159,6 +161,6 @@ export function ForceFieldBare({
} }
> >
<ForceFieldMesh scale={scale} data={data} /> <ForceFieldMesh scale={scale} data={data} />
</Suspense> </DebugSuspense>
); );
} }

View file

@ -1,4 +1,33 @@
/* Shared button base for dialog actions (server browser, map info, etc.). */ .Dialog {
position: relative;
max-width: calc(100dvw - 40px);
max-height: calc(100dvh - 40px);
background: rgba(20, 37, 38, 0.8);
border: 1px solid rgba(65, 131, 139, 0.6);
border-radius: 4px;
box-shadow:
0 0 50px rgba(0, 0, 0, 0.4),
inset 0 0 60px rgba(1, 7, 13, 0.6);
color: #b0d5c9;
font-size: 14px;
line-height: 1.5;
overflow: hidden;
outline: none;
user-select: text;
-webkit-touch-callout: default;
}
.Overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.DialogButton { .DialogButton {
background: linear-gradient( background: linear-gradient(
to bottom, to bottom,

View file

@ -2,6 +2,7 @@ import { memo, Suspense, useEffect, useMemo, useRef } from "react";
import { ErrorBoundary } from "react-error-boundary"; import { ErrorBoundary } from "react-error-boundary";
import { useGLTF, useTexture } from "@react-three/drei"; import { useGLTF, useTexture } from "@react-three/drei";
import { useFrame } from "@react-three/fiber"; import { useFrame } from "@react-three/fiber";
import { createLogger } from "../logger";
import { FALLBACK_TEXTURE_URL, textureToUrl, shapeToUrl } from "../loaders"; import { FALLBACK_TEXTURE_URL, textureToUrl, shapeToUrl } from "../loaders";
import { import {
MeshStandardMaterial, MeshStandardMaterial,
@ -21,7 +22,7 @@ import * as SkeletonUtils from "three/examples/jsm/utils/SkeletonUtils.js";
import { setupTexture } from "../textureUtils"; import { setupTexture } from "../textureUtils";
import { useDebug, useSettings } from "./SettingsProvider"; import { useDebug, useSettings } from "./SettingsProvider";
import { useShapeInfo, isOrganicShape } from "./ShapeInfoProvider"; import { useShapeInfo, isOrganicShape } from "./ShapeInfoProvider";
import { useEngineSelector, effectNow, engineStore } from "../state"; import { useEngineSelector, effectNow, engineStore } from "../state/engineStore";
import { FloatingLabel } from "./FloatingLabel"; import { FloatingLabel } from "./FloatingLabel";
import { import {
useIflTexture, useIflTexture,
@ -39,6 +40,8 @@ import {
} from "../stream/playbackUtils"; } from "../stream/playbackUtils";
import type { ThreadState as StreamThreadState } from "../stream/types"; import type { ThreadState as StreamThreadState } from "../stream/types";
const log = createLogger("GenericShape");
/** Returns pausable time in seconds for demo mode, real time otherwise. */ /** Returns pausable time in seconds for demo mode, real time otherwise. */
function shapeNowSec(): number { function shapeNowSec(): number {
const { recording } = engineStore.getState().playback; const { recording } = engineStore.getState().playback;
@ -271,9 +274,7 @@ const StaticTexture = memo(function StaticTexture({
const url = useMemo(() => { const url = useMemo(() => {
if (!resourcePath) { if (!resourcePath) {
console.warn( log.warn("No resource_path found on \"%s\" — rendering fallback", shapeName);
`No resource_path was found on "${shapeName}" - rendering fallback.`,
);
} }
return resourcePath ? textureToUrl(resourcePath) : FALLBACK_TEXTURE_URL; return resourcePath ? textureToUrl(resourcePath) : FALLBACK_TEXTURE_URL;
}, [resourcePath, shapeName]); }, [resourcePath, shapeName]);
@ -662,10 +663,7 @@ export const ShapeModel = memo(function ShapeModel({
iflMeshAtlasRef.current.set(info.mesh, atlas); iflMeshAtlasRef.current.set(info.mesh, atlas);
}) })
.catch((err) => { .catch((err) => {
console.warn( log.warn("Failed to load IFL atlas for %s: %o", info.iflPath, err);
`[ShapeModel] Failed to load IFL atlas for ${info.iflPath}:`,
err,
);
}); });
} }
}, [iflMeshes]); }, [iflMeshes]);

View file

@ -0,0 +1,37 @@
import { lazy, ReactNode, Suspense } from "react";
import { KeyboardControls } from "@react-three/drei";
import { JoystickProvider } from "./JoystickContext";
import { useTouchDevice } from "./useTouchDevice";
import {
KeyboardAndMouseHandler,
KEYBOARD_CONTROLS,
} from "./KeyboardAndMouseHandler";
const TouchHandler = lazy(() =>
import("@/src/components/TouchHandler").then((mod) => ({
default: mod.TouchHandler,
})),
);
export function InputProvider({ children }: { children: ReactNode }) {
return (
<KeyboardControls map={KEYBOARD_CONTROLS}>
<JoystickProvider>{children}</JoystickProvider>
</KeyboardControls>
);
}
export function InputHandlers() {
const isTouch = useTouchDevice();
return (
<>
<KeyboardAndMouseHandler />
{isTouch ? (
<Suspense>
<TouchHandler />
</Suspense>
) : null}
</>
);
}

View file

@ -1,57 +1,170 @@
.Controls { .InspectorControls {
position: fixed; position: relative;
top: 0;
left: 0;
background: rgba(0, 0, 0, 0.5);
color: #fff;
padding: 8px 12px 8px 8px;
border-radius: 0 0 4px 0;
font-size: 13px; font-size: 13px;
line-height: 1.231;
z-index: 2; z-index: 2;
display: flex;
align-items: center;
justify-content: center;
gap: 20px;
} }
.Dropdown { .Dropdown {
display: flex; display: flex;
align-items: center; flex-direction: column;
align-items: stretch;
justify-content: center; justify-content: center;
gap: 20px; gap: 0;
} }
.ButtonGroup {
width: 100%;
flex: 1 0 auto;
display: flex;
align-items: stretch;
}
.ButtonGroup .IconButton {
flex: 1 0 0;
flex-direction: column;
gap: 1px;
font-size: 22px;
padding-top: 8px;
padding-bottom: 8px;
}
.ButtonGroup .IconButton svg {
margin-bottom: 3px;
}
.ButtonGroup .IconButton[data-active="true"] {
background: rgb(5, 114, 177);
}
.ButtonGroup .IconButton:not(:first-child) {
border-left: 0;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
.ButtonGroup .IconButton:not(:last-child) {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.Group { .Group {
display: flex; display: flex;
flex-wrap: wrap;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 20px; gap: 20px;
} }
.CheckboxField,
.LabelledButton { .LabelledButton {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
} }
.Field {
.CheckboxField {
display: grid;
grid-template-columns: auto 1fr;
grid-template-rows: auto auto;
align-items: center;
gap: 0 6px;
margin: 0 0 6px 0;
}
.CheckboxField input[type="checkbox"] {
margin-left: 0;
grid-column: 1;
grid-row: 1;
}
.CheckboxField .Label {
grid-column: 2;
grid-row: 1;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
} }
.Description {
font-size: 12px;
line-height: 1.3333;
opacity: 0.6;
margin: 2px 0 4px 0;
padding: 0;
}
.CheckboxField .Description {
grid-column: 2;
grid-row: 2;
}
.Control {
display: flex;
align-items: center;
gap: 8px;
}
.Field select {
margin-bottom: 6px;
}
.Field output {
opacity: 0.7;
}
.Tools {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
padding: 10px;
}
.Field {
display: grid;
grid-template-columns: 1fr auto;
grid-template-rows: auto auto;
align-items: center;
margin: 0 0 6px 0;
}
.Field label {
grid-column: 1;
grid-row: 1;
}
.Field .Control {
grid-column: 2;
grid-row: 1;
}
.Field .Description {
grid-column: 1 / -1;
grid-row: 2;
}
.IconButton { .IconButton {
flex: 1 1 auto;
position: relative; position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: auto;
height: auto;
min-width: 28px; min-width: 28px;
height: 28px; min-height: 32px;
margin: 0 0 0 -12px; gap: 8px;
font-size: 15px; margin: 0;
padding: 0; font-family: inherit;
font-size: 18px;
font-weight: 500;
padding: 4px 8px;
border-top: 1px solid rgba(255, 255, 255, 0.3); border-top: 1px solid rgba(255, 255, 255, 0.3);
border-left: 1px solid rgba(255, 255, 255, 0.3); border-left: 1px solid rgba(255, 255, 255, 0.3);
border-right: 1px solid rgba(200, 200, 200, 0.3); border-right: 1px solid rgba(200, 200, 200, 0.3);
border-bottom: 1px solid rgba(200, 200, 200, 0.3); border-bottom: 1px solid rgba(200, 200, 200, 0.3);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.4); box-shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
border-radius: 4px; border-radius: 5px;
background: rgba(3, 82, 147, 0.6); background: rgba(3, 82, 147, 0.6);
color: #fff; color: #fff;
cursor: pointer; cursor: pointer;
@ -60,92 +173,69 @@
background 0.2s, background 0.2s,
border-color 0.2s; border-color 0.2s;
} }
.IconButton svg { .IconButton svg {
flex: 0 0 auto;
pointer-events: none; pointer-events: none;
opacity: 0.6;
transition: opacity 0.2s;
} }
.IconButton:disabled {
opacity: 0.6;
cursor: default;
}
@media (hover: hover) { @media (hover: hover) {
.IconButton:hover { .IconButton:not(:disabled):hover {
background: rgba(0, 98, 179, 0.8); background: rgba(0, 98, 179, 0.8);
border-color: rgba(255, 255, 255, 0.4); border-color: rgba(255, 255, 255, 0.4);
} }
.IconButton:not(:disabled):hover svg {
opacity: 1;
} }
.IconButton:active, }
.IconButton:not(:disabled):active,
.IconButton[aria-expanded="true"] { .IconButton[aria-expanded="true"] {
background: rgba(0, 98, 179, 0.7); background: rgba(0, 98, 179, 0.7);
border-color: rgba(255, 255, 255, 0.3); border-color: rgba(255, 255, 255, 0.3);
transform: translate(0, 1px); transform: translate(0, 1px);
} }
.IconButton[data-active="true"] { .IconButton[data-active="true"] {
background: rgba(0, 117, 213, 0.9); background: rgba(0, 117, 213, 0.9);
border-color: rgba(255, 255, 255, 0.4); border-color: rgba(255, 255, 255, 0.4);
} }
.ButtonLabel { .ButtonLabel {
font-size: 12px; font-size: 14px;
} }
.ButtonHint {
font-size: 10px;
opacity: 0.7;
}
.Toggle { .Toggle {
composes: IconButton; composes: IconButton;
margin: 0; margin: 0;
} }
.MapInfoButton { .MapInfoButton {
composes: IconButton; composes: IconButton;
composes: LabelledButton; composes: LabelledButton;
} }
.MissionSelectWrapper {
} .ForceRenderButton {
@media (max-width: 1279px) { display: grid;
.Dropdown[data-open="false"] { place-content: center;
display: none; border: 0;
} margin: 0;
.Dropdown { padding: 0 2px;
position: absolute; font-size: 16px;
top: calc(100% + 2px); background: transparent;
left: 2px; color: #4cb5ff;
right: 2px; cursor: pointer;
display: flex;
overflow: auto;
max-height: calc(100dvh - 56px);
flex-direction: column;
align-items: center;
gap: 12px;
background: rgba(0, 0, 0, 0.8);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 4px;
padding: 12px;
box-shadow: 0 0 12px rgba(0, 0, 0, 0.4);
}
.Group {
flex-wrap: wrap;
gap: 12px 20px;
}
.LabelledButton {
width: auto;
padding: 0 10px;
}
}
@media (max-width: 639px) {
.Controls {
right: 0;
border-radius: 0;
}
.MissionSelectWrapper {
flex: 1 1 0;
min-width: 0;
}
.MissionSelectWrapper input {
width: 100%;
}
.Toggle {
flex: 0 0 auto;
}
}
@media (min-width: 1280px) {
.Toggle {
display: none;
}
.LabelledButton .ButtonLabel {
display: none;
}
.MapInfoButton {
display: none;
}
} }

View file

@ -1,42 +1,62 @@
import { useEffect, useState, useRef, RefObject } from "react";
import { RiLandscapeFill } from "react-icons/ri";
import { FaRotateRight } from "react-icons/fa6";
import { LuClipboardList } from "react-icons/lu";
import { Camera } from "three";
import { import {
useControls, useControls,
useDebug, useDebug,
useSettings, useSettings,
type TouchMode, type TouchMode,
} from "./SettingsProvider"; } from "./SettingsProvider";
import { MissionSelect } from "./MissionSelect";
import { useEffect, useState, useRef, RefObject } from "react";
import { CopyCoordinatesButton } from "./CopyCoordinatesButton"; import { CopyCoordinatesButton } from "./CopyCoordinatesButton";
import { LoadDemoButton } from "./LoadDemoButton"; import { LoadDemoButton } from "./LoadDemoButton";
import { JoinServerButton } from "./JoinServerButton"; import { JoinServerButton } from "./JoinServerButton";
import { useRecording } from "./RecordingProvider"; import { Accordion, AccordionGroup } from "./Accordion";
import { useLiveSelector } from "../state/liveConnectionStore";
import { FiInfo, FiSettings } from "react-icons/fi";
import { Camera } from "three";
import styles from "./InspectorControls.module.css"; import styles from "./InspectorControls.module.css";
import { useTouchDevice } from "./useTouchDevice";
import { useRecording } from "./RecordingProvider";
import { useDataSource, useMissionName } from "../state/gameEntityStore";
import { useLiveSelector } from "../state/liveConnectionStore";
import { hasMission } from "../manifest";
const DEFAULT_PANELS = ["controls", "preferences", "audio"];
export function InspectorControls({ export function InspectorControls({
missionName, missionName,
missionType, missionType,
onChangeMission,
onOpenMapInfo, onOpenMapInfo,
onOpenServerBrowser, onOpenServerBrowser,
isTouch, onChooseMap,
onCancelChoosingMap,
choosingMap,
cameraRef, cameraRef,
invalidateRef,
}: { }: {
missionName: string; missionName: string;
missionType: string; missionType: string;
onChangeMission: ({
missionName,
missionType,
}: {
missionName: string;
missionType: string;
}) => void;
onOpenMapInfo: () => void; onOpenMapInfo: () => void;
onOpenServerBrowser?: () => void; onOpenServerBrowser?: () => void;
isTouch: boolean | null; onChooseMap?: () => void;
onCancelChoosingMap?: () => void;
choosingMap?: boolean;
cameraRef: RefObject<Camera>; cameraRef: RefObject<Camera>;
invalidateRef: RefObject<() => void>;
}) { }) {
const isTouch = useTouchDevice();
const dataSource = useDataSource();
const recording = useRecording();
const storeMissionName = useMissionName();
const hasStreamData = dataSource === "demo" || dataSource === "live";
// When streaming, the URL query param may not reflect the actual map.
// Use the store's mission name (from the server) for the manifest check.
const effectiveMissionName = hasStreamData ? storeMissionName : missionName;
const missionInManifest = effectiveMissionName
? hasMission(effectiveMissionName)
: false;
const isLiveConnected = useLiveSelector(
(s) => s.gameStatus === "connected" || s.gameStatus === "authenticating",
);
const { const {
fogEnabled, fogEnabled,
setFogEnabled, setFogEnabled,
@ -44,18 +64,25 @@ export function InspectorControls({
setFov, setFov,
audioEnabled, audioEnabled,
setAudioEnabled, setAudioEnabled,
audioVolume,
setAudioVolume,
animationEnabled, animationEnabled,
setAnimationEnabled, setAnimationEnabled,
} = useSettings(); } = useSettings();
const { speedMultiplier, setSpeedMultiplier, touchMode, setTouchMode } = const {
useControls(); speedMultiplier,
const { debugMode, setDebugMode } = useDebug(); setSpeedMultiplier,
const demoRecording = useRecording(); touchMode,
const isLive = useLiveSelector((s) => s.adapter != null); setTouchMode,
const isStreaming = demoRecording != null || isLive; invertScroll,
// Hide FOV/speed controls during .rec playback (faithfully replaying), setInvertScroll,
// but show them in .mis browsing and live observer mode. invertDrag,
const hideViewControls = isStreaming && !isLive; setInvertDrag,
invertJoystick,
setInvertJoystick,
} = useControls();
const { debugMode, setDebugMode, renderOnDemand, setRenderOnDemand } =
useDebug();
const [settingsOpen, setSettingsOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null); const buttonRef = useRef<HTMLButtonElement>(null);
@ -81,34 +108,8 @@ export function InspectorControls({
} }
}; };
return ( return (
<div <div className={styles.InspectorControls}>
id="controls"
className={styles.Controls}
onKeyDown={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
<div className={styles.MissionSelectWrapper}>
<MissionSelect
value={missionName}
missionType={missionType}
onChange={onChangeMission}
disabled={isStreaming}
/>
</div>
<div ref={focusAreaRef}> <div ref={focusAreaRef}>
<button
ref={buttonRef}
className={styles.Toggle}
onClick={() => {
setSettingsOpen((isOpen) => !isOpen);
}}
aria-expanded={settingsOpen}
aria-controls="settingsPanel"
aria-label="Settings"
>
<FiSettings />
</button>
<div <div
className={styles.Dropdown} className={styles.Dropdown}
ref={dropdownRef} ref={dropdownRef}
@ -118,93 +119,54 @@ export function InspectorControls({
onBlur={handleDropdownBlur} onBlur={handleDropdownBlur}
data-open={settingsOpen} data-open={settingsOpen}
> >
<div className={styles.Group}> <div className={styles.Tools}>
<div className={styles.ButtonGroup}>
<button
type="button"
className={styles.IconButton}
data-active={
(dataSource === "map" && !recording) || choosingMap
}
onClick={onChooseMap}
>
<RiLandscapeFill />
<span className={styles.ButtonLabel}>Explore</span>
<span className={styles.ButtonHint}>Browse maps</span>
</button>
<LoadDemoButton
isActive={!choosingMap && recording?.source === "demo"}
choosingMap={choosingMap}
onCancelChoosingMap={onCancelChoosingMap}
/>
{onOpenServerBrowser && (
<JoinServerButton
isActive={!choosingMap && isLiveConnected}
onOpenServerBrowser={onOpenServerBrowser}
/>
)}
</div>
<CopyCoordinatesButton <CopyCoordinatesButton
missionName={missionName} missionName={missionName}
missionType={missionType} missionType={missionType}
cameraRef={cameraRef} cameraRef={cameraRef}
disabled={!missionInManifest}
/> />
<LoadDemoButton />
{onOpenServerBrowser && (
<JoinServerButton onOpenServerBrowser={onOpenServerBrowser} />
)}
<button <button
type="button" type="button"
className={styles.MapInfoButton} className={styles.MapInfoButton}
aria-label="Show map info" aria-label="Show map info"
onClick={onOpenMapInfo} onClick={onOpenMapInfo}
disabled={!missionInManifest}
> >
<FiInfo /> <LuClipboardList />
<span className={styles.ButtonLabel}>Show map info</span> <span className={styles.ButtonLabel}>Show map info</span>
</button> </button>
</div> </div>
<div className={styles.Group}> <div className={styles.Accordions}>
<div className={styles.CheckboxField}> <AccordionGroup type="multiple" defaultValue={DEFAULT_PANELS}>
<input <Accordion value="controls" label="Controls">
id="fogInput"
type="checkbox"
checked={fogEnabled}
onChange={(event) => {
setFogEnabled(event.target.checked);
}}
/>
<label htmlFor="fogInput">Fog?</label>
</div>
<div className={styles.CheckboxField}>
<input
id="audioInput"
type="checkbox"
checked={audioEnabled}
onChange={(event) => {
setAudioEnabled(event.target.checked);
}}
/>
<label htmlFor="audioInput">Audio?</label>
</div>
</div>
<div className={styles.Group}>
<div className={styles.CheckboxField}>
<input
id="animationInput"
type="checkbox"
checked={animationEnabled}
onChange={(event) => {
setAnimationEnabled(event.target.checked);
}}
/>
<label htmlFor="animationInput">Animation?</label>
</div>
<div className={styles.CheckboxField}>
<input
id="debugInput"
type="checkbox"
checked={debugMode}
onChange={(event) => {
setDebugMode(event.target.checked);
}}
/>
<label htmlFor="debugInput">Debug?</label>
</div>
</div>
<div className={styles.Group}>
{hideViewControls ? null : (
<div className={styles.Field}> <div className={styles.Field}>
<label htmlFor="fovInput">FOV</label> <label htmlFor="speedInput">Fly speed</label>
<input
id="fovInput"
type="range"
min={75}
max={120}
step={5}
value={fov}
onChange={(event) => setFov(parseInt(event.target.value))}
/>
<output htmlFor="fovInput">{fov}</output>
</div>
)}
{hideViewControls ? null : (
<div className={styles.Field}>
<label htmlFor="speedInput">Speed</label>
<input <input
id="speedInput" id="speedInput"
type="range" type="range"
@ -216,24 +178,209 @@ export function InspectorControls({
setSpeedMultiplier(parseFloat(event.target.value)) setSpeedMultiplier(parseFloat(event.target.value))
} }
/> />
<p className={styles.Description}>
How fast you move in free-flying mode.
{isTouch === false
? " Use your scroll wheel or trackpad to adjust while flying."
: ""}
</p>
</div> </div>
)} {isTouch ? (
</div>
{isTouch && (
<div className={styles.Group}>
<div className={styles.Field}> <div className={styles.Field}>
<label htmlFor="touchModeInput">Joystick:</label>{" "} <label htmlFor="touchModeInput">Joystick</label>{" "}
<select <select
id="touchModeInput" id="touchModeInput"
value={touchMode} value={touchMode}
onChange={(e) => setTouchMode(e.target.value as TouchMode)} onChange={(e) =>
setTouchMode(e.target.value as TouchMode)
}
> >
<option value="dualStick">Dual Stick</option> <option value="dualStick">Dual stick</option>
<option value="moveLookStick">Single Stick</option> <option value="moveLookStick">Single stick</option>
</select> </select>
<p className={styles.Description}>
Single stick has a unified move + look control. Dual stick
has independent move + look.
</p>
</div>
) : null}
{isTouch === false ? (
<div className={styles.CheckboxField}>
<input
id="invertScroll"
type="checkbox"
checked={invertScroll}
onChange={(event) => {
setInvertScroll(event.target.checked);
}}
/>
<label className={styles.Label} htmlFor="invertScroll">
Invert scroll direction
</label>
<p className={styles.Description}>
Reverse which scroll direction increases and decreases fly
speed.
</p>
</div>
) : null}
{isTouch ? (
<div className={styles.CheckboxField}>
<input
id="invertJoystick"
type="checkbox"
checked={invertJoystick}
onChange={(event) => {
setInvertJoystick(event.target.checked);
}}
/>
<label className={styles.Label} htmlFor="invertJoystick">
Invert joystick direction
</label>
<p className={styles.Description}>
Reverse joystick look direction.
</p>
</div>
) : null}
<div className={styles.CheckboxField}>
<input
id="invertDrag"
type="checkbox"
checked={invertDrag}
onChange={(event) => {
setInvertDrag(event.target.checked);
}}
/>
<label className={styles.Label} htmlFor="invertDrag">
Invert drag direction
</label>
<p className={styles.Description}>
Reverse how dragging the viewport aims the camera.
</p>
</div>
</Accordion>
<Accordion value="preferences" label="Preferences">
<div className={styles.Field}>
<label htmlFor="fovInput">FOV</label>
<div className={styles.Control}>
<output htmlFor="fovInput">{fov}&deg;</output>
<input
id="fovInput"
type="range"
min={75}
max={120}
step={5}
value={fov}
onChange={(event) => setFov(parseInt(event.target.value))}
/>
</div> </div>
</div> </div>
)} </Accordion>
<Accordion value="audio" label="Audio">
<div className={styles.CheckboxField}>
<input
id="audioInput"
type="checkbox"
checked={audioEnabled}
onChange={(event) => {
setAudioEnabled(event.target.checked);
}}
/>
<label className={styles.Label} htmlFor="audioInput">
Enable audio
</label>
</div>
<div className={styles.Field}>
<label htmlFor="volumeInput">Master volume</label>
<div className={styles.Control}>
<output htmlFor="volumeInput">
{Math.round(audioVolume * 100)}%
</output>
<input
id="volumeInput"
type="range"
min={0}
max={1}
step={0.05}
value={audioVolume}
onChange={(event) =>
setAudioVolume(parseFloat(event.target.value))
}
/>
</div>
</div>
</Accordion>
<Accordion value="graphics" label="Graphics">
<div className={styles.CheckboxField}>
<input
id="fogInput"
type="checkbox"
checked={fogEnabled}
onChange={(event) => {
setFogEnabled(event.target.checked);
}}
/>
<label className={styles.Label} htmlFor="fogInput">
Enable fog
</label>
</div>
<div className={styles.CheckboxField}>
<input
id="animationInput"
type="checkbox"
checked={animationEnabled}
onChange={(event) => {
setAnimationEnabled(event.target.checked);
}}
/>
<label className={styles.Label} htmlFor="animationInput">
Enable animations
</label>
</div>
</Accordion>
<Accordion value="debug" label="Debug">
<div className={styles.CheckboxField}>
<input
id="debugInput"
type="checkbox"
checked={debugMode}
onChange={(event) => {
setDebugMode(event.target.checked);
}}
/>
<label className={styles.Label} htmlFor="debugInput">
Render debug visuals
</label>
</div>
<div className={styles.CheckboxField}>
<input
id="onDemandInput"
type="checkbox"
checked={renderOnDemand}
onChange={(event) => {
setRenderOnDemand(event.target.checked);
}}
/>
<div className={styles.Label}>
<label htmlFor="onDemandInput">Render on demand </label>
<button
type="button"
className={styles.ForceRenderButton}
title="Force render"
aria-label="Force render"
onClick={() => invalidateRef.current?.()}
>
<FaRotateRight />
</button>
</div>
<p className={styles.Description}>
Significantly decreases CPU and GPU usage by only rendering
frames when requested. Helpful when developing parts of the
app unrelated to rendering.
</p>
</div>
</Accordion>
</AccordionGroup>
</div>
</div> </div>
</div> </div>
</div> </div>

View file

@ -1,5 +1,7 @@
import { memo, Suspense, useMemo, useCallback, useEffect, useRef } from "react"; import { memo, useMemo, useCallback, useEffect, useRef } from "react";
import { DebugSuspense } from "./DebugSuspense";
import { ErrorBoundary } from "react-error-boundary"; import { ErrorBoundary } from "react-error-boundary";
import { createLogger } from "../logger";
import { import {
Mesh, Mesh,
Material, Material,
@ -24,6 +26,8 @@ import { injectCustomFog } from "../fogShader";
import { globalFogUniforms } from "../globalFogUniforms"; import { globalFogUniforms } from "../globalFogUniforms";
import { injectInteriorLighting } from "../interiorMaterial"; import { injectInteriorLighting } from "../interiorMaterial";
const log = createLogger("InteriorInstance");
/** /**
* Load a .gltf file that was converted from a .dif, used for "interior" models. * Load a .gltf file that was converted from a .dif, used for "interior" models.
*/ */
@ -151,7 +155,8 @@ function InteriorMesh({ node }: { node: Mesh }) {
return ( return (
<mesh geometry={node.geometry} castShadow receiveShadow> <mesh geometry={node.geometry} castShadow receiveShadow>
{node.material ? ( {node.material ? (
<Suspense <DebugSuspense
label={`InteriorTexture:${Array.isArray(node.material) ? node.material[0]?.userData?.resource_path : node.material?.userData?.resource_path ?? "?"}`}
fallback={ fallback={
// Allow the mesh to render while the texture is still loading; // Allow the mesh to render while the texture is still loading;
// show a wireframe placeholder. // show a wireframe placeholder.
@ -174,7 +179,7 @@ function InteriorMesh({ node }: { node: Mesh }) {
lightMap={lightMaps[0]} lightMap={lightMaps[0]}
/> />
)} )}
</Suspense> </DebugSuspense>
) : null} ) : null}
</mesh> </mesh>
); );
@ -253,18 +258,18 @@ export const InteriorInstance = memo(function InteriorInstance({
/> />
} }
onError={(error) => { onError={(error) => {
console.warn( log.error("Failed to load %s: %s", scene.interiorFile, error.message);
`[interior] Failed to load ${scene.interiorFile}:`,
error.message,
);
}} }}
> >
<Suspense fallback={<InteriorPlaceholder color="orange" />}> <DebugSuspense
label={`InteriorModel:${scene.interiorFile}`}
fallback={<InteriorPlaceholder color="orange" />}
>
<InteriorModel <InteriorModel
interiorFile={scene.interiorFile} interiorFile={scene.interiorFile}
ghostIndex={scene.ghostIndex} ghostIndex={scene.ghostIndex}
/> />
</Suspense> </DebugSuspense>
</ErrorBoundary> </ErrorBoundary>
</group> </group>
); );

View file

@ -1,7 +1,6 @@
.Root { .Root {
composes: IconButton from "./InspectorControls.module.css"; composes: IconButton from "./InspectorControls.module.css";
composes: LabelledButton from "./InspectorControls.module.css"; composes: LabelledButton from "./InspectorControls.module.css";
padding: 0 5px;
} }
/* Text label ("Connect", "Connecting...") follows standard breakpoint rules. */ /* Text label ("Connect", "Connecting...") follows standard breakpoint rules. */
.TextLabel { .TextLabel {
@ -14,7 +13,7 @@
margin-right: 2px; margin-right: 2px;
} }
.LiveIcon { .LiveIcon {
font-size: 15px; /* font-size: 15px; */
} }
.Pulsing { .Pulsing {
animation: blink 1.2s ease-out infinite; animation: blink 1.2s ease-out infinite;
@ -27,3 +26,7 @@
opacity: 0.25; opacity: 0.25;
} }
} }
.ButtonHint {
composes: ButtonHint from "./InspectorControls.module.css";
}

View file

@ -3,12 +3,14 @@ import { useLiveSelector, selectPing } from "../state/liveConnectionStore";
import styles from "./JoinServerButton.module.css"; import styles from "./JoinServerButton.module.css";
function formatPing(ms: number): string { function formatPing(ms: number): string {
return ms >= 1000 ? ms.toLocaleString() + "ms" : ms + "ms"; return `${ms.toLocaleString()} ms`;
} }
export function JoinServerButton({ export function JoinServerButton({
isActive,
onOpenServerBrowser, onOpenServerBrowser,
}: { }: {
isActive: boolean;
onOpenServerBrowser: () => void; onOpenServerBrowser: () => void;
}) { }) {
const gameStatus = useLiveSelector((s) => s.gameStatus); const gameStatus = useLiveSelector((s) => s.gameStatus);
@ -26,8 +28,8 @@ export function JoinServerButton({
<button <button
type="button" type="button"
className={styles.Root} className={styles.Root}
aria-label={isLive ? `Disconnect from ${serverName ?? "server"}` : "Join server"} aria-label={isLive ? "Connected  click to disconnect" : "Join server"}
title={isLive ? `Disconnect from ${serverName ?? "server"}` : "Join server"} title={isLive ? "Connected  click to disconnect" : "Join server"}
onClick={() => { onClick={() => {
if (isLive) { if (isLive) {
disconnectServer(); disconnectServer();
@ -35,21 +37,20 @@ export function JoinServerButton({
onOpenServerBrowser(); onOpenServerBrowser();
} }
}} }}
data-active={isLive ? "true" : undefined} data-active={isActive}
> >
<BsFillLightningChargeFill <BsFillLightningChargeFill
className={`${styles.LiveIcon} ${isLive ? styles.Pulsing : ""}`} className={`${styles.LiveIcon} ${isLive ? styles.Pulsing : ""}`}
/> />
{!isLive && ( <>
<span className={styles.TextLabel}> <span className={styles.TextLabel}>Live</span>
{isConnecting ? "Connecting..." : "Connect"} <span className={styles.ButtonHint}>
{ping != null ? formatPing(ping) : "Join a game"}
</span> </span>
)} </>
{isLive && ping != null && ( {/* {isLive && ping != null && (
<span className={styles.PingLabel}> <span className={styles.PingLabel}>{formatPing(ping)}</span>
{formatPing(ping)} )} */}
</span>
)}
</button> </button>
); );
} }

View file

@ -0,0 +1,69 @@
import {
createContext,
ReactNode,
RefObject,
useCallback,
useContext,
useMemo,
useRef,
} from "react";
export type JoystickState = {
angle: number;
force: number;
};
type JoystickContextType = {
moveState: RefObject<JoystickState>;
lookState: RefObject<JoystickState>;
setMoveState: (state: Partial<JoystickState>) => void;
setLookState: (state: Partial<JoystickState>) => void;
};
export const JoystickContext = createContext<JoystickContextType | null>(null);
export function useJoystick() {
const context = useContext(JoystickContext);
if (!context) {
throw new Error(
"No JoystickContext found. Did you forget to add a <JoystickProvider>?",
);
}
return context;
}
export function JoystickProvider({ children }: { children: ReactNode }) {
const moveState = useRef<JoystickState>({ angle: 0, force: 0 });
const lookState = useRef<JoystickState>({ angle: 0, force: 0 });
const setMoveState = useCallback(
({ angle, force }: Partial<JoystickState>) => {
if (angle != null) {
moveState.current.angle = angle;
}
if (force != null) {
moveState.current.force = force;
}
},
[],
);
const setLookState = useCallback(
({ angle, force }: Partial<JoystickState>) => {
if (angle != null) {
lookState.current.angle = angle;
}
if (force != null) {
lookState.current.force = force;
}
},
[],
);
const context: JoystickContextType = useMemo(
() => ({ moveState, lookState, setMoveState, setLookState }),
[setMoveState, setLookState],
);
return <JoystickContext value={context}>{children}</JoystickContext>;
}

View file

@ -1,4 +1,4 @@
import { useEffect, useRef } from "react"; import { useEffect, useEffectEvent, useRef } from "react";
import { Euler, Vector3 } from "three"; import { Euler, Vector3 } from "three";
import { useFrame, useThree } from "@react-three/fiber"; import { useFrame, useThree } from "@react-three/fiber";
import { useKeyboardControls } from "@react-three/drei"; import { useKeyboardControls } from "@react-three/drei";
@ -29,6 +29,28 @@ export enum Controls {
camera9 = "camera9", camera9 = "camera9",
} }
export const KEYBOARD_CONTROLS = [
{ name: Controls.forward, keys: ["KeyW"] },
{ name: Controls.backward, keys: ["KeyS"] },
{ name: Controls.left, keys: ["KeyA"] },
{ name: Controls.right, keys: ["KeyD"] },
{ name: Controls.up, keys: ["Space"] },
{ name: Controls.down, keys: ["ShiftLeft", "ShiftRight"] },
{ name: Controls.lookUp, keys: ["ArrowUp"] },
{ name: Controls.lookDown, keys: ["ArrowDown"] },
{ name: Controls.lookLeft, keys: ["ArrowLeft"] },
{ name: Controls.lookRight, keys: ["ArrowRight"] },
{ name: Controls.camera1, keys: ["Digit1"] },
{ name: Controls.camera2, keys: ["Digit2"] },
{ name: Controls.camera3, keys: ["Digit3"] },
{ name: Controls.camera4, keys: ["Digit4"] },
{ name: Controls.camera5, keys: ["Digit5"] },
{ name: Controls.camera6, keys: ["Digit6"] },
{ name: Controls.camera7, keys: ["Digit7"] },
{ name: Controls.camera8, keys: ["Digit8"] },
{ name: Controls.camera9, keys: ["Digit9"] },
];
const BASE_SPEED = 80; const BASE_SPEED = 80;
const MIN_SPEED_ADJUSTMENT = 0.05; const MIN_SPEED_ADJUSTMENT = 0.05;
const MAX_SPEED_ADJUSTMENT = 0.5; const MAX_SPEED_ADJUSTMENT = 0.5;
@ -39,13 +61,39 @@ const DRAG_THRESHOLD = 3; // px of movement before it counts as a drag
export const MOUSE_SENSITIVITY = 0.003; export const MOUSE_SENSITIVITY = 0.003;
export const ARROW_LOOK_SPEED = 1; // radians/sec export const ARROW_LOOK_SPEED = 1; // radians/sec
function CameraMovement() { export function KeyboardAndMouseHandler() {
const { speedMultiplier, setSpeedMultiplier } = useControls(); // Don't let KeyboardControls handle stuff when metaKey is held.
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
// Let Cmd/Ctrl+K pass through for search focus.
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
return;
}
if (e.metaKey) {
e.stopImmediatePropagation();
}
};
window.addEventListener("keydown", handleKey, { capture: true });
window.addEventListener("keyup", handleKey, { capture: true });
return () => {
window.removeEventListener("keydown", handleKey, { capture: true });
window.removeEventListener("keyup", handleKey, { capture: true });
};
}, []);
const { speedMultiplier, setSpeedMultiplier, invertScroll, invertDrag } =
useControls();
const [subscribe, getKeys] = useKeyboardControls<Controls>(); const [subscribe, getKeys] = useKeyboardControls<Controls>();
const { camera, gl } = useThree(); const camera = useThree((state) => state.camera);
const gl = useThree((state) => state.gl);
const { nextCamera, setCameraIndex, cameraCount } = useCameras(); const { nextCamera, setCameraIndex, cameraCount } = useCameras();
const controlsRef = useRef<PointerLockControls | null>(null); const controlsRef = useRef<PointerLockControls | null>(null);
const getInvertScroll = useEffectEvent(() => invertScroll);
const getInvertDrag = useEffectEvent(() => invertDrag);
// Scratch vectors/euler to avoid allocations each frame // Scratch vectors/euler to avoid allocations each frame
const forwardVec = useRef(new Vector3()); const forwardVec = useRef(new Vector3());
const sideVec = useRef(new Vector3()); const sideVec = useRef(new Vector3());
@ -92,9 +140,10 @@ function CameraMovement() {
} }
didDrag = true; didDrag = true;
const dragSign = getInvertDrag() ? -1 : 1;
euler.setFromQuaternion(camera.quaternion, "YXZ"); euler.setFromQuaternion(camera.quaternion, "YXZ");
euler.y -= e.movementX * MOUSE_SENSITIVITY; euler.y += dragSign * e.movementX * MOUSE_SENSITIVITY;
euler.x -= e.movementY * MOUSE_SENSITIVITY; euler.x += dragSign * e.movementY * MOUSE_SENSITIVITY;
euler.x = Math.max(-MAX_PITCH, Math.min(MAX_PITCH, euler.x)); euler.x = Math.max(-MAX_PITCH, Math.min(MAX_PITCH, euler.x));
camera.quaternion.setFromEuler(euler); camera.quaternion.setFromEuler(euler);
}; };
@ -154,7 +203,8 @@ function CameraMovement() {
const handleWheel = (e: WheelEvent) => { const handleWheel = (e: WheelEvent) => {
e.preventDefault(); e.preventDefault();
const direction = e.deltaY > 0 ? -1 : 1; const scrollSign = getInvertScroll() ? -1 : 1;
const direction = (e.deltaY > 0 ? -1 : 1) * scrollSign;
const delta = const delta =
// Helps normalize sensitivity; trackpad scrolling will have many small // Helps normalize sensitivity; trackpad scrolling will have many small
@ -253,50 +303,3 @@ function CameraMovement() {
return null; return null;
} }
export const KEYBOARD_CONTROLS = [
{ name: Controls.forward, keys: ["KeyW"] },
{ name: Controls.backward, keys: ["KeyS"] },
{ name: Controls.left, keys: ["KeyA"] },
{ name: Controls.right, keys: ["KeyD"] },
{ name: Controls.up, keys: ["Space"] },
{ name: Controls.down, keys: ["ShiftLeft", "ShiftRight"] },
{ name: Controls.lookUp, keys: ["ArrowUp"] },
{ name: Controls.lookDown, keys: ["ArrowDown"] },
{ name: Controls.lookLeft, keys: ["ArrowLeft"] },
{ name: Controls.lookRight, keys: ["ArrowRight"] },
{ name: Controls.camera1, keys: ["Digit1"] },
{ name: Controls.camera2, keys: ["Digit2"] },
{ name: Controls.camera3, keys: ["Digit3"] },
{ name: Controls.camera4, keys: ["Digit4"] },
{ name: Controls.camera5, keys: ["Digit5"] },
{ name: Controls.camera6, keys: ["Digit6"] },
{ name: Controls.camera7, keys: ["Digit7"] },
{ name: Controls.camera8, keys: ["Digit8"] },
{ name: Controls.camera9, keys: ["Digit9"] },
];
export function ObserverControls() {
// Don't let KeyboardControls handle stuff when metaKey is held.
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
// Let Cmd/Ctrl+K pass through for search focus.
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
return;
}
if (e.metaKey) {
e.stopImmediatePropagation();
}
};
window.addEventListener("keydown", handleKey, { capture: true });
window.addEventListener("keyup", handleKey, { capture: true });
return () => {
window.removeEventListener("keydown", handleKey, { capture: true });
window.removeEventListener("keyup", handleKey, { capture: true });
};
}, []);
return <CameraMovement />;
}

View file

@ -1,5 +1,5 @@
.Root { .Root {
position: fixed; position: absolute;
bottom: 16px; bottom: 16px;
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);

View file

@ -1,5 +1,5 @@
import { useKeyboardControls } from "@react-three/drei"; import { useKeyboardControls } from "@react-three/drei";
import { Controls } from "./ObserverControls"; import { Controls } from "./KeyboardAndMouseHandler";
import { useRecording } from "./RecordingProvider"; import { useRecording } from "./RecordingProvider";
import styles from "./KeyboardOverlay.module.css"; import styles from "./KeyboardOverlay.module.css";

View file

@ -1,15 +0,0 @@
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;
}) {
useEffect(() => {
return () => disposeLiveConnection();
}, []);
return children;
}

View file

@ -2,19 +2,26 @@ import { useRef, useEffect } from "react";
import { useFrame, useThree } from "@react-three/fiber"; import { useFrame, useThree } from "@react-three/fiber";
import { Vector3 } from "three"; import { Vector3 } from "three";
import { useKeyboardControls } from "@react-three/drei"; import { useKeyboardControls } from "@react-three/drei";
import { useLiveSelector } from "../state/liveConnectionStore"; import { createLogger } from "../logger";
import {
liveConnectionStore,
useLiveSelector,
} from "../state/liveConnectionStore";
import { useEngineStoreApi } from "../state/engineStore"; import { useEngineStoreApi } from "../state/engineStore";
import { streamPlaybackStore } from "../state/streamPlaybackStore"; import { streamPlaybackStore } from "../state/streamPlaybackStore";
import { Controls, MOUSE_SENSITIVITY, ARROW_LOOK_SPEED } from "./ObserverControls"; import {
Controls,
MOUSE_SENSITIVITY,
ARROW_LOOK_SPEED,
} from "./KeyboardAndMouseHandler";
import { useControls } from "./SettingsProvider"; import { useControls } from "./SettingsProvider";
import { useTick, TICK_RATE } from "./TickProvider"; import { useTick, TICK_RATE } from "./TickProvider";
import { import { yawPitchToQuaternion, MAX_PITCH } from "../stream/streamHelpers";
yawPitchToQuaternion,
MAX_PITCH,
} from "../stream/streamHelpers";
import type { StreamRecording, StreamCamera } from "../stream/types"; import type { StreamRecording, StreamCamera } from "../stream/types";
import type { LiveStreamAdapter } from "../stream/liveStreaming"; import type { LiveStreamAdapter } from "../stream/liveStreaming";
const log = createLogger("LiveObserver");
const TICK_INTERVAL = 1 / TICK_RATE; const TICK_INTERVAL = 1 / TICK_RATE;
// Scratch objects to avoid per-frame allocations. // Scratch objects to avoid per-frame allocations.
@ -50,7 +57,7 @@ export function LiveObserver() {
const store = useEngineStoreApi(); const store = useEngineStoreApi();
const { speedMultiplier } = useControls(); const { speedMultiplier } = useControls();
const activeAdapterRef = useRef<LiveStreamAdapter | null>(null); const activeAdapterRef = useRef<LiveStreamAdapter | null>(null);
const { gl } = useThree(); const gl = useThree((state) => state.gl);
const [, getKeys] = useKeyboardControls<Controls>(); const [, getKeys] = useKeyboardControls<Controls>();
// Accumulated rotation deltas since last move was sent. Mouse events and // Accumulated rotation deltas since last move was sent. Mouse events and
@ -73,15 +80,22 @@ export function LiveObserver() {
// Wire adapter to engine store. // Wire adapter to engine store.
useEffect(() => { useEffect(() => {
if (adapter && (gameStatus === "connected" || gameStatus === "authenticating")) { if (
adapter &&
(gameStatus === "connected" || gameStatus === "authenticating")
) {
if (activeAdapterRef.current === adapter) return; if (activeAdapterRef.current === adapter) return;
console.log("[LiveObserver] wiring adapter to engine store"); log.info("wiring adapter to engine store");
const liveState = liveConnectionStore.getState();
const liveRecording: StreamRecording = { const liveRecording: StreamRecording = {
source: "live", source: "live",
duration: Infinity, duration: Infinity,
missionName: null, missionName: liveState.mapName ?? null,
gameType: null, gameType: null,
serverDisplayName: liveState.serverName ?? null,
recorderName: liveState.warriorName ?? null,
recordingDate: null,
streamingPlayback: adapter, streamingPlayback: adapter,
}; };
@ -92,7 +106,11 @@ export function LiveObserver() {
predRef.current.initialized = false; predRef.current.initialized = false;
predRef.current.lastSyncedCamera = null; predRef.current.lastSyncedCamera = null;
} else if (!adapter && activeAdapterRef.current) { } else if (!adapter && activeAdapterRef.current) {
// Only clear the recording if it's still the live one we set.
const current = store.getState().playback.recording;
if (current?.source === "live") {
store.getState().setRecording(null); store.getState().setRecording(null);
}
activeAdapterRef.current = null; activeAdapterRef.current = null;
predRef.current.initialized = false; predRef.current.initialized = false;
} }
@ -165,7 +183,7 @@ export function LiveObserver() {
if (!activeAdapterRef.current) return; if (!activeAdapterRef.current) return;
activeAdapterRef.current.toggleObserverMode(); activeAdapterRef.current.toggleObserverMode();
console.log(`[LiveObserver] observer mode: ${activeAdapterRef.current.observerMode}`); log.info("observer mode: %s", activeAdapterRef.current.observerMode);
}; };
window.addEventListener("keydown", handleKey); window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey); return () => window.removeEventListener("keydown", handleKey);
@ -274,12 +292,15 @@ export function LiveObserver() {
// Interpolate between previous and current tick prediction, then add // Interpolate between previous and current tick prediction, then add
// pending (unconsumed) mouse/arrow deltas so rotation responds at frame // pending (unconsumed) mouse/arrow deltas so rotation responds at frame
// rate rather than waiting for the next useTick to consume them. // rate rather than waiting for the next useTick to consume them.
const interpYaw = pred.prevYaw + (pred.yaw - pred.prevYaw) * t + deltaYawRef.current; const interpYaw =
pred.prevYaw + (pred.yaw - pred.prevYaw) * t + deltaYawRef.current;
const interpPitch = Math.max( const interpPitch = Math.max(
-MAX_PITCH, -MAX_PITCH,
Math.min( Math.min(
MAX_PITCH, MAX_PITCH,
pred.prevPitch + (pred.pitch - pred.prevPitch) * t + deltaPitchRef.current, pred.prevPitch +
(pred.pitch - pred.prevPitch) * t +
deltaPitchRef.current,
), ),
); );
@ -314,7 +335,9 @@ export function LiveObserver() {
if (_orbitDir.lengthSq() > 1e-8) { if (_orbitDir.lengthSq() > 1e-8) {
_orbitDir.normalize(); _orbitDir.normalize();
const orbitDistance = Math.max(0.1, serverCam.orbitDistance ?? 4); const orbitDistance = Math.max(0.1, serverCam.orbitDistance ?? 4);
state.camera.position.copy(_orbitTarget).addScaledVector(_orbitDir, orbitDistance); state.camera.position
.copy(_orbitTarget)
.addScaledVector(_orbitDir, orbitDistance);
state.camera.lookAt(_orbitTarget); state.camera.lookAt(_orbitTarget);
} }
} }
@ -323,13 +346,16 @@ export function LiveObserver() {
// from StreamingController's server snapshot interpolation). // from StreamingController's server snapshot interpolation).
state.camera.quaternion.set(qx, qy, qz, qw); state.camera.quaternion.set(qx, qy, qz, qw);
} }
}, 1); });
// Clean up on unmount. // Clean up on unmount.
useEffect(() => { useEffect(() => {
return () => { return () => {
if (activeAdapterRef.current) { if (activeAdapterRef.current) {
const current = store.getState().playback.recording;
if (current?.source === "live") {
store.getState().setRecording(null); store.getState().setRecording(null);
}
activeAdapterRef.current = null; activeAdapterRef.current = null;
} }
}; };

View file

@ -7,6 +7,10 @@
composes: ButtonLabel from "./InspectorControls.module.css"; composes: ButtonLabel from "./InspectorControls.module.css";
} }
.DemoIcon { .ButtonHint {
font-size: 19px; composes: ButtonHint from "./InspectorControls.module.css";
}
.DemoIcon {
/* font-size: 20px; */
} }

View file

@ -1,10 +1,21 @@
import { useCallback, useRef } from "react"; import { useCallback, useRef } from "react";
import { MdOndemandVideo } from "react-icons/md"; import { MdOndemandVideo } from "react-icons/md";
import { createLogger } from "../logger";
import { liveConnectionStore } from "../state/liveConnectionStore";
import { usePlaybackActions, useRecording } from "./RecordingProvider"; import { usePlaybackActions, useRecording } from "./RecordingProvider";
import { createDemoStreamingRecording } from "../stream/demoStreaming";
import styles from "./LoadDemoButton.module.css"; import styles from "./LoadDemoButton.module.css";
export function LoadDemoButton() { const log = createLogger("LoadDemoButton");
export function LoadDemoButton({
isActive = false,
choosingMap = false,
onCancelChoosingMap,
}: {
isActive?: boolean;
choosingMap?: boolean;
onCancelChoosingMap?: () => void;
}) {
const recording = useRecording(); const recording = useRecording();
const isDemoLoaded = recording?.source === "demo"; const isDemoLoaded = recording?.source === "demo";
const { setRecording } = usePlaybackActions(); const { setRecording } = usePlaybackActions();
@ -12,14 +23,18 @@ export function LoadDemoButton() {
const parseTokenRef = useRef(0); const parseTokenRef = useRef(0);
const handleClick = useCallback(() => { const handleClick = useCallback(() => {
if (choosingMap && isDemoLoaded) {
onCancelChoosingMap?.();
return;
}
if (isDemoLoaded) { if (isDemoLoaded) {
// Unload the current recording. // Unload the recording/parser but leave entities frozen in the store.
parseTokenRef.current += 1; parseTokenRef.current += 1;
setRecording(null); setRecording(null);
return; return;
} }
inputRef.current?.click(); inputRef.current?.click();
}, [isDemoLoaded, setRecording]); }, [isDemoLoaded, choosingMap, onCancelChoosingMap, setRecording]);
const handleFileChange = useCallback( const handleFileChange = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => { async (e: React.ChangeEvent<HTMLInputElement>) => {
@ -31,14 +46,20 @@ export function LoadDemoButton() {
const buffer = await file.arrayBuffer(); const buffer = await file.arrayBuffer();
const parseToken = parseTokenRef.current + 1; const parseToken = parseTokenRef.current + 1;
parseTokenRef.current = parseToken; parseTokenRef.current = parseToken;
const { createDemoStreamingRecording } =
await import("../stream/demoStreaming");
const recording = await createDemoStreamingRecording(buffer); const recording = await createDemoStreamingRecording(buffer);
if (parseTokenRef.current !== parseToken) { if (parseTokenRef.current !== parseToken) {
return; return;
} }
// Disconnect from any live server before loading the demo.
const liveState = liveConnectionStore.getState();
liveState.disconnectServer();
liveState.disconnectRelay();
// Metadata-first: mission/game-mode sync happens immediately. // Metadata-first: mission/game-mode sync happens immediately.
setRecording(recording); setRecording(recording);
} catch (err) { } catch (err) {
console.error("Failed to load demo:", err); log.error("Failed to load demo: %o", err);
} }
}, },
[setRecording], [setRecording],
@ -59,12 +80,16 @@ export function LoadDemoButton() {
aria-label={isDemoLoaded ? "Unload demo" : "Load demo (.rec)"} aria-label={isDemoLoaded ? "Unload demo" : "Load demo (.rec)"}
title={isDemoLoaded ? "Unload demo" : "Load demo (.rec)"} title={isDemoLoaded ? "Unload demo" : "Load demo (.rec)"}
onClick={handleClick} onClick={handleClick}
data-active={isDemoLoaded ? "true" : undefined} data-active={isActive}
disabled={recording != null && !isDemoLoaded}
> >
<MdOndemandVideo className={styles.DemoIcon} /> <MdOndemandVideo className={styles.DemoIcon} />
<span className={styles.ButtonLabel}> <span className={styles.ButtonLabel}>Demo</span>
{isDemoLoaded ? "Unload demo" : "Demo"} <span className={styles.ButtonHint}>
{choosingMap && isDemoLoaded
? "Return to demo"
: isDemoLoaded
? "Click to unload"
: "Load a .rec file"}
</span> </span>
</button> </button>
</> </>

View file

@ -1,12 +1,3 @@
.CanvasContainer {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 0;
}
.LoadingIndicator { .LoadingIndicator {
position: absolute; position: absolute;
top: 50%; top: 50%;

View file

@ -0,0 +1,22 @@
import styles from "./LoadingIndicator.module.css";
export function LoadingIndicator({
isLoading,
progress,
}: {
isLoading: boolean;
progress: number;
}) {
return (
<div className={styles.LoadingIndicator} data-complete={!isLoading}>
<div className={styles.Spinner} />
<div className={styles.Progress}>
<div
className={styles.ProgressBar}
style={{ width: `${progress * 100}%` }}
/>
</div>
<div className={styles.ProgressText}>{Math.round(progress * 100)}%</div>
</div>
);
}

View file

@ -1,36 +1,14 @@
.Dialog { .Dialog {
position: relative; composes: Dialog from "./GameDialog.module.css";
width: 800px; width: 800px;
height: 600px; height: 600px;
max-width: calc(100dvw - 40px);
max-height: calc(100dvh - 40px);
display: grid; display: grid;
grid-template-columns: 100%; grid-template-columns: 100%;
grid-template-rows: 1fr auto; grid-template-rows: 1fr auto;
background: rgba(20, 37, 38, 0.8);
border: 1px solid rgba(65, 131, 139, 0.6);
border-radius: 4px;
box-shadow:
0 0 50px rgba(0, 0, 0, 0.4),
inset 0 0 60px rgba(1, 7, 13, 0.6);
color: #bccec3;
font-size: 14px;
line-height: 1.5;
overflow: hidden;
outline: none;
user-select: text;
-webkit-touch-callout: default;
} }
.Overlay { .Overlay {
position: fixed; composes: Overlay from "./GameDialog.module.css";
inset: 0;
background: rgba(0, 0, 0, 0.7);
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
} }
.Body { .Body {
@ -99,7 +77,7 @@
.MapQuote cite { .MapQuote cite {
font-style: normal; font-style: normal;
font-size: 12px; font-size: 12px;
color: rgba(255, 255, 255, 0.45); color: rgba(215, 237, 203, 0.5);
display: block; display: block;
} }
@ -125,7 +103,7 @@
.MusicTrack { .MusicTrack {
margin-top: 16px; margin-top: 16px;
font-size: 14px; font-size: 14px;
color: rgba(202, 208, 172, 0.5); color: rgba(215, 237, 203, 0.5);
font-style: italic; font-style: italic;
display: flex; display: flex;
align-items: center; align-items: center;
@ -174,7 +152,7 @@
} }
.CloseButton { .CloseButton {
composes: DialogButton from "./DialogButton.module.css"; composes: DialogButton from "./GameDialog.module.css";
} }
.Hint { .Hint {

View file

@ -50,7 +50,9 @@ function getBitmapUrl(
try { try {
const key = getStandardTextureResourceKey(`textures/gui/${bitmap}`); const key = getStandardTextureResourceKey(`textures/gui/${bitmap}`);
return getUrlForPath(key); return getUrlForPath(key);
} catch { /* expected */ } } catch {
/* expected */
}
} }
// Fall back to Load_<MissionName>.png convention (multiplayer missions) // Fall back to Load_<MissionName>.png convention (multiplayer missions)
try { try {
@ -58,7 +60,9 @@ function getBitmapUrl(
`textures/gui/Load_${missionName}`, `textures/gui/Load_${missionName}`,
); );
return getUrlForPath(key); return getUrlForPath(key);
} catch { /* expected */ } } catch {
/* expected */
}
return null; return null;
} }
@ -161,12 +165,10 @@ function MusicPlayer({ track }: { track: string }) {
} }
export function MapInfoDialog({ export function MapInfoDialog({
open,
onClose, onClose,
missionName, missionName,
missionType, missionType,
}: { }: {
open: boolean;
onClose: () => void; onClose: () => void;
missionName: string; missionName: string;
missionType: string; missionType: string;
@ -175,19 +177,18 @@ export function MapInfoDialog({
const dialogRef = useRef<HTMLDivElement>(null); const dialogRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
if (open) {
dialogRef.current?.focus(); dialogRef.current?.focus();
try { try {
document.exitPointerLock(); document.exitPointerLock();
} catch { /* expected */ } } catch {
/* expected */
} }
}, [open]); }, []);
// While open: block keyboard events from reaching drei, and handle close keys. // While open: block keyboard events from reaching drei, and handle close keys.
useEffect(() => { useEffect(() => {
if (!open) return;
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (e.code === "KeyI" || e.key === "Escape") { if (e.key === "Escape") {
onClose(); onClose();
} else if (e.key === "k" && (e.metaKey || e.ctrlKey)) { } else if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
onClose(); onClose();
@ -204,9 +205,7 @@ export function MapInfoDialog({
window.removeEventListener("keydown", handleKeyDown, { capture: true }); window.removeEventListener("keydown", handleKeyDown, { capture: true });
window.removeEventListener("keyup", handleKeyUp, { capture: true }); window.removeEventListener("keyup", handleKeyUp, { capture: true });
}; };
}, [open, onClose]); }, [onClose]);
if (!open) return null;
const missionGroupProps = parsedMission const missionGroupProps = parsedMission
? getMissionGroupProps(parsedMission.ast) ? getMissionGroupProps(parsedMission.ast)
@ -322,7 +321,7 @@ export function MapInfoDialog({
<button className={styles.CloseButton} onClick={onClose}> <button className={styles.CloseButton} onClick={onClose}>
Close Close
</button> </button>
<span className={styles.Hint}>I or Esc to close</span> <span className={styles.Hint}>Esc to close</span>
</div> </div>
</div> </div>
</div> </div>

View file

@ -0,0 +1,169 @@
.Frame {
display: grid;
grid-template-columns: auto 1fr;
grid-template-rows: auto 1fr auto;
grid-template-areas:
"sidebar toolbar"
"sidebar content"
"sidebar footer";
width: 100vw;
width: 100dvw;
height: 100vh;
height: 100dvh;
overflow: hidden;
}
.Toolbar {
display: flex;
align-items: center;
position: relative;
grid-area: toolbar;
background: rgb(25, 31, 31);
color: #fff;
border-bottom: 1px solid rgb(70, 85, 85);
box-shadow: 0 0 3px 1px rgba(0, 0, 0, 0.4);
z-index: 3;
view-transition-class: layout;
}
.CancelButton {
padding: 4px 6px;
font-size: 12px;
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 3px;
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.7);
cursor: pointer;
white-space: nowrap;
z-index: 1;
}
.CancelButton:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
.Sidebar {
position: relative;
grid-area: sidebar;
width: 320px;
height: 100%;
min-height: 0;
overflow-y: auto;
background: rgb(25, 31, 31);
color: #fff;
border-right: 1px solid rgb(70, 85, 85);
box-shadow: 0 0 3px 1px rgba(0, 0, 0, 0.4);
z-index: 2;
}
.Content {
position: relative;
z-index: 0;
grid-area: content;
min-width: 0;
min-height: 0;
overflow: hidden;
}
.ThreeView {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 0;
}
.PlayerBar {
position: relative;
grid-area: footer;
background: rgb(25, 31, 31);
color: #fff;
border-top: 1px solid rgb(70, 85, 85);
box-shadow: 0 0 3px 1px rgba(0, 0, 0, 0.4);
z-index: 1;
}
.ToggleSidebarButton {
flex: 0 0 auto;
display: grid;
place-content: center;
min-width: 30px;
min-height: 30px;
padding: 2px;
margin: 0 0 0 8px;
font-size: 24px;
border-radius: 4px;
background: transparent;
color: #fff;
border: 0;
cursor: pointer;
opacity: 0.6;
}
.ToggleSidebarButton[data-orientation="top"] {
display: none;
min-height: 48px;
min-width: 48px;
margin: 0;
}
.ToggleSidebarButton:not(:disabled):hover {
opacity: 1;
}
.ToggleSidebarButton svg {
pointer-events: none;
}
.Backdrop {
display: none;
}
@media (max-width: 899px) {
.Frame {
display: grid;
grid-template-columns: 1fr;
grid-template-rows: auto 1fr auto;
grid-template-areas:
"toolbar"
"content"
"footer";
}
.Sidebar {
justify-self: center;
grid-area: content;
grid-row: content-start / footer-end;
width: auto;
max-width: 500px;
height: calc(100% + 1px);
margin: 0 -1px 0 -1px;
border: 1px solid rgb(70, 85, 85);
border-top: 0;
}
.Toolbar {
justify-content: center;
}
.ToggleSidebarButton[data-orientation="left"] {
display: none;
}
.ToggleSidebarButton[data-orientation="top"] {
display: grid;
}
.Backdrop {
display: block;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
z-index: 2;
}
}

View file

@ -0,0 +1,387 @@
"use client";
import {
useState,
useEffect,
useCallback,
startTransition,
Suspense,
useRef,
lazy,
Activity,
ViewTransition,
} from "react";
import { Camera } from "three";
import { InspectorControls } from "@/src/components/InspectorControls";
import { MissionSelect } from "@/src/components/MissionSelect";
import { StreamingMissionInfo } from "@/src/components/StreamingMissionInfo";
import { SettingsProvider } from "@/src/components/SettingsProvider";
import { ObserverCamera } from "@/src/components/ObserverCamera";
import { AudioProvider } from "@/src/components/AudioContext";
import { CamerasProvider } from "@/src/components/CamerasProvider";
import {
RecordingProvider,
useRecording,
} from "@/src/components/RecordingProvider";
import { EntityScene } from "@/src/components/EntityScene";
import { TickProvider } from "@/src/components/TickProvider";
import { SceneLighting } from "@/src/components/SceneLighting";
import { useFeatures } from "@/src/components/FeaturesProvider";
import {
liveConnectionStore,
useLiveSelector,
} from "@/src/state/liveConnectionStore";
import { usePublicWindowAPI } from "@/src/components/usePublicWindowAPI";
import {
CurrentMission,
useFogQueryState,
useMissionQueryState,
} from "@/src/components/useQueryParams";
import { ThreeCanvas, InvalidateFunction } from "@/src/components/ThreeCanvas";
import { InputHandlers, InputProvider } from "./InputHandlers";
import { VisualInput } from "./VisualInput";
import { LoadingIndicator } from "./LoadingIndicator";
import { AudioEnabled } from "./AudioEnabled";
import { DebugEnabled } from "./DebugEnabled";
import { engineStore } from "../state/engineStore";
import {
gameEntityStore,
useDataSource,
useMissionName,
useMissionType,
} from "../state/gameEntityStore";
import { getMissionInfo } from "../manifest";
import {
LuPanelLeftClose,
LuPanelLeftOpen,
LuPanelTopClose,
LuPanelTopOpen,
} from "react-icons/lu";
import styles from "./MapInspector.module.css";
function createLazy(
name: string,
loader: () => Promise<{
[name]: React.ComponentType<any>;
}>,
) {
return lazy(() => loader().then((mod) => ({ default: mod[name] })));
}
const StreamingController = createLazy(
"StreamingController",
() => import("@/src/components/StreamingController"),
);
const DemoPlaybackControls = createLazy(
"DemoPlaybackControls",
() => import("@/src/components/DemoPlaybackControls"),
);
const DebugElements = createLazy(
"DebugElements",
() => import("@/src/components/DebugElements"),
);
const Mission = createLazy("Mission", () => import("@/src/components/Mission"));
const LiveObserver = createLazy(
"LiveObserver",
() => import("@/src/components/LiveObserver"),
);
const ChatSoundPlayer = createLazy(
"ChatSoundPlayer",
() => import("@/src/components/ChatSoundPlayer"),
);
const PlayerHUD = createLazy(
"PlayerHUD",
() => import("@/src/components/PlayerHUD"),
);
const MapInfoDialog = createLazy(
"MapInfoDialog",
() => import("@/src/components/MapInfoDialog"),
);
const ServerBrowser = createLazy(
"ServerBrowser",
() => import("@/src/components/ServerBrowser"),
);
export function MapInspector() {
const [currentMission, setCurrentMission] = useMissionQueryState();
const [fogEnabledOverride, setFogEnabledOverride] = useFogQueryState();
const clearFogEnabledOverride = useCallback(() => {
setFogEnabledOverride(null);
}, [setFogEnabledOverride]);
const features = useFeatures();
const { missionName, missionType } = currentMission;
const [mapInfoOpen, setMapInfoOpen] = useState(false);
const [serverBrowserOpen, setServerBrowserOpen] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState(true);
const [choosingMap, setChoosingMap] = useState(false);
const [missionLoadingProgress, setMissionLoadingProgress] = useState(0);
const [showLoadingIndicator, setShowLoadingIndicator] = useState(true);
const changeMission = useCallback(
(mission: CurrentMission) => {
window.location.hash = "";
clearFogEnabledOverride();
setChoosingMap(false);
// Disconnect from any live server, unload any active recording, and
// clear stream state before loading the new mission in map mode.
const liveState = liveConnectionStore.getState();
liveState.disconnectServer();
liveState.disconnectRelay();
engineStore.getState().setRecording(null);
gameEntityStore.getState().endStreaming();
setCurrentMission(mission);
},
[setCurrentMission, clearFogEnabledOverride],
);
usePublicWindowAPI({ onChangeMission: changeMission });
const recording = useRecording();
const dataSource = useDataSource();
const hasStreamData = dataSource === "demo" || dataSource === "live";
const hasLiveAdapter = useLiveSelector((s) => s.adapter != null);
// Sync the mission query param when streaming data provides a mission name.
const streamMissionName = useMissionName();
const streamMissionType = useMissionType();
useEffect(() => {
if (!hasStreamData || !streamMissionName) return;
try {
const info = getMissionInfo(streamMissionName);
const matchedType =
streamMissionType && info.missionTypes.includes(streamMissionType)
? streamMissionType
: undefined;
setCurrentMission({
missionName: streamMissionName,
missionType: matchedType,
});
} catch {
// Mission not in manifest — remove the query param.
setCurrentMission(null);
}
}, [hasStreamData, streamMissionName, streamMissionType, setCurrentMission]);
// Cancel "choosing map" when a new recording loads.
useEffect(() => {
if (recording) {
setChoosingMap(false);
}
}, [recording]);
const loadingProgress = missionLoadingProgress;
const isLoading = loadingProgress < 1;
// Keep the loading indicator visible briefly after reaching 100%
useEffect(() => {
if (isLoading) {
setShowLoadingIndicator(true);
} else {
const timer = setTimeout(() => setShowLoadingIndicator(false), 500);
return () => clearTimeout(timer);
}
}, [isLoading]);
const handleLoadingChange = useCallback(
(_loading: boolean, progress: number = 0) => {
setMissionLoadingProgress(progress);
},
[],
);
const cameraRef = useRef<Camera | null>(null);
const invalidateRef = useRef<InvalidateFunction | null>(null);
return (
<main className={styles.Frame}>
<RecordingProvider>
<SettingsProvider
fogEnabledOverride={fogEnabledOverride}
onClearFogEnabledOverride={clearFogEnabledOverride}
>
<header
className={styles.Toolbar}
onKeyDown={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
<button
type="button"
className={styles.ToggleSidebarButton}
data-orientation="top"
aria-label={sidebarOpen ? "Close sidebar" : "Open sidebar"}
title={sidebarOpen ? "Close sidebar" : "Open sidebar"}
onClick={(event) => {
startTransition(() => setSidebarOpen((open) => !open));
}}
>
{sidebarOpen ? <LuPanelTopClose /> : <LuPanelTopOpen />}
</button>
<button
type="button"
className={styles.ToggleSidebarButton}
data-orientation="left"
aria-label={sidebarOpen ? "Close sidebar" : "Open sidebar"}
title={sidebarOpen ? "Close sidebar" : "Open sidebar"}
onClick={(event) => {
startTransition(() => setSidebarOpen((open) => !open));
}}
>
{sidebarOpen ? <LuPanelLeftClose /> : <LuPanelLeftOpen />}
</button>
<Activity
mode={hasStreamData && !choosingMap ? "visible" : "hidden"}
>
<StreamingMissionInfo />
</Activity>
<Activity
mode={!hasStreamData || choosingMap ? "visible" : "hidden"}
>
<MissionSelect
value={choosingMap ? "" : missionName}
missionType={choosingMap ? "" : missionType}
onChange={changeMission}
autoFocus={choosingMap}
/>
{choosingMap && (
<button
type="button"
className={styles.CancelButton}
onClick={() => {
setChoosingMap(false);
}}
>
Cancel
</button>
)}
</Activity>
</header>
{sidebarOpen ? <div className={styles.Backdrop} /> : null}
<Activity mode={sidebarOpen ? "visible" : "hidden"}>
<ViewTransition>
<div
className={styles.Sidebar}
onKeyDown={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
data-open={sidebarOpen}
>
<InspectorControls
missionName={missionName}
missionType={missionType}
onOpenMapInfo={() => setMapInfoOpen(true)}
onOpenServerBrowser={
features.live ? () => setServerBrowserOpen(true) : undefined
}
onChooseMap={
hasStreamData
? () => {
setChoosingMap(true);
}
: undefined
}
onCancelChoosingMap={() => {
setChoosingMap(false);
}}
choosingMap={choosingMap}
cameraRef={cameraRef}
invalidateRef={invalidateRef}
/>
</div>
</ViewTransition>
</Activity>
<InputProvider>
<div className={styles.Content}>
<div className={styles.ThreeView}>
<ThreeCanvas
dpr={mapInfoOpen || serverBrowserOpen ? 0.25 : undefined}
onCreated={(state) => {
cameraRef.current = state.camera;
invalidateRef.current = state.invalidate;
}}
>
<TickProvider>
<CamerasProvider>
<InputHandlers />
<AudioProvider>
<SceneLighting />
<Suspense>
<EntityScene />
</Suspense>
<ObserverCamera />
<AudioEnabled>
<ChatSoundPlayer />
</AudioEnabled>
<DebugEnabled>
<DebugElements />
</DebugEnabled>
{recording ? (
<Suspense>
<StreamingController recording={recording} />
</Suspense>
) : null}
{!hasStreamData ? (
<Suspense>
<Mission
key={`${missionName}~${missionType}`}
name={missionName}
missionType={missionType}
onLoadingChange={handleLoadingChange}
/>
</Suspense>
) : null}
{hasLiveAdapter ? (
<Suspense>
<LiveObserver />
</Suspense>
) : null}
</AudioProvider>
</CamerasProvider>
</TickProvider>
</ThreeCanvas>
</div>
{hasStreamData ? (
<Suspense>
<PlayerHUD />
</Suspense>
) : null}
<VisualInput />
{showLoadingIndicator && (
<LoadingIndicator
isLoading={isLoading}
progress={loadingProgress}
/>
)}
</div>
</InputProvider>
<footer
className={styles.PlayerBar}
onKeyDown={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
{recording?.source === "demo" ? (
<Suspense>
<DemoPlaybackControls />
</Suspense>
) : null}
</footer>
{mapInfoOpen ? (
<Suspense>
<MapInfoDialog
onClose={() => setMapInfoOpen(false)}
missionName={missionName}
missionType={missionType ?? ""}
/>
</Suspense>
) : null}
{serverBrowserOpen ? (
<Suspense>
<ServerBrowser onClose={() => setServerBrowserOpen(false)} />
</Suspense>
) : null}
</SettingsProvider>
</RecordingProvider>
</main>
);
}

View file

@ -19,9 +19,13 @@ import {
getSourceAndPath, getSourceAndPath,
} from "../manifest"; } from "../manifest";
import { MissionProvider } from "./MissionContext"; import { MissionProvider } from "./MissionContext";
import { engineStore, gameEntityStore } from "../state"; import { engineStore } from "../state/engineStore";
import { gameEntityStore } from "../state/gameEntityStore";
import { ignoreScripts } from "../torqueScript/ignoreScripts"; import { ignoreScripts } from "../torqueScript/ignoreScripts";
import { walkMissionTree } from "../stream/missionEntityBridge"; import { walkMissionTree } from "../stream/missionEntityBridge";
import { createLogger } from "../logger";
const log = createLogger("Mission");
const loadScript = createScriptLoader(); const loadScript = createScriptLoader();
// Shared cache for parsed scripts - survives runtime restarts // Shared cache for parsed scripts - survives runtime restarts
@ -105,8 +109,16 @@ function useExecutedMission(
engineStore.getState().setRuntime(runtime); engineStore.getState().setRuntime(runtime);
const missionGroup = runtime.getObjectByName("MissionGroup"); const missionGroup = runtime.getObjectByName("MissionGroup");
if (missionGroup) { if (missionGroup) {
const gameEntities = walkMissionTree(missionGroup, runtime); const gameEntities = walkMissionTree(
missionGroup,
runtime,
missionType,
);
gameEntityStore.getState().setAllEntities(gameEntities); gameEntityStore.getState().setAllEntities(gameEntities);
gameEntityStore.getState().setMissionInfo({
missionName,
missionType: missionType ?? undefined,
});
} }
setState({ ready: true, runtime, progress: 1 }); setState({ ready: true, runtime, progress: 1 });
}) })
@ -114,7 +126,7 @@ function useExecutedMission(
if (err instanceof Error && err.name === "AbortError") { if (err instanceof Error && err.name === "AbortError") {
return; return;
} }
console.error("Mission runtime failed to become ready:", err); log.error("Mission runtime failed to become ready: %o", err);
}); });
// Subscribe as soon as the runtime exists so no mutation batches are missed // Subscribe as soon as the runtime exists so no mutation batches are missed

View file

@ -2,6 +2,8 @@
position: relative; position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
margin: 10px 10px 10px 4px;
z-index: 2;
} }
.Shortcut { .Shortcut {
@ -41,7 +43,10 @@
} }
.Input::placeholder { .Input::placeholder {
color: transparent; font-size: 12px;
font-family: inherit;
color: #777;
/* color: transparent; */
} }
.SelectedValue { .SelectedValue {
@ -63,6 +68,7 @@
color: #fff; color: #fff;
font-weight: 600; font-weight: 600;
font-size: 14px; font-size: 14px;
line-height: calc(18 / 14);
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@ -158,6 +164,7 @@
.ItemType { .ItemType {
font-size: 10px; font-size: 10px;
font-weight: 600; font-weight: 600;
line-height: calc(13 / 10);
padding: 2px 5px; padding: 2px 5px;
border-radius: 3px; border-radius: 3px;
background: rgba(255, 157, 0, 0.4); background: rgba(255, 157, 0, 0.4);
@ -179,3 +186,13 @@
color: rgba(255, 255, 255, 0.5); color: rgba(255, 255, 255, 0.5);
text-align: center; text-align: center;
} }
.Backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 1;
}

View file

@ -1,4 +1,5 @@
import { import {
Activity,
Fragment, Fragment,
startTransition, startTransition,
useEffect, useEffect,
@ -15,6 +16,7 @@ import {
ComboboxGroup, ComboboxGroup,
ComboboxGroupLabel, ComboboxGroupLabel,
useComboboxStore, useComboboxStore,
useStoreState,
} from "@ariakit/react"; } from "@ariakit/react";
import { matchSorter } from "match-sorter"; import { matchSorter } from "match-sorter";
import { getMissionInfo, getMissionList, getSourceAndPath } from "../manifest"; import { getMissionInfo, getMissionList, getSourceAndPath } from "../manifest";
@ -154,6 +156,7 @@ export function MissionSelect({
missionType, missionType,
onChange, onChange,
disabled, disabled,
autoFocus,
}: { }: {
value: string; value: string;
missionType: string; missionType: string;
@ -165,6 +168,7 @@ export function MissionSelect({
missionType: string | undefined; missionType: string | undefined;
}) => void; }) => void;
disabled?: boolean; disabled?: boolean;
autoFocus?: boolean;
}) { }) {
const [searchValue, setSearchValue] = useState(""); const [searchValue, setSearchValue] = useState("");
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
@ -195,6 +199,8 @@ export function MissionSelect({
}, },
}); });
const isOpen = useStoreState(combobox, "open");
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) { if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
@ -264,17 +270,23 @@ export function MissionSelect({
return ( return (
<ComboboxProvider store={combobox}> <ComboboxProvider store={combobox}>
<Activity mode={isOpen ? "visible" : "hidden"}>
<div className={styles.Backdrop} />
</Activity>
<div className={styles.InputWrapper}> <div className={styles.InputWrapper}>
<Combobox <Combobox
ref={inputRef} ref={inputRef}
autoSelect autoSelect
autoFocus={autoFocus}
disabled={disabled} disabled={disabled}
placeholder={displayValue} placeholder={selectedMission ? undefined : "Choose a map…"}
className={styles.Input} className={styles.Input}
onFocus={() => { onFocus={() => {
try { try {
document.exitPointerLock(); document.exitPointerLock();
} catch { /* expected */ } } catch {
/* expected */
}
combobox.show(); combobox.show();
}} }}
onKeyDown={(e) => { onKeyDown={(e) => {
@ -283,6 +295,7 @@ export function MissionSelect({
} }
}} }}
/> />
{selectedMission && (
<div className={styles.SelectedValue}> <div className={styles.SelectedValue}>
<span className={styles.SelectedName}>{displayValue}</span> <span className={styles.SelectedName}>{displayValue}</span>
{missionType && ( {missionType && (
@ -291,6 +304,7 @@ export function MissionSelect({
</span> </span>
)} )}
</div> </div>
)}
<kbd className={styles.Shortcut}>{isMac ? "⌘K" : "^K"}</kbd> <kbd className={styles.Shortcut}>{isMac ? "⌘K" : "^K"}</kbd>
</div> </div>
<ComboboxPopover <ComboboxPopover

View file

@ -36,10 +36,10 @@ import {
particleFragmentShader, particleFragmentShader,
} from "../particles/shaders"; } from "../particles/shaders";
import type { EmitterDataResolved } from "../particles/types"; import type { EmitterDataResolved } from "../particles/types";
import type { import type { StreamSnapshot, StreamingPlayback } from "../stream/types";
StreamSnapshot, import { createLogger } from "../logger";
StreamingPlayback,
} from "../stream/types"; const log = createLogger("ParticleEffects");
import { useDebug, useSettings } from "./SettingsProvider"; import { useDebug, useSettings } from "./SettingsProvider";
import { useAudio } from "./AudioContext"; import { useAudio } from "./AudioContext";
import { import {
@ -50,7 +50,7 @@ import {
trackSound, trackSound,
untrackSound, untrackSound,
} from "./AudioEmitter"; } from "./AudioEmitter";
import { effectNow, engineStore } from "../state"; import { effectNow, engineStore } from "../state/engineStore";
// ── Constants ── // ── Constants ──
@ -96,9 +96,15 @@ function getParticleTexture(textureName: string): Texture {
// ── Debug geometry (reusable) ── // ── Debug geometry (reusable) ──
const _debugOriginGeo = new SphereGeometry(1, 6, 6); const _debugOriginGeo = new SphereGeometry(1, 6, 6);
const _debugOriginMat = new MeshBasicMaterial({ color: 0xff0000, wireframe: true }); const _debugOriginMat = new MeshBasicMaterial({
color: 0xff0000,
wireframe: true,
});
const _debugParticleGeo = new BoxGeometry(0.3, 0.3, 0.3); const _debugParticleGeo = new BoxGeometry(0.3, 0.3, 0.3);
const _debugParticleMat = new MeshBasicMaterial({ color: 0x00ff00, wireframe: true }); const _debugParticleMat = new MeshBasicMaterial({
color: 0x00ff00,
wireframe: true,
});
// ── Explosion wireframe sphere geometry (reusable) ── // ── Explosion wireframe sphere geometry (reusable) ──
@ -116,7 +122,10 @@ interface ActiveExplosionSphere {
} }
/** Create a text label sprite for an explosion sphere. */ /** Create a text label sprite for an explosion sphere. */
function createExplosionLabel(text: string, color: number): { sprite: Sprite; material: SpriteMaterial } { function createExplosionLabel(
text: string,
color: number,
): { sprite: Sprite; material: SpriteMaterial } {
const canvas = document.createElement("canvas"); const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d")!; const ctx = canvas.getContext("2d")!;
const fontSize = 32; const fontSize = 32;
@ -431,10 +440,10 @@ function getExplosionColor(dataBlock: string | undefined): number {
* Each entry is `{x, y, z}` with values in range 016000 (scale multiplier). * Each entry is `{x, y, z}` with values in range 016000 (scale multiplier).
* Falls back to `particleRadius` or a default of 5. * Falls back to `particleRadius` or a default of 5.
*/ */
function getExplosionRadius( function getExplosionRadius(expBlock: Record<string, unknown>): number {
expBlock: Record<string, unknown>, const sizes = expBlock.sizes as
): number { | Array<{ x: number; y: number; z: number }>
const sizes = expBlock.sizes as Array<{ x: number; y: number; z: number }> | undefined; | undefined;
if (Array.isArray(sizes) && sizes.length > 0) { if (Array.isArray(sizes) && sizes.length > 0) {
let maxVal = 0; let maxVal = 0;
for (const s of sizes) { for (const s of sizes) {
@ -552,14 +561,17 @@ function checkShaderCompilation(
material: ShaderMaterial, material: ShaderMaterial,
label: string, label: string,
): void { ): void {
const props = renderer.properties.get(material) as { currentProgram?: { program: WebGLProgram } }; const props = renderer.properties.get(material) as {
currentProgram?: { program: WebGLProgram };
};
const program = props.currentProgram; const program = props.currentProgram;
if (!program) return; // Not yet compiled. if (!program) return; // Not yet compiled.
const glProgram = program!.program; const glProgram = program!.program;
const glContext = renderer.getContext(); const glContext = renderer.getContext();
if (!glContext.getProgramParameter(glProgram, glContext.LINK_STATUS)) { if (!glContext.getProgramParameter(glProgram, glContext.LINK_STATUS)) {
console.error( log.error(
`[ParticleFX] Shader LINK ERROR (${label}):`, "Shader LINK ERROR (%s): %s",
label,
glContext.getProgramInfoLog(glProgram), glContext.getProgramInfoLog(glProgram),
); );
} }
@ -847,7 +859,8 @@ export function ParticleEffects({
group.add(sphereMesh); group.add(sphereMesh);
const labelText = `${entity.id}: ${entity.dataBlock ?? `expId:${entity.explosionDataBlockId}`}`; const labelText = `${entity.id}: ${entity.dataBlock ?? `expId:${entity.explosionDataBlockId}`}`;
const { sprite: labelSprite, material: labelMat } = createExplosionLabel(labelText, color); const { sprite: labelSprite, material: labelMat } =
createExplosionLabel(labelText, color);
labelSprite.position.set(origin[1], origin[2] + radius + 2, origin[0]); labelSprite.position.set(origin[1], origin[2] + radius + 2, origin[0]);
labelSprite.frustumCulled = false; labelSprite.frustumCulled = false;
group.add(labelSprite); group.add(labelSprite);
@ -891,9 +904,8 @@ export function ParticleEffects({
} }
// Clamp denormalized velocity values (parser bug workaround). // Clamp denormalized velocity values (parser bug workaround).
const initVelocity = Math.abs(swData.velocity) > 1e-10 const initVelocity =
? swData.velocity Math.abs(swData.velocity) > 1e-10 ? swData.velocity : 0;
: 0;
activeShockwavesRef.current.push({ activeShockwavesRef.current.push({
entityId: entity.id as string, entityId: entity.id as string,
@ -910,7 +922,6 @@ export function ParticleEffects({
}); });
} }
} }
} }
// Detect projectile entities with trail emitters (maintainEmitterId). // Detect projectile entities with trail emitters (maintainEmitterId).
@ -918,7 +929,10 @@ export function ParticleEffects({
for (const entity of snapshot.entities) { for (const entity of snapshot.entities) {
currentEntityIds.add(entity.id); currentEntityIds.add(entity.id);
if (!entity.maintainEmitterId || trailEntitiesRef.current.has(entity.id)) { if (
!entity.maintainEmitterId ||
trailEntitiesRef.current.has(entity.id)
) {
continue; continue;
} }
trailEntitiesRef.current.add(entity.id); trailEntitiesRef.current.add(entity.id);
@ -933,7 +947,10 @@ export function ParticleEffects({
? [...entity.position] ? [...entity.position]
: [0, 0, 0]; : [0, 0, 0];
const emitter = new EmitterInstance(emitterData, MAX_PARTICLES_PER_EMITTER); const emitter = new EmitterInstance(
emitterData,
MAX_PARTICLES_PER_EMITTER,
);
const texture = getParticleTexture(emitterData.particles.textureName); const texture = getParticleTexture(emitterData.particles.textureName);
const geometry = createParticleGeometry(MAX_PARTICLES_PER_EMITTER); const geometry = createParticleGeometry(MAX_PARTICLES_PER_EMITTER);
@ -980,7 +997,11 @@ export function ParticleEffects({
// One-time shader compilation check. // One-time shader compilation check.
if (!entry.shaderChecked) { if (!entry.shaderChecked) {
checkShaderCompilation(gl, entry.material, entry.isBurst ? "burst" : "stream"); checkShaderCompilation(
gl,
entry.material,
entry.isBurst ? "burst" : "stream",
);
entry.shaderChecked = true; entry.shaderChecked = true;
} }
@ -1166,7 +1187,13 @@ export function ParticleEffects({
// ── Audio: explosion impact sounds ── // ── Audio: explosion impact sounds ──
// Only process new audio events while playing to avoid triggering // Only process new audio events while playing to avoid triggering
// sounds during pause (existing sounds are frozen via AudioContext.suspend). // sounds during pause (existing sounds are frozen via AudioContext.suspend).
if (isPlaying && audioEnabled && audioLoader && audioListener && groupRef.current) { if (
isPlaying &&
audioEnabled &&
audioLoader &&
audioListener &&
groupRef.current
) {
for (const entity of snapshot.entities) { for (const entity of snapshot.entities) {
if ( if (
entity.type !== "Explosion" || entity.type !== "Explosion" ||
@ -1205,7 +1232,11 @@ export function ParticleEffects({
const projSounds = projectileSoundsRef.current; const projSounds = projectileSoundsRef.current;
for (const entity of snapshot.entities) { for (const entity of snapshot.entities) {
if (entity.type !== "Projectile" || !entity.dataBlockId || !entity.position) { if (
entity.type !== "Projectile" ||
!entity.dataBlockId ||
!entity.position
) {
continue; continue;
} }
if (projSounds.has(entity.id)) { if (projSounds.has(entity.id)) {
@ -1268,8 +1299,16 @@ export function ParticleEffects({
for (const [entityId, sound] of projSounds) { for (const [entityId, sound] of projSounds) {
if (!currentEntityIds.has(entityId)) { if (!currentEntityIds.has(entityId)) {
untrackSound(sound); untrackSound(sound);
try { sound.stop(); } catch { /* already stopped */ } try {
try { sound.disconnect(); } catch { /* already disconnected */ } sound.stop();
} catch {
/* already stopped */
}
try {
sound.disconnect();
} catch {
/* already disconnected */
}
groupRef.current?.remove(sound); groupRef.current?.remove(sound);
projSounds.delete(entityId); projSounds.delete(entityId);
} }
@ -1360,8 +1399,16 @@ export function ParticleEffects({
// Clean up projectile sounds. // Clean up projectile sounds.
for (const [, sound] of projectileSoundsRef.current) { for (const [, sound] of projectileSoundsRef.current) {
untrackSound(sound); untrackSound(sound);
try { sound.stop(); } catch { /* already stopped */ } try {
try { sound.disconnect(); } catch { /* already disconnected */ } sound.stop();
} catch {
/* already stopped */
}
try {
sound.disconnect();
} catch {
/* already disconnected */
}
if (group) group.remove(sound); if (group) group.remove(sound);
} }
projectileSoundsRef.current.clear(); projectileSoundsRef.current.clear();

View file

@ -8,12 +8,10 @@
pointer-events: none; pointer-events: none;
} }
/* ── Top-right cluster: compass + bars ── */
.TopRight { .TopRight {
position: absolute; position: absolute;
top: 56px; top: 10px;
right: 8px; right: 10px;
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
gap: 6px; gap: 6px;
@ -87,103 +85,12 @@
height: 6px; height: 6px;
} }
/* ── Chat Window (top-left) ── */
.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: 6px 8px;
user-select: text;
font-size: 12px;
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;
/* 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);
}
/* ── Team Scores (bottom-left) ── */ /* ── Team Scores (bottom-left) ── */
.TeamScores { .TeamScores {
position: absolute; position: absolute;
bottom: 130px; bottom: 8px;
left: 0; left: 8px;
font-family: monospace; font-family: monospace;
font-size: 12px; font-size: 12px;
} }
@ -226,7 +133,7 @@
.PackInventoryHUD { .PackInventoryHUD {
position: absolute; position: absolute;
bottom: 100px; bottom: 8px;
right: 8px; right: 8px;
display: flex; display: flex;
align-items: center; align-items: center;

View file

@ -1,20 +1,16 @@
import { useRef, useEffect, useState, useCallback } from "react"; import { useEngineSelector } from "../state/engineStore";
import { useRecording } from "./RecordingProvider";
import { useEngineSelector } from "../state";
import { textureToUrl } from "../loaders"; import { textureToUrl } from "../loaders";
import { liveConnectionStore } from "../state/liveConnectionStore"; import type { StreamEntity, TeamScore, WeaponsHudSlot } from "../stream/types";
import type {
ChatSegment,
ChatMessage,
StreamEntity,
TeamScore,
WeaponsHudSlot,
} from "../stream/types";
import styles from "./PlayerHUD.module.css"; import styles from "./PlayerHUD.module.css";
// ── Compass ── import { ChatWindow } from "./ChatWindow";
const COMPASS_URL = textureToUrl("gui/hud_new_compass"); const COMPASS_URL = textureToUrl("gui/hud_new_compass");
const NSEW_URL = textureToUrl("gui/hud_new_NSEW"); const NSEW_URL = textureToUrl("gui/hud_new_NSEW");
function Compass({ yaw }: { yaw: number | undefined }) {
function Compass() {
const yaw = useEngineSelector(
(state) => state.playback.streamSnapshot?.camera?.yaw,
);
if (yaw == null) return null; if (yaw == null) return null;
// The ring notch is the fixed heading indicator (always "forward" at top). // The ring notch is the fixed heading indicator (always "forward" at top).
// The NSEW letters rotate to show world cardinal directions relative to // The NSEW letters rotate to show world cardinal directions relative to
@ -33,41 +29,51 @@ function Compass({ yaw }: { yaw: number | undefined }) {
</div> </div>
); );
} }
// ── Health / Energy bars ──
function HealthBar({ value }: { value: number }) { function HealthBar() {
const pct = Math.max(0, Math.min(100, value * 100)); const health = useEngineSelector(
(state) => state.playback.streamSnapshot?.status?.health,
);
if (health == null) return null;
const pct = Math.max(0, Math.min(100, health * 100));
return ( return (
<div className={styles.BarTrack}> <div className={styles.BarTrack}>
<div className={styles.BarFillHealth} style={{ width: `${pct}%` }} /> <div className={styles.BarFillHealth} style={{ width: `${pct}%` }} />
</div> </div>
); );
} }
function EnergyBar({ value }: { value: number }) {
const pct = Math.max(0, Math.min(100, value * 100)); function EnergyBar() {
const energy = useEngineSelector(
(state) => state.playback.streamSnapshot?.status?.energy,
);
if (energy == null) return null;
const pct = Math.max(0, Math.min(100, energy * 100));
return ( return (
<div className={styles.BarTrack}> <div className={styles.BarTrack}>
<div className={styles.BarFillEnergy} style={{ width: `${pct}%` }} /> <div className={styles.BarFillEnergy} style={{ width: `${pct}%` }} />
</div> </div>
); );
} }
// ── Reticle ──
const RETICLE_TEXTURES: Record<string, string> = { const RETICLE_TEXTURES: Record<string, string> = {
weapon_sniper: "gui/hud_ret_sniper", weapon_sniper: "gui/hud_ret_sniper",
weapon_shocklance: "gui/hud_ret_shocklance", weapon_shocklance: "gui/hud_ret_shocklance",
weapon_targeting: "gui/hud_ret_targlaser", weapon_targeting: "gui/hud_ret_targlaser",
}; };
function normalizeWeaponName(shape: string | undefined): string { function normalizeWeaponName(shape: string | undefined): string {
if (!shape) return ""; if (!shape) return "";
return shape.replace(/\.dts$/i, "").toLowerCase(); return shape.replace(/\.dts$/i, "").toLowerCase();
} }
function Reticle() { function Reticle() {
const weaponShape = useEngineSelector((state) => { const weaponShape = useEngineSelector((state) => {
const snap = state.playback.streamSnapshot; const snap = state.playback.streamSnapshot;
if (!snap || snap.camera?.mode !== "first-person") return undefined; if (!snap || snap.camera?.mode !== "first-person") return undefined;
const ctrl = snap.controlPlayerGhostId; const ctrl = snap.controlPlayerGhostId;
if (!ctrl) return undefined; if (!ctrl) return undefined;
return snap.entities.find((e: StreamEntity) => e.id === ctrl) return snap.entities.find((e: StreamEntity) => e.id === ctrl)?.weaponShape;
?.weaponShape;
}); });
if (weaponShape === undefined) return null; if (weaponShape === undefined) return null;
const weapon = normalizeWeaponName(weaponShape); const weapon = normalizeWeaponName(weaponShape);
@ -75,7 +81,6 @@ function Reticle() {
if (textureName) { if (textureName) {
return ( return (
<div className={styles.Reticle}> <div className={styles.Reticle}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img <img
src={textureToUrl(textureName)} src={textureToUrl(textureName)}
alt="" alt=""
@ -90,7 +95,7 @@ function Reticle() {
</div> </div>
); );
} }
// ── Weapon HUD (right side weapon list) ──
/** Maps $WeaponsHudData indices to simple icon textures (no baked background) /** Maps $WeaponsHudData indices to simple icon textures (no baked background)
* and labels. Mortar uses hud_new_ because no simple variant exists. */ * and labels. Mortar uses hud_new_ because no simple variant exists. */
const WEAPON_HUD_SLOTS: Record<number, { icon: string; label: string }> = { const WEAPON_HUD_SLOTS: Record<number, { icon: string; label: string }> = {
@ -114,6 +119,7 @@ const WEAPON_HUD_SLOTS: Record<number, { icon: string; label: string }> = {
16: { icon: "gui/hud_shocklance", label: "Shocklance" }, 16: { icon: "gui/hud_shocklance", label: "Shocklance" },
17: { icon: "gui/hud_new_mortar", label: "Mortar" }, 17: { icon: "gui/hud_new_mortar", label: "Mortar" },
}; };
// Precompute URLs so we don't call textureToUrl on every render. // Precompute URLs so we don't call textureToUrl on every render.
const WEAPON_HUD_ICON_URLS = new Map( const WEAPON_HUD_ICON_URLS = new Map(
Object.entries(WEAPON_HUD_SLOTS).map(([idx, w]) => [ Object.entries(WEAPON_HUD_SLOTS).map(([idx, w]) => [
@ -121,9 +127,11 @@ const WEAPON_HUD_ICON_URLS = new Map(
textureToUrl(w.icon), textureToUrl(w.icon),
]), ]),
); );
/** Targeting laser HUD indices (standard + TR2 variants). */ /** Targeting laser HUD indices (standard + TR2 variants). */
const TARGETING_LASER_INDICES = new Set([9, 14, 15]); const TARGETING_LASER_INDICES = new Set([9, 14, 15]);
const INFINITY_ICON_URL = textureToUrl("gui/hud_infinity"); const INFINITY_ICON_URL = textureToUrl("gui/hud_infinity");
function WeaponSlotIcon({ function WeaponSlotIcon({
slot, slot,
isSelected, isSelected,
@ -138,14 +146,12 @@ function WeaponSlotIcon({
<div <div
className={`${styles.PackInvItem} ${isSelected ? styles.PackInvItemActive : styles.PackInvItemDim}`} className={`${styles.PackInvItem} ${isSelected ? styles.PackInvItemActive : styles.PackInvItemDim}`}
> >
{/* eslint-disable-next-line @next/next/no-img-element */}
<img <img
src={WEAPON_HUD_ICON_URLS.get(slot.index)!} src={WEAPON_HUD_ICON_URLS.get(slot.index)!}
alt={info.label} alt={info.label}
className={styles.PackInvIcon} className={styles.PackInvIcon}
/> />
{isInfinite ? ( {isInfinite ? (
// eslint-disable-next-line @next/next/no-img-element
<img <img
src={INFINITY_ICON_URL} src={INFINITY_ICON_URL}
alt="\u221E" alt="\u221E"
@ -157,6 +163,7 @@ function WeaponSlotIcon({
</div> </div>
); );
} }
function WeaponHUD() { function WeaponHUD() {
const weaponsHud = useEngineSelector( const weaponsHud = useEngineSelector(
(state) => state.playback.streamSnapshot?.weaponsHud, (state) => state.playback.streamSnapshot?.weaponsHud,
@ -191,7 +198,7 @@ function WeaponHUD() {
</div> </div>
); );
} }
// ── Team Scores (bottom-left) ──
/** Default team names from serverDefaults.cs. */ /** Default team names from serverDefaults.cs. */
const DEFAULT_TEAM_NAMES: Record<number, string> = { const DEFAULT_TEAM_NAMES: Record<number, string> = {
1: "Storm", 1: "Storm",
@ -201,6 +208,7 @@ const DEFAULT_TEAM_NAMES: Record<number, string> = {
5: "Blood Eagle", 5: "Blood Eagle",
6: "Phoenix", 6: "Phoenix",
}; };
function TeamScores() { function TeamScores() {
const teamScores = useEngineSelector( const teamScores = useEngineSelector(
(state) => state.playback.streamSnapshot?.teamScores, (state) => state.playback.streamSnapshot?.teamScores,
@ -242,101 +250,7 @@ function TeamScores() {
</div> </div>
); );
} }
// ── Chat Window (top-left) ──
/** Map a colorCode to a CSS module class name (c0c9 GuiChatHudProfile). */
const CHAT_COLOR_CLASSES: Record<number, string> = {
0: styles.ChatColor0,
1: styles.ChatColor1,
2: styles.ChatColor2,
3: styles.ChatColor3,
4: styles.ChatColor4,
5: styles.ChatColor5,
6: styles.ChatColor6,
7: styles.ChatColor7,
8: styles.ChatColor8,
9: styles.ChatColor9,
};
function segmentColorClass(colorCode: number): string {
return CHAT_COLOR_CLASSES[colorCode] ?? CHAT_COLOR_CLASSES[0];
}
function chatColorClass(msg: ChatMessage): string {
if (msg.colorCode != null && CHAT_COLOR_CLASSES[msg.colorCode]) {
return CHAT_COLOR_CLASSES[msg.colorCode];
}
// Fallback: default to \c0 (teal). Messages with detected codes (like \c2
// for flag events) will match above; \c0 kill messages may lose their null
// byte color code, so the correct default for server messages is c0.
return CHAT_COLOR_CLASSES[0];
}
function ChatWindow({ isLive }: { isLive: boolean }) {
const messages = useEngineSelector(
(state) => state.playback.streamSnapshot?.chatMessages,
);
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],
);
const hasMessages = !!messages?.length;
return (
<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>
)}
</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>
);
}
// ── Backpack + Inventory HUD (bottom-right) ── // ── Backpack + Inventory HUD (bottom-right) ──
/** Maps $BackpackHudData indices to icon textures. */ /** Maps $BackpackHudData indices to icon textures. */
const BACKPACK_ICONS: Record<number, string> = { const BACKPACK_ICONS: Record<number, string> = {
@ -429,7 +343,6 @@ function PackAndInventoryHUD() {
<div <div
className={`${styles.PackInvItem} ${backpackHud!.active ? styles.PackInvItemActive : ""}`} className={`${styles.PackInvItem} ${backpackHud!.active ? styles.PackInvItemActive : ""}`}
> >
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={packIconUrl} alt="" className={styles.PackInvIcon} /> <img src={packIconUrl} alt="" className={styles.PackInvIcon} />
<span className={styles.PackInvCount}> <span className={styles.PackInvCount}>
{backpackHud!.text || "\u00A0"} {backpackHud!.text || "\u00A0"}
@ -442,7 +355,6 @@ function PackAndInventoryHUD() {
if (!info || !iconUrl) return null; if (!info || !iconUrl) return null;
return ( return (
<div key={slotId} className={styles.PackInvItem}> <div key={slotId} className={styles.PackInvItem}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img <img
src={iconUrl} src={iconUrl}
alt={info.label} alt={info.label}
@ -457,32 +369,31 @@ function PackAndInventoryHUD() {
</div> </div>
); );
} }
// ── Main HUD ──
export function PlayerHUD({ isLive = false }: { isLive?: boolean } = {}) { export function PlayerHUD() {
const recording = useRecording(); const hasControlPlayer = useEngineSelector(
const streamSnapshot = useEngineSelector( (state) => !!state.playback.streamSnapshot?.controlPlayerGhostId,
(state) => state.playback.streamSnapshot,
); );
if (!recording && !isLive) return null;
const status = streamSnapshot?.status;
return ( return (
<div className={styles.PlayerHUD}> <div className={styles.PlayerHUD}>
<ChatWindow isLive={isLive} /> <ChatWindow />
{status && (
<>
<div className={styles.TopRight}> <div className={styles.TopRight}>
{hasControlPlayer && (
<div className={styles.Bars}> <div className={styles.Bars}>
<HealthBar value={status.health} /> <HealthBar />
<EnergyBar value={status.energy} /> <EnergyBar />
</div> </div>
<Compass yaw={streamSnapshot?.camera?.yaw} /> )}
<Compass />
</div> </div>
{hasControlPlayer && (
<>
<WeaponHUD /> <WeaponHUD />
<PackAndInventoryHUD /> <PackAndInventoryHUD />
<TeamScores />
<Reticle /> <Reticle />
</> </>
)} )}
<TeamScores />
</div> </div>
); );
} }

View file

@ -1,4 +1,4 @@
import { Suspense, useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import type { MutableRefObject } from "react"; import type { MutableRefObject } from "react";
import { useFrame } from "@react-three/fiber"; import { useFrame } from "@react-three/fiber";
import { import {
@ -26,8 +26,9 @@ import { pickMoveAnimation } from "../stream/playerAnimation";
import { WeaponImageStateMachine } from "../stream/weaponStateMachine"; import { WeaponImageStateMachine } from "../stream/weaponStateMachine";
import type { WeaponAnimState } from "../stream/weaponStateMachine"; import type { WeaponAnimState } from "../stream/weaponStateMachine";
import { getAliasedActions } from "../torqueScript/shapeConstructor"; import { getAliasedActions } from "../torqueScript/shapeConstructor";
import { useStaticShape } from "./GenericShape"; import { useStaticShape, ShapePlaceholder } from "./GenericShape";
import { ShapeErrorBoundary } from "./EntityScene"; import { ShapeErrorBoundary } from "./ShapeErrorBoundary";
import { DebugSuspense } from "./DebugSuspense";
import { useAudio } from "./AudioContext"; import { useAudio } from "./AudioContext";
import { import {
resolveAudioProfile, resolveAudioProfile,
@ -39,7 +40,7 @@ import {
} from "./AudioEmitter"; } from "./AudioEmitter";
import { audioToUrl } from "../loaders"; import { audioToUrl } from "../loaders";
import { useSettings } from "./SettingsProvider"; import { useSettings } from "./SettingsProvider";
import { useEngineStoreApi, useEngineSelector } from "../state"; import { useEngineStoreApi, useEngineSelector } from "../state/engineStore";
import { streamPlaybackStore } from "../state/streamPlaybackStore"; import { streamPlaybackStore } from "../state/streamPlaybackStore";
import type { PlayerEntity } from "../state/gameEntityTypes"; import type { PlayerEntity } from "../state/gameEntityTypes";
@ -60,8 +61,16 @@ function getArmThread(weaponShape: string | undefined): string {
const NUM_TABLE_ACTION_ANIMS = 8; const NUM_TABLE_ACTION_ANIMS = 8;
/** Table action names in engine order (indices 0-7). */ /** Table action names in engine order (indices 0-7). */
const TABLE_ACTION_NAMES = ["root", "run", "back", "side", "fall", "jet", "jump", "land"]; const TABLE_ACTION_NAMES = [
"root",
"run",
"back",
"side",
"fall",
"jet",
"jump",
"land",
];
interface ActionAnimEntry { interface ActionAnimEntry {
/** GLB clip name (lowercase, e.g. "diehead"). */ /** GLB clip name (lowercase, e.g. "diehead"). */
@ -90,7 +99,10 @@ function buildActionAnimMap(
const spaceIdx = entry.indexOf(" "); const spaceIdx = entry.indexOf(" ");
if (spaceIdx === -1) continue; if (spaceIdx === -1) continue;
const dsqFile = entry.slice(0, spaceIdx).toLowerCase(); const dsqFile = entry.slice(0, spaceIdx).toLowerCase();
const alias = entry.slice(spaceIdx + 1).trim().toLowerCase(); const alias = entry
.slice(spaceIdx + 1)
.trim()
.toLowerCase();
if (!alias || !dsqFile.startsWith(shapePrefix) || !dsqFile.endsWith(".dsq")) if (!alias || !dsqFile.startsWith(shapePrefix) || !dsqFile.endsWith(".dsq"))
continue; continue;
const clipName = dsqFile.slice(shapePrefix.length, -4); const clipName = dsqFile.slice(shapePrefix.length, -4);
@ -131,8 +143,16 @@ function stopLoopingSound(
const sound = soundRef.current; const sound = soundRef.current;
if (!sound) return; if (!sound) return;
untrackSound(sound); untrackSound(sound);
try { sound.stop(); } catch { /* already stopped */ } try {
try { sound.disconnect(); } catch { /* already disconnected */ } sound.stop();
} catch {
/* already stopped */
}
try {
sound.disconnect();
} catch {
/* already disconnected */
}
parent?.remove(sound); parent?.remove(sound);
soundRef.current = null; soundRef.current = null;
stateRef.current = -1; stateRef.current = -1;
@ -152,13 +172,12 @@ export function PlayerModel({ entity }: { entity: PlayerEntity }) {
const gltf = useStaticShape(shapeName!); const gltf = useStaticShape(shapeName!);
const shapeAliases = useEngineSelector((state) => { const shapeAliases = useEngineSelector((state) => {
const sn = shapeName?.toLowerCase(); const sn = shapeName?.toLowerCase();
return sn return sn ? state.runtime.sequenceAliases.get(sn) : undefined;
? state.runtime.sequenceAliases.get(sn)
: undefined;
}); });
// Clone scene preserving skeleton bindings, create mixer, find mount bones. // Clone scene preserving skeleton bindings, create mixer, find mount bones.
const { clonedScene, mixer, mount0, mount1, iflInitializers } = useMemo(() => { const { clonedScene, mixer, mount0, mount1, mount2, iflInitializers } =
useMemo(() => {
const scene = SkeletonUtils.clone(gltf.scene) as Group; const scene = SkeletonUtils.clone(gltf.scene) as Group;
const iflInits = processShapeScene(scene); const iflInits = processShapeScene(scene);
@ -175,12 +194,21 @@ export function PlayerModel({ entity }: { entity: PlayerEntity }) {
let m0: Object3D | null = null; let m0: Object3D | null = null;
let m1: Object3D | null = null; let m1: Object3D | null = null;
let m2: Object3D | null = null;
scene.traverse((n) => { scene.traverse((n) => {
if (!m0 && n.name === "Mount0") m0 = n; if (!m0 && n.name === "Mount0") m0 = n;
if (!m1 && n.name === "Mount1") m1 = n; if (!m1 && n.name === "Mount1") m1 = n;
if (!m2 && n.name === "Mount2") m2 = n;
}); });
return { clonedScene: scene, mixer: mix, mount0: m0, mount1: m1, iflInitializers: iflInits }; return {
clonedScene: scene,
mixer: mix,
mount0: m0,
mount1: m1,
mount2: m2,
iflInitializers: iflInits,
};
}, [gltf]); }, [gltf]);
// Build case-insensitive clip lookup with alias support. // Build case-insensitive clip lookup with alias support.
@ -226,7 +254,10 @@ export function PlayerModel({ entity }: { entity: PlayerEntity }) {
// place) since multiple player entities share the same GLTF cache. // place) since multiple player entities share the same GLTF cache.
// Head blend actions. // Head blend actions.
const blendRefs: typeof blendActionsRef.current = { head: null, headside: null }; const blendRefs: typeof blendActionsRef.current = {
head: null,
headside: null,
};
for (const { key, names } of [ for (const { key, names } of [
{ key: "head" as const, names: ["head"] }, { key: "head" as const, names: ["head"] },
{ key: "headside" as const, names: ["headside"] }, { key: "headside" as const, names: ["headside"] },
@ -318,6 +349,8 @@ export function PlayerModel({ entity }: { entity: PlayerEntity }) {
); );
const packShapeRef = useRef(entity.packShape); const packShapeRef = useRef(entity.packShape);
const [currentPackShape, setCurrentPackShape] = useState(entity.packShape); const [currentPackShape, setCurrentPackShape] = useState(entity.packShape);
const flagShapeRef = useRef(entity.flagShape);
const [currentFlagShape, setCurrentFlagShape] = useState(entity.flagShape);
// Per-frame animation selection and mixer update. // Per-frame animation selection and mixer update.
useFrame((_, delta) => { useFrame((_, delta) => {
@ -329,6 +362,10 @@ export function PlayerModel({ entity }: { entity: PlayerEntity }) {
packShapeRef.current = entity.packShape; packShapeRef.current = entity.packShape;
setCurrentPackShape(entity.packShape); setCurrentPackShape(entity.packShape);
} }
if (entity.flagShape !== flagShapeRef.current) {
flagShapeRef.current = entity.flagShape;
setCurrentFlagShape(entity.flagShape);
}
const playback = engineStore.getState().playback; const playback = engineStore.getState().playback;
const isPlaying = playback.status === "playing"; const isPlaying = playback.status === "playing";
const time = streamPlaybackStore.getState().time; const time = streamPlaybackStore.getState().time;
@ -343,9 +380,8 @@ export function PlayerModel({ entity }: { entity: PlayerEntity }) {
isDeadRef.current = true; isDeadRef.current = true;
// The server sends the death animation as an actionAnim index. // The server sends the death animation as an actionAnim index.
const deathEntry = kf.actionAnim != null const deathEntry =
? actionAnimMap.get(kf.actionAnim) kf.actionAnim != null ? actionAnimMap.get(kf.actionAnim) : undefined;
: undefined;
if (deathEntry) { if (deathEntry) {
const deathAction = actions.get(deathEntry.clipName); const deathAction = actions.get(deathEntry.clipName);
if (deathAction) { if (deathAction) {
@ -368,7 +404,9 @@ export function PlayerModel({ entity }: { entity: PlayerEntity }) {
isDeadRef.current = false; isDeadRef.current = false;
actionAnimRef.current = undefined; actionAnimRef.current = undefined;
const deathAction = actions.get(currentAnimRef.current.name.toLowerCase()); const deathAction = actions.get(
currentAnimRef.current.name.toLowerCase(),
);
if (deathAction) { if (deathAction) {
deathAction.stop(); deathAction.stop();
deathAction.setLoop(LoopRepeat, Infinity); deathAction.setLoop(LoopRepeat, Infinity);
@ -386,8 +424,10 @@ export function PlayerModel({ entity }: { entity: PlayerEntity }) {
const prevActionAnim = actionAnimRef.current; const prevActionAnim = actionAnimRef.current;
if (!isDeadRef.current && actionAnim !== prevActionAnim) { if (!isDeadRef.current && actionAnim !== prevActionAnim) {
actionAnimRef.current = actionAnim; actionAnimRef.current = actionAnim;
const isNonTableAction = actionAnim != null && actionAnim >= NUM_TABLE_ACTION_ANIMS; const isNonTableAction =
const wasNonTableAction = prevActionAnim != null && prevActionAnim >= NUM_TABLE_ACTION_ANIMS; actionAnim != null && actionAnim >= NUM_TABLE_ACTION_ANIMS;
const wasNonTableAction =
prevActionAnim != null && prevActionAnim >= NUM_TABLE_ACTION_ANIMS;
if (isNonTableAction) { if (isNonTableAction) {
// Start or change action animation. // Start or change action animation.
@ -395,7 +435,9 @@ export function PlayerModel({ entity }: { entity: PlayerEntity }) {
if (entry) { if (entry) {
const actionAction = actions.get(entry.clipName); const actionAction = actions.get(entry.clipName);
if (actionAction) { if (actionAction) {
const prevAction = actions.get(currentAnimRef.current.name.toLowerCase()); const prevAction = actions.get(
currentAnimRef.current.name.toLowerCase(),
);
if (prevAction) prevAction.fadeOut(ANIM_TRANSITION_TIME); if (prevAction) prevAction.fadeOut(ANIM_TRANSITION_TIME);
actionAction.setLoop(LoopOnce, 1); actionAction.setLoop(LoopOnce, 1);
actionAction.clampWhenFinished = true; actionAction.clampWhenFinished = true;
@ -421,7 +463,11 @@ export function PlayerModel({ entity }: { entity: PlayerEntity }) {
} }
// If atEnd, clamp the action animation at its final frame. // If atEnd, clamp the action animation at its final frame.
if (actionAnim != null && actionAnim >= NUM_TABLE_ACTION_ANIMS && kf?.actionAtEnd) { if (
actionAnim != null &&
actionAnim >= NUM_TABLE_ACTION_ANIMS &&
kf?.actionAtEnd
) {
const entry = actionAnimMap.get(actionAnim); const entry = actionAnimMap.get(actionAnim);
if (entry) { if (entry) {
const actionAction = actions.get(entry.clipName); const actionAction = actions.get(entry.clipName);
@ -432,7 +478,8 @@ export function PlayerModel({ entity }: { entity: PlayerEntity }) {
} }
// Movement animation selection (skip while dead or playing action anim). // Movement animation selection (skip while dead or playing action anim).
const playingActionAnim = actionAnimRef.current != null && const playingActionAnim =
actionAnimRef.current != null &&
actionAnimRef.current >= NUM_TABLE_ACTION_ANIMS; actionAnimRef.current >= NUM_TABLE_ACTION_ANIMS;
if (!isDeadRef.current && !playingActionAnim) { if (!isDeadRef.current && !playingActionAnim) {
const anim = pickMoveAnimation( const anim = pickMoveAnimation(
@ -518,24 +565,52 @@ export function PlayerModel({ entity }: { entity: PlayerEntity }) {
<primitive object={clonedScene} /> <primitive object={clonedScene} />
</group> </group>
{currentWeaponShape && mount0 && ( {currentWeaponShape && mount0 && (
<ShapeErrorBoundary key={currentWeaponShape} fallback={null}> <ShapeErrorBoundary
<Suspense fallback={null}> key={currentWeaponShape}
<AnimatedWeaponModel fallback={<ShapePlaceholder color="red" label={currentWeaponShape} />}
>
<DebugSuspense
label={`Weapon:${entity.id}/${currentWeaponShape}`}
fallback={
<ShapePlaceholder color="cyan" label={currentWeaponShape} />
}
>
<WeaponModel
entity={entity} entity={entity}
weaponShape={currentWeaponShape} weaponShape={currentWeaponShape}
mount0={mount0} mount0={mount0}
/> />
</Suspense> </DebugSuspense>
</ShapeErrorBoundary> </ShapeErrorBoundary>
)} )}
{currentPackShape && mount1 && ( {currentPackShape && mount1 && (
<ShapeErrorBoundary key={currentPackShape} fallback={null}> <ShapeErrorBoundary
<Suspense fallback={null}> key={currentPackShape}
<MountedPackModel fallback={<ShapePlaceholder color="red" label={currentPackShape} />}
packShape={currentPackShape} >
mountBone={mount1} <DebugSuspense
/> label={`Pack:${entity.id}/${currentPackShape}`}
</Suspense> fallback={
<ShapePlaceholder color="cyan" label={currentPackShape} />
}
>
<PackModel packShape={currentPackShape} mountBone={mount1} />
</DebugSuspense>
</ShapeErrorBoundary>
)}
{currentFlagShape && mount2 && (
<ShapeErrorBoundary
key={currentFlagShape}
fallback={<ShapePlaceholder color="red" label={currentFlagShape} />}
>
<DebugSuspense
label={`Flag:${entity.id}/${currentFlagShape}`}
fallback={
<ShapePlaceholder color="cyan" label={currentFlagShape} />
}
>
<PackModel packShape={currentFlagShape} mountBone={mount2} />
</DebugSuspense>
</ShapeErrorBoundary> </ShapeErrorBoundary>
)} )}
</> </>
@ -556,7 +631,9 @@ function buildSeqIndexToName(
try { try {
const names: string[] = JSON.parse(raw); const names: string[] = JSON.parse(raw);
return names.map((n) => n.toLowerCase()); return names.map((n) => n.toLowerCase());
} catch { /* fall through */ } } catch {
/* fall through */
}
} }
return animations.map((a) => a.name.toLowerCase()); return animations.map((a) => a.name.toLowerCase());
} }
@ -571,7 +648,7 @@ function buildSeqIndexToName(
* from the entity inside useFrame, since these fields are mutated per-tick * from the entity inside useFrame, since these fields are mutated per-tick
* without triggering React re-renders. * without triggering React re-renders.
*/ */
function AnimatedWeaponModel({ function WeaponModel({
entity, entity,
weaponShape, weaponShape,
mount0, mount0,
@ -584,8 +661,13 @@ function AnimatedWeaponModel({
const weaponGltf = useStaticShape(weaponShape); const weaponGltf = useStaticShape(weaponShape);
// Clone weapon with skeleton bindings, create dedicated mixer. // Clone weapon with skeleton bindings, create dedicated mixer.
const { weaponClone, weaponMixer, seqIndexToName, visNodesBySequence, weaponIflInitializers } = const {
useMemo(() => { weaponClone,
weaponMixer,
seqIndexToName,
visNodesBySequence,
weaponIflInitializers,
} = useMemo(() => {
const clone = SkeletonUtils.clone(weaponGltf.scene) as Group; const clone = SkeletonUtils.clone(weaponGltf.scene) as Group;
const iflInits = processShapeScene(clone); const iflInits = processShapeScene(clone);
@ -760,8 +842,10 @@ function AnimatedWeaponModel({
audioListener && audioListener &&
animState.soundDataBlockIds.length > 0 animState.soundDataBlockIds.length > 0
) { ) {
const getDb = playback.recording?.streamingPlayback.getDataBlockData const getDb =
.bind(playback.recording.streamingPlayback); playback.recording?.streamingPlayback.getDataBlockData.bind(
playback.recording.streamingPlayback,
);
if (getDb) { if (getDb) {
for (const soundDbId of animState.soundDataBlockIds) { for (const soundDbId of animState.soundDataBlockIds) {
const resolved = resolveAudioProfile(soundDbId, getDb); const resolved = resolveAudioProfile(soundDbId, getDb);
@ -795,7 +879,9 @@ function AnimatedWeaponModel({
loopingSoundRef.current = sound; loopingSoundRef.current = sound;
loopingSoundStateRef.current = currentIdx; loopingSoundStateRef.current = currentIdx;
}); });
} catch { /* expected */ } } catch {
/* expected */
}
} }
} else { } else {
playOneShotSound( playOneShotSound(
@ -814,7 +900,6 @@ function AnimatedWeaponModel({
if (spinActionRef.current) { if (spinActionRef.current) {
spinActionRef.current.timeScale = animState.spinTimeScale; spinActionRef.current.timeScale = animState.spinTimeScale;
} }
} }
// Advance the weapon mixer. // Advance the weapon mixer.
@ -898,9 +983,8 @@ function applyWeaponAnim(
// Scale animation to fit the state timeout if requested. // Scale animation to fit the state timeout if requested.
if (animState.scaleAnimation && animState.timeoutValue > 0) { if (animState.scaleAnimation && animState.timeoutValue > 0) {
const clipDuration = action.getClip().duration; const clipDuration = action.getClip().duration;
action.timeScale = clipDuration > 0 action.timeScale =
? clipDuration / animState.timeoutValue clipDuration > 0 ? clipDuration / animState.timeoutValue : 1;
: 1;
} else { } else {
action.timeScale = animState.reverse ? -1 : 1; action.timeScale = animState.reverse ? -1 : 1;
} }
@ -921,7 +1005,7 @@ function applyWeaponAnim(
* mounted images (no state machine or animation) just positioned via * mounted images (no state machine or animation) just positioned via
* the pack shape's Mountpoint node inverse offset, same as weapons. * the pack shape's Mountpoint node inverse offset, same as weapons.
*/ */
function MountedPackModel({ function PackModel({
packShape, packShape,
mountBone, mountBone,
}: { }: {

View file

@ -29,7 +29,7 @@ const _tmpVec = new Vector3();
*/ */
export function PlayerNameplate({ entity }: { entity: PlayerEntity }) { export function PlayerNameplate({ entity }: { entity: PlayerEntity }) {
const gltf = useStaticShape((entity.shapeName ?? entity.dataBlock)!); const gltf = useStaticShape((entity.shapeName ?? entity.dataBlock)!);
const { camera } = useThree(); const camera = useThree((state) => state.camera);
const groupRef = useRef<Object3D>(null); const groupRef = useRef<Object3D>(null);
const iffContainerRef = useRef<HTMLDivElement>(null); const iffContainerRef = useRef<HTMLDivElement>(null);
const nameContainerRef = useRef<HTMLDivElement>(null); const nameContainerRef = useRef<HTMLDivElement>(null);
@ -75,7 +75,10 @@ export function PlayerNameplate({ entity }: { entity: PlayerEntity }) {
if (!shouldBeVisible) return; if (!shouldBeVisible) return;
// Hide nameplate when player is dead. // Hide nameplate when player is dead.
const kf = getKeyframeAtTime(keyframes, streamPlaybackStore.getState().time); const kf = getKeyframeAtTime(
keyframes,
streamPlaybackStore.getState().time,
);
const health = kf?.health ?? 1; const health = kf?.health ?? 1;
if (kf?.damageState != null && kf.damageState >= 1) { if (kf?.damageState != null && kf.damageState >= 1) {
if (iffContainerRef.current) iffContainerRef.current.style.opacity = "0"; if (iffContainerRef.current) iffContainerRef.current.style.opacity = "0";

View file

@ -16,7 +16,7 @@ import {
setQuaternionFromDir, setQuaternionFromDir,
} from "../stream/playbackUtils"; } from "../stream/playbackUtils";
import { textureToUrl } from "../loaders"; import { textureToUrl } from "../loaders";
import type { TracerVisual, SpriteVisual } from "../stream/types"; import { SpriteEntity, TracerEntity } from "../state/gameEntityTypes";
const _tracerDir = new Vector3(); const _tracerDir = new Vector3();
const _tracerDirFromCam = new Vector3(); const _tracerDirFromCam = new Vector3();
@ -26,7 +26,8 @@ const _tracerEnd = new Vector3();
const _tracerWorldPos = new Vector3(); const _tracerWorldPos = new Vector3();
const _upY = new Vector3(0, 1, 0); const _upY = new Vector3(0, 1, 0);
export function SpriteProjectile({ visual }: { visual: SpriteVisual }) { export function SpriteProjectile({ entity }: { entity: SpriteEntity }) {
const { visual } = entity;
const url = textureToUrl(visual.texture); const url = textureToUrl(visual.texture);
const texture = useTexture(url, (tex) => { const texture = useTexture(url, (tex) => {
const t = Array.isArray(tex) ? tex[0] : tex; const t = Array.isArray(tex) ? tex[0] : tex;
@ -37,7 +38,12 @@ export function SpriteProjectile({ visual }: { visual: SpriteVisual }) {
// Convert sRGB datablock color to linear for Three.js material. // Convert sRGB datablock color to linear for Three.js material.
const color = useMemo( const color = useMemo(
() => () =>
new Color().setRGB(visual.color.r, visual.color.g, visual.color.b, SRGBColorSpace), new Color().setRGB(
visual.color.r,
visual.color.g,
visual.color.b,
SRGBColorSpace,
),
[visual.color.r, visual.color.g, visual.color.b], [visual.color.r, visual.color.g, visual.color.b],
); );
@ -55,13 +61,8 @@ export function SpriteProjectile({ visual }: { visual: SpriteVisual }) {
); );
} }
export function TracerProjectile({ export function TracerProjectile({ entity }: { entity: TracerEntity }) {
entity, const { visual } = entity;
visual,
}: {
entity: { keyframes?: Array<{ position?: [number, number, number]; velocity?: [number, number, number] }>; direction?: [number, number, number] };
visual: TracerVisual;
}) {
const tracerRef = useRef<Mesh>(null); const tracerRef = useRef<Mesh>(null);
const tracerPosRef = useRef<BufferAttribute>(null); const tracerPosRef = useRef<BufferAttribute>(null);
const crossRef = useRef<Mesh>(null); const crossRef = useRef<Mesh>(null);
@ -167,14 +168,12 @@ export function TracerProjectile({
/> />
<bufferAttribute <bufferAttribute
attach="attributes-uv" attach="attributes-uv"
args={[ args={[new Float32Array([0, 0, 0, 1, 1, 1, 1, 0]), 2]}
new Float32Array([ />
0, 0, 0, 1, 1, 1, 1, 0, <bufferAttribute
]), attach="index"
2, args={[new Uint16Array([0, 1, 2, 0, 2, 3]), 1]}
]}
/> />
<bufferAttribute attach="index" args={[new Uint16Array([0, 1, 2, 0, 2, 3]), 1]} />
</bufferGeometry> </bufferGeometry>
<meshBasicMaterial <meshBasicMaterial
map={tracerTexture} map={tracerTexture}
@ -192,24 +191,19 @@ export function TracerProjectile({
attach="attributes-position" attach="attributes-position"
args={[ args={[
new Float32Array([ new Float32Array([
-0.5, 0, -0.5, -0.5, 0, -0.5, 0.5, 0, -0.5, 0.5, 0, 0.5, -0.5, 0, 0.5,
0.5, 0, -0.5,
0.5, 0, 0.5,
-0.5, 0, 0.5,
]), ]),
3, 3,
]} ]}
/> />
<bufferAttribute <bufferAttribute
attach="attributes-uv" attach="attributes-uv"
args={[ args={[new Float32Array([0, 0, 0, 1, 1, 1, 1, 0]), 2]}
new Float32Array([ />
0, 0, 0, 1, 1, 1, 1, 0, <bufferAttribute
]), attach="index"
2, args={[new Uint16Array([0, 1, 2, 0, 2, 3]), 1]}
]}
/> />
<bufferAttribute attach="index" args={[new Uint16Array([0, 1, 2, 0, 2, 3]), 1]} />
</bufferGeometry> </bufferGeometry>
<meshBasicMaterial <meshBasicMaterial
map={crossTexture} map={crossTexture}

View file

@ -1,6 +1,6 @@
import { useCallback, type ReactNode } from "react"; import { useCallback, type ReactNode } from "react";
import type { StreamRecording } from "../stream/types"; import type { StreamRecording } from "../stream/types";
import { useEngineSelector } from "../state"; import { useEngineSelector } from "../state/engineStore";
export function RecordingProvider({ children }: { children: ReactNode }) { export function RecordingProvider({ children }: { children: ReactNode }) {
return <>{children}</>; return <>{children}</>;

View file

@ -1,7 +1,6 @@
import { createContext, ReactNode, useContext } from "react"; import { createContext, ReactNode, useContext } from "react";
import type { TorqueRuntime } from "../torqueScript"; import type { TorqueRuntime } from "../torqueScript";
const RuntimeContext = createContext<TorqueRuntime | null>(null); const RuntimeContext = createContext<TorqueRuntime | null>(null);
export interface RuntimeProviderProps { export interface RuntimeProviderProps {
@ -24,4 +23,3 @@ export function useRuntime(): TorqueRuntime {
} }
return runtime; return runtime;
} }

View file

@ -1,9 +1,12 @@
import { useEffect, useMemo } from "react"; import { useEffect, useMemo } from "react";
import { Color, Vector3 } from "three"; import { Color, Vector3 } from "three";
import { createLogger } from "../logger";
import { useSceneSun } from "../state/gameEntityStore"; import { useSceneSun } from "../state/gameEntityStore";
import { torqueToThree } from "../scene/coordinates"; import { torqueToThree } from "../scene/coordinates";
import { updateGlobalSunUniforms } from "../globalSunUniforms"; import { updateGlobalSunUniforms } from "../globalSunUniforms";
const log = createLogger("SceneLighting");
/** /**
* Renders scene-global lights (directional sun + ambient) derived from the * Renders scene-global lights (directional sun + ambient) derived from the
* Sun entity in the game entity store. Rendered outside EntityScene so that * Sun entity in the game entity store. Rendered outside EntityScene so that
@ -14,6 +17,25 @@ import { updateGlobalSunUniforms } from "../globalSunUniforms";
export function SceneLighting() { export function SceneLighting() {
const sunData = useSceneSun(); const sunData = useSceneSun();
useEffect(() => {
if (sunData) {
log.debug(
"sunData: dir=(%s, %s, %s) color=(%s, %s, %s) ambient=(%s, %s, %s)",
sunData.direction.x.toFixed(3),
sunData.direction.y.toFixed(3),
sunData.direction.z.toFixed(3),
sunData.color.r.toFixed(3),
sunData.color.g.toFixed(3),
sunData.color.b.toFixed(3),
sunData.ambient.r.toFixed(3),
sunData.ambient.g.toFixed(3),
sunData.ambient.b.toFixed(3),
);
} else {
log.debug("No sunData — using fallback ambient #888");
}
}, [sunData]);
if (!sunData) { if (!sunData) {
// Fallback lighting when no Sun entity exists yet // Fallback lighting when no Sun entity exists yet
return <ambientLight color="#888888" intensity={1.0} />; return <ambientLight color="#888888" intensity={1.0} />;
@ -22,7 +44,11 @@ export function SceneLighting() {
return <SunLighting sunData={sunData} />; return <SunLighting sunData={sunData} />;
} }
function SunLighting({ sunData }: { sunData: NonNullable<ReturnType<typeof useSceneSun>> }) { function SunLighting({
sunData,
}: {
sunData: NonNullable<ReturnType<typeof useSceneSun>>;
}) {
const direction = useMemo(() => { const direction = useMemo(() => {
const [x, y, z] = torqueToThree(sunData.direction); const [x, y, z] = torqueToThree(sunData.direction);
const len = Math.sqrt(x * x + y * y + z * z); const len = Math.sqrt(x * x + y * y + z * z);

View file

@ -1,36 +1,14 @@
.Dialog { .Dialog {
position: relative; composes: Dialog from "./GameDialog.module.css";
width: 860px; width: 860px;
height: 560px; height: 560px;
max-width: calc(100dvw - 40px);
max-height: calc(100dvh - 40px);
display: grid; display: grid;
grid-template-columns: 100%; grid-template-columns: 100%;
grid-template-rows: auto 1fr auto; grid-template-rows: auto 1fr auto;
background: rgba(20, 37, 38, 0.8);
border: 1px solid rgba(65, 131, 139, 0.6);
border-radius: 4px;
box-shadow:
0 0 50px rgba(0, 0, 0, 0.4),
inset 0 0 60px rgba(1, 7, 13, 0.6);
color: #b0d5c9;
font-size: 14px;
line-height: 1.5;
overflow: hidden;
outline: none;
user-select: text;
-webkit-touch-callout: default;
} }
.Overlay { .Overlay {
position: fixed; composes: Overlay from "./GameDialog.module.css";
inset: 0;
background: rgba(0, 0, 0, 0.7);
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
} }
.Header { .Header {
@ -50,8 +28,18 @@
flex: 1; flex: 1;
} }
.HiddenRadio {
position: absolute;
width: 0;
height: 0;
opacity: 0;
z-index: -1;
overflow: hidden;
pointer-events: none;
}
.RefreshButton { .RefreshButton {
composes: DialogButton from "./DialogButton.module.css"; composes: DialogButton from "./GameDialog.module.css";
padding: 3px 14px; padding: 3px 14px;
font-size: 12px; font-size: 12px;
} }
@ -74,6 +62,7 @@
.Table th { .Table th {
position: sticky; position: sticky;
z-index: 1;
top: 0; top: 0;
background: rgba(10, 25, 26, 0.95); background: rgba(10, 25, 26, 0.95);
padding: 6px 12px; padding: 6px 12px;
@ -100,6 +89,8 @@
} }
.Table td { .Table td {
position: relative;
z-index: 0;
padding: 3px 12px; padding: 3px 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.04); border-bottom: 1px solid rgba(255, 255, 255, 0.04);
white-space: nowrap; white-space: nowrap;
@ -109,15 +100,19 @@
font-weight: 500; font-weight: 500;
} }
.Table tbody tr { .Table td.EmptyServer {
opacity: 0.4;
}
.Table tbody tr:not(.Empty) {
cursor: pointer; cursor: pointer;
} }
.Table tbody tr:hover { .Table tbody tr:not(.Empty):hover {
background: rgba(65, 131, 139, 0.12); background: rgba(65, 131, 139, 0.12);
} }
.Selected { .Table tbody tr:has(input:checked) {
background: rgba(93, 255, 225, 0.9) !important; background: rgba(93, 255, 225, 0.9) !important;
color: #1e2828; color: #1e2828;
} }
@ -128,7 +123,7 @@
font-size: 11px; font-size: 11px;
} }
.Empty { .Empty td {
text-align: center; text-align: center;
color: rgba(201, 220, 216, 0.3); color: rgba(201, 220, 216, 0.3);
padding: 32px 12px !important; padding: 32px 12px !important;
@ -146,12 +141,12 @@
} }
.JoinButton { .JoinButton {
composes: DialogButton from "./DialogButton.module.css"; composes: DialogButton from "./GameDialog.module.css";
min-width: 100px; min-width: 100px;
} }
.CloseButton { .CloseButton {
composes: Secondary from "./DialogButton.module.css"; composes: Secondary from "./GameDialog.module.css";
} }
.WarriorField { .WarriorField {

View file

@ -1,57 +1,47 @@
import { useState, useEffect, useCallback, useRef, useMemo } from "react"; import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import type { ServerInfo } from "../../relay/types"; import type { ServerInfo } from "../../relay/types";
import styles from "./ServerBrowser.module.css"; import styles from "./ServerBrowser.module.css";
export function ServerBrowser({ import { useLiveSelector } from "../state/liveConnectionStore";
open, import { useSettings } from "./SettingsProvider";
onClose,
servers, export function ServerBrowser({ onClose }: { onClose: () => void }) {
loading, const servers = useLiveSelector((s) => s.servers);
onRefresh, const serversLoading = useLiveSelector((s) => s.serversLoading);
onJoin, const browserToRelayPing = useLiveSelector((s) => s.browserToRelayPing);
wsPing, const listServers = useLiveSelector((s) => s.listServers);
warriorName, const joinServer = useLiveSelector((s) => s.joinServer);
onWarriorNameChange, const { warriorName, setWarriorName } = useSettings();
}: {
open: boolean;
onClose: () => void;
servers: ServerInfo[];
loading: boolean;
onRefresh: () => void;
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 [selectedAddress, setSelectedAddress] = useState<string | null>(null);
const handleJoinSelected = () => {
if (selectedAddress) {
joinServer(selectedAddress, warriorName);
onClose();
}
};
const handleJoin = (address: string) => {
joinServer(address, warriorName);
onClose();
};
const [sortKey, setSortKey] = useState<keyof ServerInfo>("ping"); const [sortKey, setSortKey] = useState<keyof ServerInfo>("ping");
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc"); const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
const dialogRef = useRef<HTMLDivElement>(null); const dialogRef = useRef<HTMLDivElement>(null);
const onRefreshRef = useRef(onRefresh);
onRefreshRef.current = onRefresh;
const didAutoRefreshRef = useRef(false);
useEffect(() => { useEffect(() => {
if (open) {
dialogRef.current?.focus(); dialogRef.current?.focus();
try { try {
document.exitPointerLock(); document.exitPointerLock();
} catch { } catch {
/* expected */ /* expected */
} }
} else { }, []);
didAutoRefreshRef.current = false;
}
}, [open]);
// Refresh on open if no servers cached
useEffect(() => { useEffect(() => {
if (open && servers.length === 0 && !didAutoRefreshRef.current) { listServers();
didAutoRefreshRef.current = true; }, [listServers]);
onRefreshRef.current();
}
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps
// Block keyboard events from reaching Three.js while open // Block keyboard events from reaching Three.js while open
useEffect(() => { useEffect(() => {
if (!open) return;
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
e.stopPropagation(); e.stopPropagation();
if (e.key === "Escape") { if (e.key === "Escape") {
@ -60,7 +50,8 @@ export function ServerBrowser({
}; };
window.addEventListener("keydown", handleKeyDown, true); window.addEventListener("keydown", handleKeyDown, true);
return () => window.removeEventListener("keydown", handleKeyDown, true); return () => window.removeEventListener("keydown", handleKeyDown, true);
}, [open, onClose]); }, [onClose]);
const handleSort = useCallback( const handleSort = useCallback(
(key: keyof ServerInfo) => { (key: keyof ServerInfo) => {
if (sortKey === key) { if (sortKey === key) {
@ -85,15 +76,6 @@ export function ServerBrowser({
}); });
}, [servers, sortDir, sortKey]); }, [servers, sortDir, sortKey]);
const handleJoin = useCallback(() => {
if (selectedAddress) {
onJoin(selectedAddress);
onClose();
}
}, [selectedAddress, onJoin, onClose]);
if (!open) return null;
return ( return (
<div className={styles.Overlay} onClick={onClose}> <div className={styles.Overlay} onClick={onClose}>
<div <div
@ -109,13 +91,14 @@ export function ServerBrowser({
</span> </span>
<button <button
className={styles.RefreshButton} className={styles.RefreshButton}
onClick={onRefresh} onClick={listServers}
disabled={loading} disabled={serversLoading}
> >
{loading ? "Refreshing..." : "Refresh"} Refresh
</button> </button>
</div> </div>
<div className={styles.TableWrapper}> <div className={styles.TableWrapper}>
<form name="serverList" onSubmit={handleJoinSelected}>
<table className={styles.Table}> <table className={styles.Table}>
<thead> <thead>
<tr> <tr>
@ -131,30 +114,50 @@ export function ServerBrowser({
{sorted.map((server) => ( {sorted.map((server) => (
<tr <tr
key={server.address} key={server.address}
className={ onClick={() => {
selectedAddress === server.address setSelectedAddress(server.address);
? styles.Selected const form = document.forms["serverList"];
: undefined const inputs: RadioNodeList =
} form.elements["serverAddress"];
onClick={() => setSelectedAddress(server.address)} const input = Array.from(inputs).find(
(input) => input.value === server.address,
);
input.focus();
}}
onDoubleClick={() => { onDoubleClick={() => {
setSelectedAddress(server.address); setSelectedAddress(server.address);
onJoin(server.address); handleJoin(server.address);
onClose(); onClose();
}} }}
> >
<td> <td>
<input
type="radio"
className={styles.HiddenRadio}
name="serverAddress"
value={server.address}
checked={selectedAddress === server.address}
onChange={(event) => {
setSelectedAddress(event.target.value);
}}
/>
{server.passwordRequired && ( {server.passwordRequired && (
<span className={styles.PasswordIcon}>&#x1F512;</span> <span className={styles.PasswordIcon}>&#x1F512;</span>
)} )}
{server.name} {server.name}
</td> </td>
<td> <td
{server.playerCount}/{server.maxPlayers} className={
server.playerCount === 0
? styles.EmptyServer
: undefined
}
>
{server.playerCount}&thinsp;/&thinsp;{server.maxPlayers}
</td> </td>
<td> <td>
{wsPing != null {browserToRelayPing != null
? (server.ping + wsPing).toLocaleString() ? (server.ping + browserToRelayPing).toLocaleString()
: "\u2014"} : "\u2014"}
</td> </td>
<td>{server.mapName}</td> <td>{server.mapName}</td>
@ -162,22 +165,19 @@ export function ServerBrowser({
<td>{server.mod}</td> <td>{server.mod}</td>
</tr> </tr>
))} ))}
{sorted.length === 0 && !loading && ( {sorted.length === 0 && !serversLoading && (
<tr> <tr className={styles.Empty}>
<td colSpan={6} className={styles.Empty}> <td colSpan={6}>No servers found</td>
No servers found
</td>
</tr> </tr>
)} )}
{loading && sorted.length === 0 && ( {serversLoading && sorted.length === 0 && (
<tr> <tr className={styles.Empty}>
<td colSpan={6} className={styles.Empty}> <td colSpan={6}>Querying master server</td>
Querying master server...
</td>
</tr> </tr>
)} )}
</tbody> </tbody>
</table> </table>
</form>
</div> </div>
<div className={styles.Footer}> <div className={styles.Footer}>
<div className={styles.WarriorField}> <div className={styles.WarriorField}>
@ -189,7 +189,7 @@ export function ServerBrowser({
className={styles.WarriorInput} className={styles.WarriorInput}
type="text" type="text"
value={warriorName} value={warriorName}
onChange={(e) => onWarriorNameChange(e.target.value)} onChange={(e) => setWarriorName(e.target.value)}
placeholder="Name thyself…" placeholder="Name thyself…"
maxLength={24} maxLength={24}
/> />
@ -199,7 +199,7 @@ export function ServerBrowser({
Cancel Cancel
</button> </button>
<button <button
onClick={handleJoin} onClick={handleJoinSelected}
disabled={!selectedAddress} disabled={!selectedAddress}
className={styles.JoinButton} className={styles.JoinButton}
> >

View file

@ -27,11 +27,15 @@ type SettingsContext = {
setAnimationEnabled: StateSetter<boolean>; setAnimationEnabled: StateSetter<boolean>;
warriorName: string; warriorName: string;
setWarriorName: StateSetter<string>; setWarriorName: StateSetter<string>;
audioVolume: number;
setAudioVolume: StateSetter<number>;
}; };
type DebugContext = { type DebugContext = {
debugMode: boolean; debugMode: boolean;
setDebugMode: StateSetter<boolean>; setDebugMode: StateSetter<boolean>;
renderOnDemand: boolean;
setRenderOnDemand: StateSetter<boolean>;
}; };
type ControlsContext = { type ControlsContext = {
@ -39,6 +43,12 @@ type ControlsContext = {
setSpeedMultiplier: StateSetter<number>; setSpeedMultiplier: StateSetter<number>;
touchMode: TouchMode; touchMode: TouchMode;
setTouchMode: StateSetter<TouchMode>; setTouchMode: StateSetter<TouchMode>;
invertScroll: boolean;
setInvertScroll: StateSetter<boolean>;
invertDrag: boolean;
setInvertDrag: StateSetter<boolean>;
invertJoystick: boolean;
setInvertJoystick: StateSetter<boolean>;
}; };
const SettingsContext = createContext<SettingsContext | null>(null); const SettingsContext = createContext<SettingsContext | null>(null);
@ -55,6 +65,10 @@ type PersistedSettings = {
debugMode?: boolean; debugMode?: boolean;
touchMode?: TouchMode; touchMode?: TouchMode;
warriorName?: string; warriorName?: string;
audioVolume?: number;
invertScroll?: boolean;
invertDrag?: boolean;
invertJoystick?: boolean;
}; };
export function useSettings() { export function useSettings() {
@ -83,10 +97,15 @@ export function SettingsProvider({
const [speedMultiplier, setSpeedMultiplier] = useState(1); const [speedMultiplier, setSpeedMultiplier] = useState(1);
const [fov, setFov] = useState(90); const [fov, setFov] = useState(90);
const [audioEnabled, setAudioEnabled] = useState(false); const [audioEnabled, setAudioEnabled] = useState(false);
const [audioVolume, setAudioVolume] = useState(0.75);
const [animationEnabled, setAnimationEnabled] = useState(true); const [animationEnabled, setAnimationEnabled] = useState(true);
const [debugMode, setDebugMode] = useState(false); const [debugMode, setDebugMode] = useState(false);
const [touchMode, setTouchMode] = useState<TouchMode>("moveLookStick"); const [touchMode, setTouchMode] = useState<TouchMode>("moveLookStick");
const [warriorName, setWarriorName] = useState("MapGenius"); const [warriorName, setWarriorName] = useState("MapGenius");
const [invertScroll, setInvertScroll] = useState(false);
const [invertDrag, setInvertDrag] = useState(false);
const [invertJoystick, setInvertJoystick] = useState(false);
const [renderOnDemand, setRenderOnDemand] = useState(false);
const setFogEnabledWithoutOverride: StateSetter<boolean> = useCallback( const setFogEnabledWithoutOverride: StateSetter<boolean> = useCallback(
(value) => { (value) => {
@ -110,6 +129,8 @@ export function SettingsProvider({
setAnimationEnabled, setAnimationEnabled,
warriorName, warriorName,
setWarriorName, setWarriorName,
audioVolume,
setAudioVolume,
}), }),
[ [
fogEnabled, fogEnabled,
@ -120,21 +141,46 @@ export function SettingsProvider({
audioEnabled, audioEnabled,
animationEnabled, animationEnabled,
warriorName, warriorName,
audioVolume,
], ],
); );
const debugContext: DebugContext = useMemo( const debugContext: DebugContext = useMemo(
() => ({ debugMode, setDebugMode }), () => ({
[debugMode, setDebugMode], debugMode,
setDebugMode,
renderOnDemand,
setRenderOnDemand,
}),
[debugMode, setDebugMode, renderOnDemand],
); );
const controlsContext: ControlsContext = useMemo( const controlsContext: ControlsContext = useMemo(
() => ({ speedMultiplier, setSpeedMultiplier, touchMode, setTouchMode }), () => ({
[speedMultiplier, setSpeedMultiplier, touchMode, setTouchMode], speedMultiplier,
setSpeedMultiplier,
touchMode,
setTouchMode,
invertScroll,
setInvertScroll,
invertDrag,
setInvertDrag,
invertJoystick,
setInvertJoystick,
}),
[
speedMultiplier,
setSpeedMultiplier,
touchMode,
setTouchMode,
invertScroll,
invertDrag,
invertJoystick,
],
); );
// Read persisted settings from localStorage. // Read persisted settings from localStorage.
useLayoutEffect(() => { useEffect(() => {
let savedSettings: PersistedSettings = {}; let savedSettings: PersistedSettings = {};
try { try {
savedSettings = JSON.parse(localStorage.getItem("settings")) || {}; savedSettings = JSON.parse(localStorage.getItem("settings")) || {};
@ -157,7 +203,9 @@ export function SettingsProvider({
setHighQualityFog(savedSettings.highQualityFog); setHighQualityFog(savedSettings.highQualityFog);
} }
if (savedSettings.speedMultiplier != null) { if (savedSettings.speedMultiplier != null) {
setSpeedMultiplier(savedSettings.speedMultiplier); setSpeedMultiplier(
Math.max(0, Math.min(1, savedSettings.speedMultiplier)),
);
} }
if (savedSettings.fov != null) { if (savedSettings.fov != null) {
setFov(savedSettings.fov); setFov(savedSettings.fov);
@ -168,6 +216,18 @@ export function SettingsProvider({
if (savedSettings.warriorName != null) { if (savedSettings.warriorName != null) {
setWarriorName(savedSettings.warriorName); setWarriorName(savedSettings.warriorName);
} }
if (savedSettings.audioVolume != null) {
setAudioVolume(savedSettings.audioVolume);
}
if (savedSettings.invertScroll != null) {
setInvertScroll(savedSettings.invertScroll);
}
if (savedSettings.invertDrag != null) {
setInvertDrag(savedSettings.invertDrag);
}
if (savedSettings.invertJoystick != null) {
setInvertJoystick(savedSettings.invertJoystick);
}
}, []); }, []);
// Persist settings to localStorage with debouncing to avoid excessive writes // Persist settings to localStorage with debouncing to avoid excessive writes
@ -191,6 +251,10 @@ export function SettingsProvider({
debugMode, debugMode,
touchMode, touchMode,
warriorName, warriorName,
audioVolume,
invertScroll,
invertDrag,
invertJoystick,
}; };
try { try {
localStorage.setItem("settings", JSON.stringify(settingsToSave)); localStorage.setItem("settings", JSON.stringify(settingsToSave));
@ -214,6 +278,10 @@ export function SettingsProvider({
debugMode, debugMode,
touchMode, touchMode,
warriorName, warriorName,
audioVolume,
invertScroll,
invertDrag,
invertJoystick,
]); ]);
return ( return (

View file

@ -0,0 +1,28 @@
import { Component } from "react";
import type { ErrorInfo, ReactNode } from "react";
import { createLogger } from "../logger";
const log = createLogger("ShapeErrorBoundary");
/** Error boundary that renders a fallback when shape loading fails. */
export class ShapeErrorBoundary extends Component<
{ children: ReactNode; fallback: ReactNode },
{ hasError: boolean }
> {
state = { hasError: false };
static getDerivedStateFromError() {
return { hasError: true };
}
componentDidCatch(error: Error, info: ErrorInfo) {
log.error("Shape load failed: %s %s", error.message, info.componentStack);
}
render() {
if (this.state.hasError) {
return this.props.fallback;
}
return this.props.children;
}
}

View file

@ -1,13 +1,8 @@
import { useEffect, useMemo, useRef } from "react"; import { useEffect, useMemo, useRef } from "react";
import { useFrame } from "@react-three/fiber"; import { useFrame } from "@react-three/fiber";
import { import { AnimationMixer, LoopOnce, Quaternion, Vector3 } from "three";
AnimationMixer,
LoopOnce,
Quaternion,
Vector3,
} from "three";
import type { Group, Material } from "three"; import type { Group, Material } from "three";
import { effectNow, engineStore } from "../state"; import { effectNow, engineStore } from "../state/engineStore";
import * as SkeletonUtils from "three/examples/jsm/utils/SkeletonUtils.js"; import * as SkeletonUtils from "three/examples/jsm/utils/SkeletonUtils.js";
import { import {
_r90, _r90,
@ -21,14 +16,11 @@ import {
updateAtlasFrame, updateAtlasFrame,
} from "./useIflTexture"; } from "./useIflTexture";
import type { IflAtlas } from "./useIflTexture"; import type { IflAtlas } from "./useIflTexture";
import { import { ShapeRenderer, useStaticShape } from "./GenericShape";
ShapeRenderer,
useStaticShape,
} from "./GenericShape";
import { ShapeInfoProvider } from "./ShapeInfoProvider"; import { ShapeInfoProvider } from "./ShapeInfoProvider";
import type { TorqueObject } from "../torqueScript"; import type { TorqueObject } from "../torqueScript";
import type { StreamEntity } from "../stream/types"; import type { ExplosionEntity } from "../state/gameEntityTypes";
import type { StreamingPlayback } from "../stream/types"; import { streamPlaybackStore } from "../state/streamPlaybackStore";
/** /**
* Map weapon shape to the arm blend animation (armThread). * Map weapon shape to the arm blend animation (armThread).
@ -167,7 +159,13 @@ function extractSizeKeyframes(expBlock: Record<string, unknown>): {
const rawTimes = expBlock.times as number[] | undefined; const rawTimes = expBlock.times as number[] | undefined;
if (!Array.isArray(rawSizes) || rawSizes.length === 0) { if (!Array.isArray(rawSizes) || rawSizes.length === 0) {
return { times: [0, 1], sizes: [[1, 1, 1], [1, 1, 1]] }; return {
times: [0, 1],
sizes: [
[1, 1, 1],
[1, 1, 1],
],
};
} }
// sizes are packed as value*100 integers on the wire; divide by 100. // sizes are packed as value*100 integers on the wire; divide by 100.
@ -210,14 +208,9 @@ function interpolateSize(
* Renders an explosion DTS shape using useStaticShape (shared GLTF cache) * Renders an explosion DTS shape using useStaticShape (shared GLTF cache)
* with custom rendering for faceViewer, vis/IFL animation, and size keyframes. * with custom rendering for faceViewer, vis/IFL animation, and size keyframes.
*/ */
export function ExplosionShape({ export function ExplosionShape({ entity }: { entity: ExplosionEntity }) {
entity, const playback = streamPlaybackStore.getState().playback;
playback, const gltf = useStaticShape(entity.shapeName!);
}: {
entity: StreamEntity;
playback: StreamingPlayback;
}) {
const gltf = useStaticShape(entity.dataBlock!);
const groupRef = useRef<Group>(null); const groupRef = useRef<Group>(null);
const startTimeRef = useRef(effectNow()); const startTimeRef = useRef(effectNow());
// eslint-disable-next-line react-hooks/purity // eslint-disable-next-line react-hooks/purity
@ -256,7 +249,9 @@ export function ExplosionShape({
const iflInfos: IflInfo[] = []; const iflInfos: IflInfo[] = [];
scene.traverse((node: any) => { scene.traverse((node: any) => {
if (!node.isMesh || !node.material) return; if (!node.isMesh || !node.material) return;
const mat = Array.isArray(node.material) ? node.material[0] : node.material; const mat = Array.isArray(node.material)
? node.material[0]
: node.material;
if (!mat?.userData) return; if (!mat?.userData) return;
const flags = new Set<string>(mat.userData.flag_names ?? []); const flags = new Set<string>(mat.userData.flag_names ?? []);
const rp: string | undefined = mat.userData.resource_path; const rp: string | undefined = mat.userData.resource_path;
@ -270,12 +265,13 @@ export function ExplosionShape({
: undefined, : undefined,
duration: ud?.ifl_duration ? Number(ud.ifl_duration) : undefined, duration: ud?.ifl_duration ? Number(ud.ifl_duration) : undefined,
cyclic: ud?.ifl_sequence ? !!ud.ifl_cyclic : undefined, cyclic: ud?.ifl_sequence ? !!ud.ifl_cyclic : undefined,
toolBegin: ud?.ifl_tool_begin != null ? Number(ud.ifl_tool_begin) : undefined, toolBegin:
ud?.ifl_tool_begin != null ? Number(ud.ifl_tool_begin) : undefined,
}); });
} }
}); });
processShapeScene(scene, entity.dataBlock); processShapeScene(scene, entity.shapeName);
// Collect vis-animated nodes keyed by sequence name. // Collect vis-animated nodes keyed by sequence name.
const visNodes: VisNode[] = []; const visNodes: VisNode[] = [];
@ -290,7 +286,12 @@ export function ExplosionShape({
return; return;
// Only include vis nodes tied to the "ambient" sequence. // Only include vis nodes tied to the "ambient" sequence.
if (seqName === "ambient") { if (seqName === "ambient") {
visNodes.push({ mesh: node, keyframes: kf, duration: dur, cyclic: !!ud.vis_cyclic }); visNodes.push({
mesh: node,
keyframes: kf,
duration: dur,
cyclic: !!ud.vis_cyclic,
});
} }
}); });
@ -340,7 +341,9 @@ export function ExplosionShape({
}); });
// Disable frustum culling (explosion may scale beyond bounds). // Disable frustum culling (explosion may scale beyond bounds).
scene.traverse((child) => { child.frustumCulled = false; }); scene.traverse((child) => {
child.frustumCulled = false;
});
return { scene, mixer, visNodes, iflInfos, materials }; return { scene, mixer, visNodes, iflInfos, materials };
}, [gltf, expBlock]); }, [gltf, expBlock]);
@ -369,8 +372,8 @@ export function ExplosionShape({
if (!group) return; if (!group) return;
const playbackState = engineStore.getState().playback; const playbackState = engineStore.getState().playback;
const effectDelta = playbackState.status === "playing" const effectDelta =
? delta * playbackState.rate : 0; playbackState.status === "playing" ? delta * playbackState.rate : 0;
const elapsed = effectNow() - startTimeRef.current; const elapsed = effectNow() - startTimeRef.current;
const t = Math.min(elapsed / lifetimeMS, 1); const t = Math.min(elapsed / lifetimeMS, 1);

View file

@ -1,10 +1,4 @@
import { import { startTransition, useEffect, useMemo, useRef, useState } from "react";
startTransition,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { import {
Combobox, Combobox,
ComboboxItem, ComboboxItem,
@ -166,7 +160,9 @@ export function ShapeSelect({
onFocus={() => { onFocus={() => {
try { try {
document.exitPointerLock(); document.exitPointerLock();
} catch { /* expected */ } } catch {
/* expected */
}
combobox.show(); combobox.show();
}} }}
onKeyDown={(e) => { onKeyDown={(e) => {
@ -197,9 +193,7 @@ export function ShapeSelect({
{shapes.map(renderItem)} {shapes.map(renderItem)}
</ComboboxGroup> </ComboboxGroup>
))} ))}
{noResults && ( {noResults && <div className={styles.NoResults}>No shapes found</div>}
<div className={styles.NoResults}>No shapes found</div>
)}
</ComboboxList> </ComboboxList>
</ComboboxPopover> </ComboboxPopover>
</ComboboxProvider> </ComboboxProvider>

View file

@ -1,9 +1,9 @@
import { Suspense, useMemo, useEffect, useRef } from "react"; import { memo, Suspense, useMemo, useEffect, useRef } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useThree, useFrame } from "@react-three/fiber"; import { useThree, useFrame } from "@react-three/fiber";
import { useCubeTexture } from "@react-three/drei"; import { useCubeTexture } from "@react-three/drei";
import { Color, Fog } from "three"; import { Color, Fog } from "three";
import type { SceneSky } from "../scene/types"; import { createLogger } from "../logger";
import { useSettings } from "./SettingsProvider"; import { useSettings } from "./SettingsProvider";
import { loadDetailMapList, textureToUrl } from "../loaders"; import { loadDetailMapList, textureToUrl } from "../loaders";
import { CloudLayers } from "./CloudLayers"; import { CloudLayers } from "./CloudLayers";
@ -16,10 +16,13 @@ import {
resetGlobalFogUniforms, resetGlobalFogUniforms,
} from "../globalFogUniforms"; } from "../globalFogUniforms";
const log = createLogger("Sky");
// Track if fog shader has been installed (idempotent installation) // Track if fog shader has been installed (idempotent installation)
let fogShaderInstalled = false; let fogShaderInstalled = false;
import type { Color3 } from "../scene/types"; import type { Color3 } from "../scene/types";
import { SkyEntity } from "../state/gameEntityTypes";
/** Convert a Color3 to [sRGB Color, linear Color]. */ /** Convert a Color3 to [sRGB Color, linear Color]. */
function color3ToThree(c: Color3): [Color, Color] { function color3ToThree(c: Color3): [Color, Color] {
@ -33,10 +36,25 @@ function color3ToThree(c: Color3): [Color, Color] {
* Load a .dml file, used to list the textures for different faces of a skybox. * Load a .dml file, used to list the textures for different faces of a skybox.
*/ */
function useDetailMapList(name: string) { function useDetailMapList(name: string) {
return useQuery({ const result = useQuery({
queryKey: ["detailMapList", name], queryKey: ["detailMapList", name],
queryFn: () => loadDetailMapList(name), queryFn: () => {
log.debug("Loading detail map list: %s", name);
return loadDetailMapList(name);
},
}); });
useEffect(() => {
log.debug(
"DML query status: %s%s%s file=%s",
result.status,
result.error ? ` error=${result.error.message}` : "",
result.data ? ` (${result.data.length} entries)` : " (no data)",
name,
);
}, [result.status, result.error, result.data, name]);
return result;
} }
/** /**
@ -56,7 +74,7 @@ function SkyBoxTexture({
fogColor?: Color; fogColor?: Color;
fogState?: FogState; fogState?: FogState;
}) { }) {
const { camera } = useThree(); const camera = useThree((state) => state.camera);
const skyBox = useCubeTexture(skyBoxFiles, { path: "" }); const skyBox = useCubeTexture(skyBoxFiles, { path: "" });
const enableFog = !!fogColor; const enableFog = !!fogColor;
@ -320,7 +338,7 @@ function SolidColorSky({
fogColor?: Color; fogColor?: Color;
fogState?: FogState; fogState?: FogState;
}) { }) {
const { camera } = useThree(); const camera = useThree((state) => state.camera);
const enableFog = !!fogColor; const enableFog = !!fogColor;
@ -515,7 +533,8 @@ function DynamicFog({
fogState: FogState; fogState: FogState;
enabled: boolean; enabled: boolean;
}) { }) {
const { scene, camera } = useThree(); const scene = useThree((state) => state.scene);
const camera = useThree((state) => state.camera);
const fogRef = useRef<Fog | null>(null); const fogRef = useRef<Fog | null>(null);
// Pack fog volume data once (it doesn't change during runtime) // Pack fog volume data once (it doesn't change during runtime)
@ -594,29 +613,43 @@ function DynamicFog({
return null; return null;
} }
export function Sky({ scene }: { scene: SceneSky }) { export const Sky = memo(function Sky({ entity }: { entity: SkyEntity }) {
const { skyData } = entity;
log.debug(
"Rendering: materialList=%s, useSkyTextures=%s",
skyData.materialList,
skyData.useSkyTextures,
);
const { fogEnabled } = useSettings(); const { fogEnabled } = useSettings();
// Skybox textures // Skybox textures
const materialList = scene.materialList || undefined; const materialList = skyData.materialList || undefined;
const skySolidColor = useMemo( const skySolidColor = useMemo(
() => color3ToThree(scene.skySolidColor), () => color3ToThree(skyData.skySolidColor),
[scene.skySolidColor], [skyData.skySolidColor],
); );
const useSkyTextures = scene.useSkyTextures; const useSkyTextures = skyData.useSkyTextures;
// Parse full fog state from typed scene sky // Parse full fog state from typed scene sky
const fogState = useMemo( const fogState = useMemo(() => fogStateFromScene(skyData), [skyData]);
() => fogStateFromScene(scene),
[scene], log.debug(
"fogState: fogColor=(%s, %s, %s) visibleDistance=%d fogDistance=%d enabled=%s volumes=%d",
skyData.fogColor.r.toFixed(3),
skyData.fogColor.g.toFixed(3),
skyData.fogColor.b.toFixed(3),
skyData.visibleDistance,
skyData.fogDistance,
fogState.enabled,
fogState.fogVolumes.length,
); );
// Get sRGB fog color for background // Get sRGB fog color for background
const fogColor = useMemo( const fogColor = useMemo(
() => color3ToThree(scene.fogColor), () => color3ToThree(skyData.fogColor),
[scene.fogColor], [skyData.fogColor],
); );
const skyColor = skySolidColor || fogColor; const skyColor = skySolidColor || fogColor;
@ -629,7 +662,8 @@ export function Sky({ scene }: { scene: SceneSky }) {
// Set scene background color directly using useThree // Set scene background color directly using useThree
// This ensures the gap between fogged terrain and skybox blends correctly // This ensures the gap between fogged terrain and skybox blends correctly
const { scene: threeScene, gl } = useThree(); const threeScene = useThree((state) => state.scene);
const gl = useThree((state) => state.gl);
useEffect(() => { useEffect(() => {
if (hasFogParams) { if (hasFogParams) {
// Use effective fog color for background (matches terrain fog) // Use effective fog color for background (matches terrain fog)
@ -655,7 +689,7 @@ export function Sky({ scene }: { scene: SceneSky }) {
return ( return (
<> <>
{materialList && useSkyTextures && materialList.length > 0 ? ( {materialList && useSkyTextures && materialList.length > 0 ? (
<Suspense fallback={null}> <Suspense>
{/* Key forces remount when mission changes to clear texture caches */} {/* Key forces remount when mission changes to clear texture caches */}
<SkyBox <SkyBox
key={materialList} key={materialList}
@ -674,7 +708,7 @@ export function Sky({ scene }: { scene: SceneSky }) {
) : null} ) : null}
{/* Cloud layers render independently of skybox textures */} {/* Cloud layers render independently of skybox textures */}
<Suspense> <Suspense>
<CloudLayers scene={scene} /> <CloudLayers scene={skyData} />
</Suspense> </Suspense>
{/* Always render DynamicFog when mission has fog params. {/* Always render DynamicFog when mission has fog params.
Pass fogEnabled to control visibility - this avoids shader recompilation Pass fogEnabled to control visibility - this avoids shader recompilation
@ -684,4 +718,4 @@ export function Sky({ scene }: { scene: SceneSky }) {
) : null} ) : null}
</> </>
); );
} });

View file

@ -1,9 +0,0 @@
import { useRecording } from "./RecordingProvider";
import { StreamingController } from "./StreamingController";
export function StreamPlayback() {
const recording = useRecording();
if (!recording) return null;
return <StreamingController recording={recording} />;
}

View file

@ -1,10 +1,7 @@
import { Suspense, useCallback, useEffect, useRef, useState } from "react"; import { Suspense, useCallback, useEffect, useRef, useState } from "react";
import { useFrame } from "@react-three/fiber"; import { useFrame } from "@react-three/fiber";
import { useGLTF } from "@react-three/drei"; import { useGLTF } from "@react-three/drei";
import { import { Quaternion, Vector3 } from "three";
Quaternion,
Vector3,
} from "three";
import { import {
DEFAULT_EYE_HEIGHT, DEFAULT_EYE_HEIGHT,
STREAM_TICK_SEC, STREAM_TICK_SEC,
@ -14,7 +11,7 @@ import { shapeToUrl } from "../loaders";
import { ParticleEffects } from "./ParticleEffects"; import { ParticleEffects } from "./ParticleEffects";
import { PlayerEyeOffset } from "./PlayerModel"; import { PlayerEyeOffset } from "./PlayerModel";
import { stopAllTrackedSounds } from "./AudioEmitter"; import { stopAllTrackedSounds } from "./AudioEmitter";
import { useEngineStoreApi, advanceEffectClock } from "../state"; import { useEngineStoreApi, advanceEffectClock } from "../state/engineStore";
import { gameEntityStore } from "../state/gameEntityStore"; import { gameEntityStore } from "../state/gameEntityStore";
import { import {
streamPlaybackStore, streamPlaybackStore,
@ -33,7 +30,9 @@ type EntityById = Map<string, StreamEntity>;
/** Safely access a field that exists only on some GameEntity variants. */ /** Safely access a field that exists only on some GameEntity variants. */
function getField(entity: GameEntity, field: string): string | undefined { function getField(entity: GameEntity, field: string): string | undefined {
return (entity as unknown as Record<string, unknown>)[field] as string | undefined; return (entity as unknown as Record<string, unknown>)[field] as
| string
| undefined;
} }
/** Mutate render-affecting fields on an entity in-place from stream data. /** Mutate render-affecting fields on an entity in-place from stream data.
@ -49,6 +48,7 @@ function mutateRenderFields(
e.threads = stream.threads; e.threads = stream.threads;
e.weaponShape = stream.weaponShape; e.weaponShape = stream.weaponShape;
e.packShape = stream.packShape; e.packShape = stream.packShape;
e.flagShape = stream.flagShape;
e.falling = stream.falling; e.falling = stream.falling;
e.jetting = stream.jetting; e.jetting = stream.jetting;
e.weaponImageState = stream.weaponImageState; e.weaponImageState = stream.weaponImageState;
@ -98,8 +98,11 @@ const _billboardFlip = new Quaternion(0, 1, 0, 0); // 180° around Y
const _orbitDir = new Vector3(); const _orbitDir = new Vector3();
const _orbitTarget = new Vector3(); const _orbitTarget = new Vector3();
const _orbitCandidate = new Vector3(); const _orbitCandidate = new Vector3();
export function StreamingController({
export function StreamingController({ recording }: { recording: StreamRecording }) { recording,
}: {
recording: StreamRecording;
}) {
const engineStore = useEngineStoreApi(); const engineStore = useEngineStoreApi();
const playbackClockRef = useRef(0); const playbackClockRef = useRef(0);
const prevTickSnapshotRef = useRef<StreamSnapshot | null>(null); const prevTickSnapshotRef = useRef<StreamSnapshot | null>(null);
@ -117,30 +120,42 @@ export function StreamingController({ recording }: { recording: StreamRecording
const prevMap = entityMapRef.current; const prevMap = entityMapRef.current;
const nextMap = new Map<string, GameEntity>(); const nextMap = new Map<string, GameEntity>();
let shouldRebuild = false;
for (const entity of snapshot.entities) { for (const entity of snapshot.entities) {
let renderEntity = prevMap.get(entity.id); let renderEntity = prevMap.get(entity.id);
// Identity change -> new component (unmount/remount). // Identity change -> new component (unmount/remount).
// Compare fields that, when changed, require a full entity rebuild. // Compare fields that, when changed, require a full entity rebuild.
// Only compare shapeName for entity types that actually use it.
// Scene entities (Terrain, Interior, Sky, etc.), ForceFieldBare,
// AudioEmitter, WayPoint, and Camera don't have shapeName on their
// GameEntity, so comparing against entity.dataBlock would always
// mismatch and trigger a needless rebuild every frame.
const hasShapeName =
renderEntity &&
(renderEntity.renderType === "Shape" ||
renderEntity.renderType === "Player" ||
renderEntity.renderType === "Explosion");
const needsNewIdentity = const needsNewIdentity =
!renderEntity || !renderEntity ||
renderEntity.className !== (entity.className ?? entity.type) || renderEntity.className !== (entity.className ?? entity.type) ||
renderEntity.ghostIndex !== entity.ghostIndex || renderEntity.ghostIndex !== entity.ghostIndex ||
renderEntity.dataBlockId !== entity.dataBlockId || renderEntity.dataBlockId !== entity.dataBlockId ||
renderEntity.shapeHint !== entity.shapeHint || renderEntity.shapeHint !== entity.shapeHint ||
getField(renderEntity, "shapeName") !== entity.dataBlock || (hasShapeName &&
entity.dataBlock != null &&
getField(renderEntity, "shapeName") !== entity.dataBlock) ||
// weaponShape changes only force rebuild for non-Player shapes // weaponShape changes only force rebuild for non-Player shapes
// (turrets, vehicles). Players handle weapon changes internally // (turrets, vehicles). Players handle weapon changes internally
// via PlayerModel's Mount0 bone, and rebuilding on weapon change // via PlayerModel's Mount0 bone, and rebuilding on weapon change
// would lose animation state (death animations, etc.). // would lose animation state (death animations, etc.).
(renderEntity.renderType !== "Player" && (renderEntity.renderType !== "Player" &&
hasShapeName &&
getField(renderEntity, "weaponShape") !== entity.weaponShape); getField(renderEntity, "weaponShape") !== entity.weaponShape);
if (needsNewIdentity) { if (needsNewIdentity) {
renderEntity = streamEntityToGameEntity(entity, snapshot.timeSec); renderEntity = streamEntityToGameEntity(entity, snapshot.timeSec);
shouldRebuild = true;
} else { } else {
// Mutate render fields in-place on the existing entity object. // Mutate render fields in-place on the existing entity object.
// Components read these imperatively in useFrame — no React // Components read these imperatively in useFrame — no React
@ -153,7 +168,8 @@ export function StreamingController({ recording }: { recording: StreamRecording
// Keyframe update (mutable -- used for fallback position for // Keyframe update (mutable -- used for fallback position for
// retained explosion entities and per-frame reads in useFrame). // retained explosion entities and per-frame reads in useFrame).
// Scene entities and None don't have keyframes. // Scene entities and None don't have keyframes.
if (isSceneEntity(renderEntity) || renderEntity.renderType === "None") continue; if (isSceneEntity(renderEntity) || renderEntity.renderType === "None")
continue;
const keyframes = renderEntity.keyframes!; const keyframes = renderEntity.keyframes!;
if (keyframes.length === 0) { if (keyframes.length === 0) {
keyframes.push({ keyframes.push({
@ -189,12 +205,12 @@ export function StreamingController({ recording }: { recording: StreamRecording
continue; continue;
} }
} }
// Entity removed (or retention expired).
shouldRebuild = true;
} }
// Detect new entities added. // Only push to store when the entity set changed (adds/removes).
if (nextMap.size !== prevMap.size) shouldRebuild = true; const shouldRebuild =
nextMap.size !== prevMap.size ||
[...nextMap.keys()].some((id) => !prevMap.has(id));
entityMapRef.current = nextMap; entityMapRef.current = nextMap;
if (shouldRebuild) { if (shouldRebuild) {
@ -202,7 +218,10 @@ export function StreamingController({ recording }: { recording: StreamRecording
} }
let nextFirstPersonShape: string | null = null; let nextFirstPersonShape: string | null = null;
if (snapshot.camera?.mode === "first-person" && snapshot.camera.controlEntityId) { if (
snapshot.camera?.mode === "first-person" &&
snapshot.camera.controlEntityId
) {
const entity = nextMap.get(snapshot.camera.controlEntityId); const entity = nextMap.get(snapshot.camera.controlEntityId);
const sn = entity ? getField(entity, "shapeName") : undefined; const sn = entity ? getField(entity, "shapeName") : undefined;
if (sn) { if (sn) {
@ -231,13 +250,31 @@ export function StreamingController({ recording }: { recording: StreamRecording
const stream = streamRef.current; const stream = streamRef.current;
streamPlaybackStore.setState({ playback: stream }); streamPlaybackStore.setState({ playback: stream });
gameEntityStore.getState().beginStreaming(); gameEntityStore.getState().beginStreaming(recording.source);
if (!stream) { if (!stream) {
engineStore.getState().setPlaybackStreamSnapshot(null); engineStore.getState().setPlaybackStreamSnapshot(null);
return; return;
} }
// Update gameEntityStore when mission info arrives via server messages
// (MsgMissionDropInfo, MsgLoadInfo, MsgClientReady).
stream.onMissionInfoChange = () => {
gameEntityStore.getState().setMissionInfo({
missionDisplayName: stream.missionDisplayName ?? undefined,
missionTypeDisplayName: stream.missionTypeDisplayName ?? undefined,
gameClassName: stream.gameClassName ?? undefined,
recorderName: stream.connectedPlayerName ?? undefined,
});
};
// Save pre-populated mission info before reset clears it.
const savedMissionDisplayName = stream.missionDisplayName;
const savedMissionTypeDisplayName = stream.missionTypeDisplayName;
const savedGameClassName = stream.gameClassName;
const savedServerDisplayName = stream.serverDisplayName;
const savedConnectedPlayerName = stream.connectedPlayerName;
// Reset the stream cursor for demo playback (replay from the beginning). // Reset the stream cursor for demo playback (replay from the beginning).
// For live streams, skip reset — the adapter is already receiving packets // For live streams, skip reset — the adapter is already receiving packets
// and has accumulated protocol state (net strings, target info, sensor // and has accumulated protocol state (net strings, target info, sensor
@ -245,6 +282,26 @@ export function StreamingController({ recording }: { recording: StreamRecording
if (recording.source !== "live") { if (recording.source !== "live") {
stream.reset(); stream.reset();
} }
// Restore mission info fields that were parsed from the initial block
// (demoValues) — reset() clears them but they won't be re-sent.
stream.missionDisplayName = savedMissionDisplayName;
stream.missionTypeDisplayName = savedMissionTypeDisplayName;
stream.gameClassName = savedGameClassName;
stream.serverDisplayName = savedServerDisplayName;
stream.connectedPlayerName = savedConnectedPlayerName;
gameEntityStore.getState().setMissionInfo({
missionName: recording.missionName ?? undefined,
missionTypeDisplayName: recording.gameType ?? undefined,
missionDisplayName: savedMissionDisplayName ?? undefined,
gameClassName: savedGameClassName ?? undefined,
serverDisplayName:
savedServerDisplayName ?? recording.serverDisplayName ?? undefined,
recorderName:
savedConnectedPlayerName ?? recording.recorderName ?? undefined,
recordingDate: recording.recordingDate ?? undefined,
});
// Preload weapon effect shapes (explosions) so they're cached before // Preload weapon effect shapes (explosions) so they're cached before
// the first projectile detonates -- otherwise the GLB fetch latency // the first projectile detonates -- otherwise the GLB fetch latency
// causes the short-lived explosion entity to expire before it renders. // causes the short-lived explosion entity to expire before it renders.
@ -264,9 +321,11 @@ export function StreamingController({ recording }: { recording: StreamRecording
return () => { return () => {
stopAllTrackedSounds(); stopAllTrackedSounds();
gameEntityStore.getState().endStreaming(); // Null out streamRef so useFrame stops syncing entities.
streamRef.current = null;
// Don't call endStreaming() or clear the snapshot — leave entities,
// HUD, and chat in place as a frozen snapshot after disconnect.
resetStreamPlayback(); resetStreamPlayback();
engineStore.getState().setPlaybackStreamSnapshot(null);
}; };
}, [recording, engineStore, syncRenderableEntities]); }, [recording, engineStore, syncRenderableEntities]);
@ -279,9 +338,11 @@ export function StreamingController({ recording }: { recording: StreamRecording
const isPlaying = playback.status === "playing"; const isPlaying = playback.status === "playing";
const requestedTimeSec = playback.timeMs / 1000; const requestedTimeSec = playback.timeMs / 1000;
const externalSeekWhilePaused = const externalSeekWhilePaused =
!isPlaying && Math.abs(requestedTimeSec - playbackClockRef.current) > 0.0005; !isPlaying &&
Math.abs(requestedTimeSec - playbackClockRef.current) > 0.0005;
const externalSeekWhilePlaying = const externalSeekWhilePlaying =
isPlaying && Math.abs(requestedTimeSec - streamPlaybackStore.getState().time) > 0.05; isPlaying &&
Math.abs(requestedTimeSec - streamPlaybackStore.getState().time) > 0.05;
const isSeeking = externalSeekWhilePaused || externalSeekWhilePlaying; const isSeeking = externalSeekWhilePaused || externalSeekWhilePlaying;
if (isSeeking) { if (isSeeking) {
// Sync stream cursor to UI/programmatic seek. // Sync stream cursor to UI/programmatic seek.
@ -334,7 +395,10 @@ export function StreamingController({ recording }: { recording: StreamRecording
streamPlaybackStore.setState({ time: playbackClockRef.current }); streamPlaybackStore.setState({ time: playbackClockRef.current });
if (snapshot.exhausted && isPlaying) { if (snapshot.exhausted && isPlaying) {
playbackClockRef.current = Math.min(playbackClockRef.current, snapshot.timeSec); playbackClockRef.current = Math.min(
playbackClockRef.current,
snapshot.timeSec,
);
} }
syncRenderableEntities(renderCurrent); syncRenderableEntities(renderCurrent);
@ -404,7 +468,8 @@ export function StreamingController({ recording }: { recording: StreamRecording
const perspectiveCamera = state.camera as any; const perspectiveCamera = state.camera as any;
const fovValue = const fovValue =
previousCamera && Number.isFinite(previousCamera.fov) previousCamera && Number.isFinite(previousCamera.fov)
? previousCamera.fov + (currentCamera.fov - previousCamera.fov) * interpT ? previousCamera.fov +
(currentCamera.fov - previousCamera.fov) * interpT
: currentCamera.fov; : currentCamera.fov;
const verticalFov = torqueHorizontalFovToThreeVerticalFov( const verticalFov = torqueHorizontalFovToThreeVerticalFov(
fovValue, fovValue,
@ -436,7 +501,10 @@ export function StreamingController({ recording }: { recording: StreamRecording
// snapshot lifetime) won't be in the snapshot entity map. Fall back // snapshot lifetime) won't be in the snapshot entity map. Fall back
// to their last-known keyframe position from the render entity. // to their last-known keyframe position from the render entity.
if (!entity) { if (!entity) {
const kfs = renderEntity && "keyframes" in renderEntity ? renderEntity.keyframes : undefined; const kfs =
renderEntity && "keyframes" in renderEntity
? renderEntity.keyframes
: undefined;
if (kfs?.[0]?.position) { if (kfs?.[0]?.position) {
const kf = kfs[0]; const kf = kfs[0];
child.visible = true; child.visible = true;
@ -463,11 +531,17 @@ export function StreamingController({ recording }: { recording: StreamRecording
const iz = pz + (cz - pz) * interpT; const iz = pz + (cz - pz) * interpT;
child.position.set(iy, iz, ix); child.position.set(iy, iz, ix);
} else { } else {
child.position.set(entity.position[1], entity.position[2], entity.position[0]); child.position.set(
entity.position[1],
entity.position[2],
entity.position[0],
);
} }
if (entity.faceViewer) { if (entity.faceViewer) {
child.quaternion.copy(state.camera.quaternion).multiply(_billboardFlip); child.quaternion
.copy(state.camera.quaternion)
.multiply(_billboardFlip);
} else if (entity.visual?.kind === "tracer") { } else if (entity.visual?.kind === "tracer") {
child.quaternion.identity(); child.quaternion.identity();
} else if (entity.rotation) { } else if (entity.rotation) {
@ -486,7 +560,13 @@ export function StreamingController({ recording }: { recording: StreamRecording
const mode = currentCamera?.mode; const mode = currentCamera?.mode;
// In live mode, LiveObserver handles orbit positioning from predicted // In live mode, LiveObserver handles orbit positioning from predicted
// angles so the orbit responds at frame rate. Skip here to avoid fighting. // angles so the orbit responds at frame rate. Skip here to avoid fighting.
if (!freeFly && !isLive && mode === "third-person" && root && currentCamera?.orbitTargetId) { if (
!freeFly &&
!isLive &&
mode === "third-person" &&
root &&
currentCamera?.orbitTargetId
) {
const targetGroup = root.children.find( const targetGroup = root.children.find(
(child) => child.name === currentCamera.orbitTargetId, (child) => child.name === currentCamera.orbitTargetId,
); );
@ -530,7 +610,9 @@ export function StreamingController({ recording }: { recording: StreamRecording
if (hasDirection) { if (hasDirection) {
_orbitDir.normalize(); _orbitDir.normalize();
const orbitDistance = Math.max(0.1, currentCamera.orbitDistance ?? 4); const orbitDistance = Math.max(0.1, currentCamera.orbitDistance ?? 4);
_orbitCandidate.copy(_orbitTarget).addScaledVector(_orbitDir, orbitDistance); _orbitCandidate
.copy(_orbitTarget)
.addScaledVector(_orbitDir, orbitDistance);
state.camera.position.copy(_orbitCandidate); state.camera.position.copy(_orbitCandidate);
state.camera.lookAt(_orbitTarget); state.camera.lookAt(_orbitTarget);
@ -538,12 +620,19 @@ export function StreamingController({ recording }: { recording: StreamRecording
} }
} }
if (!freeFly && mode === "first-person" && root && currentCamera?.controlEntityId) { if (
!freeFly &&
mode === "first-person" &&
root &&
currentCamera?.controlEntityId
) {
const playerGroup = root.children.find( const playerGroup = root.children.find(
(child) => child.name === currentCamera.controlEntityId, (child) => child.name === currentCamera.controlEntityId,
); );
if (playerGroup) { if (playerGroup) {
_tmpVec.copy(eyeOffsetRef.current).applyQuaternion(playerGroup.quaternion); _tmpVec
.copy(eyeOffsetRef.current)
.applyQuaternion(playerGroup.quaternion);
state.camera.position.add(_tmpVec); state.camera.position.add(_tmpVec);
} else { } else {
state.camera.position.y += eyeOffsetRef.current.y; state.camera.position.y += eyeOffsetRef.current.y;
@ -567,8 +656,11 @@ export function StreamingController({ recording }: { recording: StreamRecording
snapshotRef={currentTickSnapshotRef} snapshotRef={currentTickSnapshotRef}
/> />
{firstPersonShape && ( {firstPersonShape && (
<Suspense fallback={null}> <Suspense>
<PlayerEyeOffset shapeName={firstPersonShape} eyeOffsetRef={eyeOffsetRef} /> <PlayerEyeOffset
shapeName={firstPersonShape}
eyeOffsetRef={eyeOffsetRef}
/>
</Suspense> </Suspense>
)} )}
</> </>

View file

@ -0,0 +1,90 @@
.Header {
flex: 1 1 auto;
display: flex;
align-items: center;
}
.MissionInfo {
display: flex;
align-items: center;
gap: 6px;
margin: 10px auto 10px 4px;
border: 1px solid rgba(255, 255, 255, 0);
border-radius: 3px;
background: rgba(0, 0, 0, 0);
color: #fff;
padding: 5px 8px;
}
.MissionName {
composes: SelectedName from "./MissionSelect.module.css";
}
.MissionType {
composes: ItemType from "./MissionSelect.module.css";
pointer-events: none;
}
.MissionTypeDisplayName {
font-size: 12px;
opacity: 0.5;
}
.Metadata {
display: flex;
flex-direction: column;
gap: 2px;
font-size: 12px;
line-height: calc(14 / 12);
text-align: right;
padding: 8px 12px;
margin: 0 0 0 auto;
}
.Attribution,
.ServerInfo {
color: #83938b;
}
.PlayerName,
.RecordingDate,
.ServerName {
color: #eceae7;
}
.ActionButton {
composes: IconButton from "./InspectorControls.module.css";
flex: 0 0 auto;
margin: 0 10px 0 0;
font-size: 16px;
min-width: 28px;
min-height: 28px;
padding: 2px;
/* display: flex;
align-items: center;
justify-content: center;
margin-right: 12px;
padding: 6px;
font-size: 18px;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 3px;
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.6);
cursor: pointer; */
}
/* .ActionButton:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff;
} */
.EjectIcon {
font-size: 21px;
margin-top: -0.5px;
}
@media (max-width: 899px) {
.Metadata {
display: none;
}
}

View file

@ -0,0 +1,118 @@
import { useCallback } from "react";
import {
useDataSource,
useMissionDisplayName,
useMissionType,
useMissionTypeDisplayName,
useRecorderName,
useRecordingDate,
useServerDisplayName,
} from "../state/gameEntityStore";
import { engineStore } from "../state/engineStore";
import {
liveConnectionStore,
useLiveSelector,
} from "../state/liveConnectionStore";
import { useRecording } from "./RecordingProvider";
import { LuCircleArrowOutUpLeft } from "react-icons/lu";
import { BiSolidEject } from "react-icons/bi";
import styles from "./StreamingMissionInfo.module.css";
export function StreamingMissionInfo() {
const dataSource = useDataSource();
const missionDisplayName = useMissionDisplayName();
const missionType = useMissionType();
const missionTypeDisplayName = useMissionTypeDisplayName();
const serverName = useServerDisplayName();
const playerName = useRecorderName();
const dateString = useRecordingDate();
const [datePart, timePart] = dateString
? dateString.split(" ")
: [null, null];
const isLive = dataSource === "live";
const recording = useRecording();
const isLiveConnected = useLiveSelector(
(s) => s.gameStatus === "connected" || s.gameStatus === "authenticating",
);
const handleEject = useCallback(() => {
engineStore.getState().setRecording(null);
}, []);
const handleDisconnect = useCallback(() => {
const liveState = liveConnectionStore.getState();
liveState.disconnectServer();
liveState.disconnectRelay();
engineStore.getState().setRecording(null);
}, []);
return (
<div className={styles.Header}>
<div className={styles.MissionInfo}>
{missionDisplayName && missionType ? (
<>
<span className={styles.MissionName}>{missionDisplayName}</span>
{missionType && (
<>
{" "}
<span
className={styles.MissionType}
data-mission-type={missionType}
>
{missionTypeDisplayName === "LCTF" ? "LCTF" : missionType}
</span>
</>
)}
</>
) : null}
</div>
<div className={styles.Metadata}>
{isLive ? (
playerName ? (
<div className={styles.Attribution}>
Connected as{" "}
<span className={styles.PlayerName}>{playerName}</span>
</div>
) : null
) : playerName && dateString ? (
<div className={styles.Attribution}>
Recorded by <span className={styles.PlayerName}>{playerName}</span>{" "}
on{" "}
<span className={styles.RecordingDate}>
{datePart.replace(/-/g, " ")}
</span>{" "}
at <span className={styles.RecordingDate}>{timePart}</span>
</div>
) : null}
{serverName ? (
<div className={styles.ServerInfo}>
Server: <span className={styles.ServerName}>{serverName}</span>
</div>
) : null}
</div>
{dataSource === "demo" ? (
<button
type="button"
className={styles.ActionButton}
title="Unload demo"
aria-label="Unload demo"
onClick={handleEject}
disabled={!recording}
>
<BiSolidEject className={styles.EjectIcon} />
</button>
) : isLive ? (
<button
type="button"
className={styles.ActionButton}
title="Disconnect"
aria-label="Disconnect"
onClick={handleDisconnect}
disabled={!isLiveConnected}
>
<LuCircleArrowOutUpLeft />
</button>
) : null}
</div>
);
}

View file

@ -1,5 +1,8 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { createLogger } from "../logger";
import type { SceneTSStatic } from "../scene/types"; import type { SceneTSStatic } from "../scene/types";
const log = createLogger("TSStatic");
import { import {
torqueToThree, torqueToThree,
torqueScaleToThree, torqueScaleToThree,
@ -18,10 +21,7 @@ export function TSStatic({ scene }: { scene: SceneTSStatic }) {
); );
const scale = useMemo(() => torqueScaleToThree(scene.scale), [scene.scale]); const scale = useMemo(() => torqueScaleToThree(scene.scale), [scene.scale]);
if (!scene.shapeName) { if (!scene.shapeName) {
console.error( log.error("TSStatic missing shapeName for ghostIndex %d", scene.ghostIndex);
"<TSStatic> missing shapeName for ghostIndex",
scene.ghostIndex,
);
} }
return ( return (
<ShapeInfoProvider type="TSStatic" shapeName={scene.shapeName}> <ShapeInfoProvider type="TSStatic" shapeName={scene.shapeName}>

View file

@ -17,7 +17,10 @@ import {
Vector3, Vector3,
} from "three"; } from "three";
import type { SceneTerrainBlock } from "../scene/types"; import type { SceneTerrainBlock } from "../scene/types";
import { createLogger } from "../logger";
import { torqueToThree } from "../scene/coordinates"; import { torqueToThree } from "../scene/coordinates";
const log = createLogger("TerrainBlock");
import { useSceneSky, useSceneSun } from "../state/gameEntityStore"; import { useSceneSky, useSceneSun } from "../state/gameEntityStore";
import { loadTerrain } from "../loaders"; import { loadTerrain } from "../loaders";
import { uint16ToFloat32 } from "../arrayUtils"; import { uint16ToFloat32 } from "../arrayUtils";
@ -413,10 +416,25 @@ function generateTerrainLightmap(
* Load a .ter file, used for terrain heightmap and texture info. * Load a .ter file, used for terrain heightmap and texture info.
*/ */
function useTerrain(terrainFile: string) { function useTerrain(terrainFile: string) {
return useQuery({ const result = useQuery({
queryKey: ["terrain", terrainFile], queryKey: ["terrain", terrainFile],
queryFn: () => loadTerrain(terrainFile), queryFn: () => {
log.debug("Loading terrain: %s", terrainFile);
return loadTerrain(terrainFile);
},
}); });
useEffect(() => {
log.debug(
"Query status: %s%s%s file=%s",
result.status,
result.error ? ` error=${result.error.message}` : "",
result.data ? " (data ready)" : " (no data)",
terrainFile,
);
}, [result.status, result.error, result.data, terrainFile]);
return result;
} }
/** /**
* Get visibleDistance from the Sky scene object, used to determine how far * Get visibleDistance from the Sky scene object, used to determine how far
@ -607,6 +625,13 @@ export const TerrainBlock = memo(function TerrainBlock({
!sharedDisplacementMap || !sharedDisplacementMap ||
!sharedAlphaTextures !sharedAlphaTextures
) { ) {
log.debug(
"Not ready: terrain=%s geometry=%s displacement=%s alpha=%s",
!!terrain,
!!sharedGeometry,
!!sharedDisplacementMap,
!!sharedAlphaTextures,
);
return null; return null;
} }
return ( return (

View file

@ -0,0 +1,41 @@
import { ReactNode } from "react";
import { Canvas, GLProps, RootState } from "@react-three/fiber";
import { NoToneMapping, PCFShadowMap, SRGBColorSpace } from "three";
import { useDebug } from "./SettingsProvider";
export type InvalidateFunction = RootState["invalidate"];
// Renderer settings to match Tribes 2's simple rendering pipeline.
// Tribes 2 (Torque engine, 2001) worked entirely in gamma/sRGB space with no HDR
// or tone mapping. We disable tone mapping and ensure proper sRGB output.
const glSettings: GLProps = {
toneMapping: NoToneMapping,
outputColorSpace: SRGBColorSpace,
};
export function ThreeCanvas({
children,
renderOnDemand: renderOnDemandFromProps = false,
dpr: dprFromProps,
onCreated,
}: {
children?: ReactNode;
dpr?: number;
renderOnDemand?: boolean;
onCreated?: (state: RootState) => void;
}) {
const { renderOnDemand: renderOnDemandFromSettings } = useDebug();
const renderOnDemand = renderOnDemandFromProps || renderOnDemandFromSettings;
return (
<Canvas
frameloop={renderOnDemand ? "demand" : "always"}
dpr={dprFromProps}
gl={glSettings}
shadows={{ type: PCFShadowMap }}
onCreated={onCreated}
>
{children}
</Canvas>
);
}

View file

@ -1,338 +0,0 @@
import { useEffect, useRef, type RefObject } from "react";
import { Euler, Vector3 } from "three";
import { useFrame, useThree } from "@react-three/fiber";
import type nipplejs from "nipplejs";
import { useControls } from "./SettingsProvider";
import styles from "./TouchControls.module.css";
/** Apply styles to nipplejs-generated `.back` and `.front` elements imperatively. */
function applyNippleStyles(zone: HTMLElement) {
const back = zone.querySelector<HTMLElement>(".back");
if (back) {
back.style.background = "rgba(3, 79, 76, 0.6)";
back.style.border = "1px solid rgba(0, 219, 223, 0.5)";
back.style.boxShadow = "inset 0 0 10px rgba(0, 0, 0, 0.7)";
}
const front = zone.querySelector<HTMLElement>(".front");
if (front) {
front.style.background =
"radial-gradient(circle at 50% 50%, rgba(23, 247, 198, 0.9) 0%, rgba(9, 184, 170, 0.95) 100%)";
front.style.border = "2px solid rgba(255, 255, 255, 0.4)";
front.style.boxShadow =
"0 2px 4px rgba(0, 0, 0, 0.5), 0 1px 1px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.15), inset 0 -1px 2px rgba(0, 0, 0, 0.3)";
}
}
const BASE_SPEED = 80;
const LOOK_SENSITIVITY = 0.004;
const STICK_LOOK_SENSITIVITY = 2.5;
const DUAL_MOVE_DEADZONE = 0.08;
const DUAL_LOOK_DEADZONE = 0.15;
const SINGLE_STICK_DEADZONE = 0.15;
const MAX_PITCH = Math.PI / 2 - 0.01; // ~89°
export type JoystickState = {
angle: number;
force: number;
};
type SharedProps = {
joystickState: RefObject<JoystickState>;
joystickZone: RefObject<HTMLDivElement | null>;
lookJoystickState: RefObject<JoystickState>;
lookJoystickZone: RefObject<HTMLDivElement | null>;
};
/** Renders the joystick zone(s). Place inside canvasContainer, outside Canvas. */
export function TouchJoystick({
joystickState,
joystickZone,
lookJoystickState,
lookJoystickZone,
}: SharedProps) {
const { touchMode } = useControls();
// Move joystick
useEffect(() => {
const zone = joystickZone.current;
if (!zone) return;
let manager: nipplejs.JoystickManager | null = null;
let cancelled = false;
import("nipplejs").then((mod) => {
if (cancelled) return;
manager = mod.default.create({
zone,
mode: "static",
position: { left: "70px", bottom: "70px" },
size: 120,
restOpacity: 0.9,
});
applyNippleStyles(zone);
manager.on("move", (_event, data) => {
joystickState.current.angle = data.angle.radian;
joystickState.current.force = Math.min(1, data.force);
});
manager.on("end", () => {
joystickState.current.force = 0;
});
});
return () => {
cancelled = true;
manager?.destroy();
};
}, [joystickState, joystickZone, touchMode]);
// Look joystick (dual stick mode only)
useEffect(() => {
if (touchMode !== "dualStick") return;
const zone = lookJoystickZone.current;
if (!zone) return;
let manager: nipplejs.JoystickManager | null = null;
let cancelled = false;
import("nipplejs").then((mod) => {
if (cancelled) return;
manager = mod.default.create({
zone,
mode: "static",
position: { right: "70px", bottom: "70px" },
size: 120,
restOpacity: 0.9,
});
applyNippleStyles(zone);
manager.on("move", (_event, data) => {
lookJoystickState.current.angle = data.angle.radian;
lookJoystickState.current.force = Math.min(1, data.force);
});
manager.on("end", () => {
lookJoystickState.current.force = 0;
});
});
return () => {
cancelled = true;
manager?.destroy();
};
}, [touchMode, lookJoystickState, lookJoystickZone]);
const blurActiveElement = () => {
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
};
if (touchMode === "dualStick") {
return (
<>
<div
ref={joystickZone}
className={styles.Left}
onContextMenu={(e) => e.preventDefault()}
onTouchStart={blurActiveElement}
/>
<div
ref={lookJoystickZone}
className={styles.Right}
onContextMenu={(e) => e.preventDefault()}
onTouchStart={blurActiveElement}
/>
</>
);
}
return (
<div
ref={joystickZone}
className={styles.Joystick}
onContextMenu={(e) => e.preventDefault()}
onTouchStart={blurActiveElement}
/>
);
}
/** Handles touch look and joystick-driven movement. Place inside Canvas. */
export function TouchCameraMovement({
joystickState,
joystickZone,
lookJoystickState,
}: SharedProps) {
const { speedMultiplier, touchMode } = useControls();
const { camera, gl } = useThree();
// Touch look state
const euler = useRef(new Euler(0, 0, 0, "YXZ"));
const lookTouchId = useRef<number | null>(null);
const lastTouchPos = useRef({ x: 0, y: 0 });
// Scratch vectors
const forwardVec = useRef(new Vector3());
const sideVec = useRef(new Vector3());
const moveVec = useRef(new Vector3());
// Initialize euler from current camera rotation on mount
useEffect(() => {
euler.current.setFromQuaternion(camera.quaternion, "YXZ");
}, [camera]);
// Touch-drag look handling (moveLookStick mode)
useEffect(() => {
if (touchMode !== "moveLookStick") return;
const canvas = gl.domElement;
const isTouchOnJoystick = (touch: Touch) => {
const zone = joystickZone.current;
if (!zone) return false;
const rect = zone.getBoundingClientRect();
return (
touch.clientX >= rect.left &&
touch.clientX <= rect.right &&
touch.clientY >= rect.top &&
touch.clientY <= rect.bottom
);
};
const handleTouchStart = (e: TouchEvent) => {
if (lookTouchId.current !== null) return;
for (let i = 0; i < e.changedTouches.length; i++) {
const touch = e.changedTouches[i];
if (!isTouchOnJoystick(touch)) {
lookTouchId.current = touch.identifier;
lastTouchPos.current = { x: touch.clientX, y: touch.clientY };
break;
}
}
};
const handleTouchMove = (e: TouchEvent) => {
if (lookTouchId.current === null) return;
for (let i = 0; i < e.changedTouches.length; i++) {
const touch = e.changedTouches[i];
if (touch.identifier === lookTouchId.current) {
const dx = touch.clientX - lastTouchPos.current.x;
const dy = touch.clientY - lastTouchPos.current.y;
lastTouchPos.current = { x: touch.clientX, y: touch.clientY };
euler.current.setFromQuaternion(camera.quaternion, "YXZ");
euler.current.y -= dx * LOOK_SENSITIVITY;
euler.current.x -= dy * LOOK_SENSITIVITY;
euler.current.x = Math.max(
-MAX_PITCH,
Math.min(MAX_PITCH, euler.current.x),
);
camera.quaternion.setFromEuler(euler.current);
break;
}
}
};
const handleTouchEnd = (e: TouchEvent) => {
for (let i = 0; i < e.changedTouches.length; i++) {
if (e.changedTouches[i].identifier === lookTouchId.current) {
lookTouchId.current = null;
break;
}
}
};
canvas.addEventListener("touchstart", handleTouchStart, { passive: true });
canvas.addEventListener("touchmove", handleTouchMove, { passive: true });
canvas.addEventListener("touchend", handleTouchEnd, { passive: true });
canvas.addEventListener("touchcancel", handleTouchEnd, { passive: true });
return () => {
canvas.removeEventListener("touchstart", handleTouchStart);
canvas.removeEventListener("touchmove", handleTouchMove);
canvas.removeEventListener("touchend", handleTouchEnd);
canvas.removeEventListener("touchcancel", handleTouchEnd);
lookTouchId.current = null;
};
}, [camera, gl.domElement, joystickZone, touchMode]);
useFrame((_state, delta) => {
const { force, angle } = joystickState.current;
if (touchMode === "dualStick") {
// Right stick → camera rotation
const look = lookJoystickState.current;
if (look.force > DUAL_LOOK_DEADZONE) {
const lookForce =
(look.force - DUAL_LOOK_DEADZONE) / (1 - DUAL_LOOK_DEADZONE);
const lookX = Math.cos(look.angle);
const lookY = Math.sin(look.angle);
euler.current.setFromQuaternion(camera.quaternion, "YXZ");
euler.current.y -= lookX * lookForce * STICK_LOOK_SENSITIVITY * delta;
euler.current.x += lookY * lookForce * STICK_LOOK_SENSITIVITY * delta;
euler.current.x = Math.max(
-MAX_PITCH,
Math.min(MAX_PITCH, euler.current.x),
);
camera.quaternion.setFromEuler(euler.current);
}
// Left stick → movement
if (force > DUAL_MOVE_DEADZONE) {
const moveForce =
(force - DUAL_MOVE_DEADZONE) / (1 - DUAL_MOVE_DEADZONE);
const speed = BASE_SPEED * speedMultiplier * moveForce;
const joyX = Math.cos(angle);
const joyY = Math.sin(angle);
camera.getWorldDirection(forwardVec.current);
forwardVec.current.normalize();
sideVec.current.crossVectors(camera.up, forwardVec.current).normalize();
moveVec.current
.set(0, 0, 0)
.addScaledVector(forwardVec.current, joyY)
.addScaledVector(sideVec.current, -joyX);
if (moveVec.current.lengthSq() > 0) {
moveVec.current.normalize().multiplyScalar(speed * delta);
camera.position.add(moveVec.current);
}
}
} else if (touchMode === "moveLookStick") {
if (force > 0) {
// Move forward at half the configured speed.
const speed = BASE_SPEED * speedMultiplier * 0.5;
camera.getWorldDirection(forwardVec.current);
forwardVec.current.normalize();
moveVec.current.copy(forwardVec.current).multiplyScalar(speed * delta);
camera.position.add(moveVec.current);
if (force >= SINGLE_STICK_DEADZONE) {
// Outer zone: also control camera look (yaw + pitch).
const lookX = Math.cos(angle);
const lookY = Math.sin(angle);
const lookForce =
(force - SINGLE_STICK_DEADZONE) / (1 - SINGLE_STICK_DEADZONE);
euler.current.setFromQuaternion(camera.quaternion, "YXZ");
euler.current.y -=
lookX * lookForce * STICK_LOOK_SENSITIVITY * 0.5 * delta;
euler.current.x +=
lookY * lookForce * STICK_LOOK_SENSITIVITY * 0.5 * delta;
euler.current.x = Math.max(
-MAX_PITCH,
Math.min(MAX_PITCH, euler.current.x),
);
camera.quaternion.setFromEuler(euler.current);
}
}
}
});
return null;
}

Some files were not shown because too many files have changed in this diff Show more