From 4741f59582bb31d0fa882843b8ea834f9913f183 Mon Sep 17 00:00:00 2001 From: Brian Beck Date: Thu, 12 Mar 2026 16:25:04 -0700 Subject: [PATCH] new UI, unify map/demo/live architecture more, cleanup --- .env.example | 6 + README.md | 12 +- app/layout.tsx | 6 +- app/page.tsx | 512 +----------------- app/shapes/page.tsx | 9 +- app/style.css | 5 - eslint.config.mjs | 1 + next-env.d.ts | 2 +- next.config.ts | 1 + package-lock.json | 274 ++++++++++ package.json | 1 + relay/HuffmanWriter.ts | 37 +- relay/auth.ts | 11 +- relay/crc.ts | 12 +- relay/gameConnection.ts | 149 +++-- relay/masterQuery.ts | 22 +- relay/protocol.ts | 35 +- relay/server.ts | 38 +- relay/types.ts | 23 +- scripts/check-mount-points.ts | 4 +- scripts/compute-mount-world.ts | 4 +- scripts/convert-wav.ts | 4 +- scripts/inspect-glb-nodes.ts | 53 +- scripts/play-demo.ts | 4 +- scripts/t2-login.ts | 14 +- scripts/t2-server-list.ts | 8 +- src/components/Accordion.module.css | 67 +++ src/components/Accordion.tsx | 29 + src/components/AudioContext.tsx | 16 +- src/components/AudioEmitter.tsx | 83 ++- src/components/AudioEnabled.tsx | 7 + src/components/Camera.tsx | 9 +- src/components/CamerasProvider.tsx | 5 +- src/components/ChatInput.module.css | 26 + src/components/ChatInput.tsx | 33 ++ src/components/ChatSoundPlayer.tsx | 34 +- src/components/ChatWindow.module.css | 63 +++ src/components/ChatWindow.tsx | 84 +++ src/components/CloudLayers.tsx | 11 +- src/components/CopyCoordinatesButton.tsx | 9 +- src/components/DebugElements.tsx | 6 +- src/components/DebugEnabled.tsx | 8 + src/components/DebugSuspense.tsx | 50 ++ .../DemoPlaybackControls.module.css | 6 - src/components/DemoPlaybackControls.tsx | 1 + src/components/EntityRenderer.tsx | 221 +++----- src/components/EntityScene.tsx | 185 ++----- src/components/FlagMarker.tsx | 14 +- src/components/FogProvider.tsx | 9 +- src/components/FollowControls.tsx | 0 src/components/ForceFieldBare.tsx | 24 +- ...utton.module.css => GameDialog.module.css} | 31 +- src/components/GenericShape.tsx | 14 +- src/components/InputHandlers.tsx | 37 ++ src/components/InspectorControls.module.css | 254 ++++++--- src/components/InspectorControls.tsx | 453 ++++++++++------ src/components/InteriorInstance.tsx | 23 +- src/components/JoinServerButton.module.css | 7 +- src/components/JoinServerButton.tsx | 27 +- src/components/JoystickContext.tsx | 69 +++ ...ntrols.tsx => KeyboardAndMouseHandler.tsx} | 111 ++-- src/components/KeyboardOverlay.module.css | 2 +- src/components/KeyboardOverlay.tsx | 2 +- src/components/LiveConnection.tsx | 15 - src/components/LiveObserver.tsx | 60 +- src/components/LoadDemoButton.module.css | 8 +- src/components/LoadDemoButton.tsx | 43 +- .../components/LoadingIndicator.module.css | 9 - src/components/LoadingIndicator.tsx | 22 + src/components/MapInfoDialog.module.css | 32 +- src/components/MapInfoDialog.tsx | 31 +- src/components/MapInspector.module.css | 169 ++++++ src/components/MapInspector.tsx | 387 +++++++++++++ src/components/Mission.tsx | 18 +- src/components/MissionSelect.module.css | 19 +- src/components/MissionSelect.tsx | 34 +- src/components/ParticleEffects.tsx | 105 +++- src/components/PlayerHUD.module.css | 103 +--- src/components/PlayerHUD.tsx | 189 ++----- src/components/PlayerModel.tsx | 302 +++++++---- src/components/PlayerNameplate.tsx | 7 +- src/components/Projectiles.tsx | 50 +- src/components/RecordingProvider.tsx | 2 +- src/components/RuntimeProvider.tsx | 2 - src/components/SceneLighting.tsx | 28 +- src/components/ServerBrowser.module.css | 57 +- src/components/ServerBrowser.tsx | 234 ++++---- src/components/SettingsProvider.tsx | 80 ++- src/components/ShapeErrorBoundary.tsx | 28 + src/components/ShapeModel.tsx | 61 ++- src/components/ShapeSelect.tsx | 16 +- src/components/Sky.tsx | 76 ++- src/components/StreamPlayback.tsx | 9 - src/components/StreamingController.tsx | 158 ++++-- .../StreamingMissionInfo.module.css | 90 +++ src/components/StreamingMissionInfo.tsx | 118 ++++ src/components/TSStatic.tsx | 8 +- src/components/TerrainBlock.tsx | 29 +- src/components/ThreeCanvas.tsx | 41 ++ src/components/TouchControls.tsx | 338 ------------ src/components/TouchHandler.tsx | 218 ++++++++ ...ls.module.css => TouchJoystick.module.css} | 2 +- src/components/TouchJoystick.tsx | 130 +++++ src/components/Turret.tsx | 9 +- src/components/VisualInput.tsx | 29 + src/components/WaterBlock.tsx | 22 +- src/components/useDatablock.ts | 2 +- src/components/useDistanceFromCamera.ts | 2 +- src/components/useIflTexture.ts | 5 +- src/components/usePublicWindowAPI.ts | 29 + src/components/useQueryParams.ts | 50 ++ src/components/useSceneObject.ts | 2 +- src/loaders.ts | 17 +- src/logger.ts | 88 +++ src/manifest.ts | 4 + src/mission.ts | 2 +- src/particles/ParticleSystem.ts | 37 +- src/scene/coordinates.spec.ts | 37 +- src/scene/coordinates.ts | 4 +- src/scene/crossValidation.spec.ts | 11 +- src/scene/ghostToScene.spec.ts | 7 +- src/scene/ghostToScene.ts | 104 +++- src/scene/misToScene.spec.ts | 4 +- src/scene/misToScene.ts | 22 +- src/state/engineStore.ts | 27 +- src/state/gameEntityStore.ts | 251 ++++++++- src/state/gameEntityTypes.ts | 8 +- src/state/index.ts | 62 --- src/state/liveConnectionStore.ts | 56 +- src/state/streamPlaybackStore.ts | 6 +- src/stream/StreamEngine.ts | 252 ++++++--- src/stream/demoStreaming.ts | 104 ++-- src/stream/entityBridge.ts | 30 +- src/stream/liveStreaming.ts | 279 +++++++--- src/stream/missionEntityBridge.ts | 71 ++- src/stream/playbackUtils.ts | 15 +- src/stream/relayClient.ts | 37 +- src/stream/streamHelpers.ts | 39 +- src/stream/types.ts | 22 + src/stream/weaponStateMachine.ts | 37 +- src/torqueScript/engineMethods.ts | 9 +- src/torqueScript/reactivity.ts | 5 +- src/torqueScript/runtime.spec.ts | 34 +- src/torqueScript/runtime.ts | 25 +- src/torqueScript/scriptLoader.browser.ts | 10 +- src/torqueScript/shapeConstructor.ts | 11 +- 146 files changed, 5477 insertions(+), 3005 deletions(-) create mode 100644 src/components/Accordion.module.css create mode 100644 src/components/Accordion.tsx create mode 100644 src/components/AudioEnabled.tsx create mode 100644 src/components/ChatInput.module.css create mode 100644 src/components/ChatInput.tsx create mode 100644 src/components/ChatWindow.module.css create mode 100644 src/components/ChatWindow.tsx create mode 100644 src/components/DebugEnabled.tsx create mode 100644 src/components/DebugSuspense.tsx create mode 100644 src/components/FollowControls.tsx rename src/components/{DialogButton.module.css => GameDialog.module.css} (67%) create mode 100644 src/components/InputHandlers.tsx create mode 100644 src/components/JoystickContext.tsx rename src/components/{ObserverControls.tsx => KeyboardAndMouseHandler.tsx} (92%) delete mode 100644 src/components/LiveConnection.tsx rename app/page.module.css => src/components/LoadingIndicator.module.css (90%) create mode 100644 src/components/LoadingIndicator.tsx create mode 100644 src/components/MapInspector.module.css create mode 100644 src/components/MapInspector.tsx create mode 100644 src/components/ShapeErrorBoundary.tsx delete mode 100644 src/components/StreamPlayback.tsx create mode 100644 src/components/StreamingMissionInfo.module.css create mode 100644 src/components/StreamingMissionInfo.tsx create mode 100644 src/components/ThreeCanvas.tsx delete mode 100644 src/components/TouchControls.tsx create mode 100644 src/components/TouchHandler.tsx rename src/components/{TouchControls.module.css => TouchJoystick.module.css} (92%) create mode 100644 src/components/TouchJoystick.tsx create mode 100644 src/components/VisualInput.tsx create mode 100644 src/components/usePublicWindowAPI.ts create mode 100644 src/components/useQueryParams.ts create mode 100644 src/logger.ts delete mode 100644 src/state/index.ts diff --git a/.env.example b/.env.example index 1852c903..cf55e777 100644 --- a/.env.example +++ b/.env.example @@ -24,3 +24,9 @@ T2_ACCOUNT_PASSWORD= # Relay URL for the browser client (default: 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 diff --git a/README.md b/README.md index 38e3a348..af1799e6 100644 --- a/README.md +++ b/README.md @@ -119,12 +119,12 @@ This only needs to be done once — the volume persists across deploys. **Environment variables** (all optional, with defaults): -| Variable | Default | Description | -| --- | --- | --- | -| `RELAY_PORT` | `8765` | WebSocket listen port | -| `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 | -| `T2_MASTER_SERVER` | `master.tribesnext.com` | Master server for server list queries | +| Variable | Default | Description | +| ------------------ | ----------------------------------------------- | ------------------------------------- | +| `RELAY_PORT` | `8765` | WebSocket listen port | +| `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 | +| `T2_MASTER_SERVER` | `master.tribesnext.com` | Master server for server list queries | ### Running scripts diff --git a/app/layout.tsx b/app/layout.tsx index b4d43093..c7f9f897 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -18,11 +18,7 @@ export default function RootLayout({ children }: { children: ReactNode }) { return ( - + {children} diff --git a/app/page.tsx b/app/page.tsx index a2ba1bfa..5abd2ec6 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,521 +1,21 @@ "use client"; -import { - 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 { Suspense } from "react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { - ObserverControls, - KEYBOARD_CONTROLS, -} 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, - })), -); +// import { LiveConnectionProvider } from "@/src/components/LiveConnection"; +import { FeaturesProvider } from "@/src/components/FeaturesProvider"; +import { MapInspector } from "@/src/components/MapInspector"; // 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. 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({ - 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(null); - const joystickStateRef = useRef({ angle: 0, force: 0 }); - const joystickZoneRef = useRef(null); - const lookJoystickStateRef = useRef({ angle: 0, force: 0 }); - const lookJoystickZoneRef = useRef(null); - - return ( - -
- - - -
- {showLoadingIndicator && ( -
-
-
-
-
-
- {Math.round(loadingProgress * 100)}% -
-
- )} - { - cameraRef.current = state.camera; - }} - > - - - - - - - - - - - - - -
- - {isTouch && ( - - )} - {isTouch === false && } - setMapInfoOpen(true)} - onOpenServerBrowser={features.live ? () => setServerBrowserOpen(true) : undefined} - cameraRef={cameraRef} - isTouch={isTouch} - /> - {mapInfoOpen && ( - - setMapInfoOpen(false)} - missionName={missionName} - missionType={missionType ?? ""} - /> - - )} - setServerBrowserOpen(false)} - /> - - - - - -
-
- ); -} - -/** - * 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 ( - - ); -} - -/** - * 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; - joystickZoneRef: React.RefObject; - lookJoystickStateRef: React.RefObject; - lookJoystickZoneRef: React.RefObject; -}) { - 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 && ( - - - - )} - {isLive && ( - - - - )} - {isStreaming && ( - - - - )} - {showObserverControls && isTouch !== null && ( - isTouch ? ( - - ) : ( - - ) - )} - - ); -} - -/** 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 ; -} - -/** 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 ( - - - - ); -} - -/** 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 ( - 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() { return ( - + - + ); diff --git a/app/shapes/page.tsx b/app/shapes/page.tsx index eeb58ba7..76222092 100644 --- a/app/shapes/page.tsx +++ b/app/shapes/page.tsx @@ -13,16 +13,13 @@ import * as THREE from "three"; import { NoToneMapping, SRGBColorSpace, PCFShadowMap } from "three"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { OrbitControls, Center, Bounds, useBounds } from "@react-three/drei"; -import { - SettingsProvider, - useDebug, -} from "@/src/components/SettingsProvider"; +import { SettingsProvider, useDebug } from "@/src/components/SettingsProvider"; import { ShapeRenderer, useStaticShape } from "@/src/components/GenericShape"; import { ShapeInfoProvider } from "@/src/components/ShapeInfoProvider"; import { DebugElements } from "@/src/components/DebugElements"; import { TickProvider } from "@/src/components/TickProvider"; import { ShapeSelect } from "@/src/components/ShapeSelect"; -import { engineStore, useEngineSelector } from "@/src/state"; +import { engineStore, useEngineSelector } from "@/src/state/engineStore"; import { getResourceList, getResourceMap, @@ -328,7 +325,7 @@ function ShapeInspector() { - + /// -import "./.next/types/routes.d.ts"; +import "./docs/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/next.config.ts b/next.config.ts index 2958a932..dc957e10 100644 --- a/next.config.ts +++ b/next.config.ts @@ -10,6 +10,7 @@ const nextConfig = (phase, { defaultConfig }): NextConfig => { assetPrefix: "/t2-mapper/", trailingSlash: true, reactCompiler: true, + experimental: { viewTransition: true }, headers: // 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 diff --git a/package-lock.json b/package-lock.json index 5508e614..50d4ea13 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "@ariakit/react": "^0.4.21", + "@radix-ui/react-accordion": "^1.2.12", "@react-three/drei": "^10.7.7", "@react-three/fiber": "^9.5.0", "@tanstack/react-query": "^5.90.21", @@ -1964,6 +1965,279 @@ "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": { "version": "10.7.7", "resolved": "https://registry.npmjs.org/@react-three/drei/-/drei-10.7.7.tgz", diff --git a/package.json b/package.json index a3f6125f..3a8050ac 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ }, "dependencies": { "@ariakit/react": "^0.4.21", + "@radix-ui/react-accordion": "^1.2.12", "@react-three/drei": "^10.7.7", "@react-three/fiber": "^9.5.0", "@tanstack/react-query": "^5.90.21", diff --git a/relay/HuffmanWriter.ts b/relay/HuffmanWriter.ts index 84b23ca0..c9518056 100644 --- a/relay/HuffmanWriter.ts +++ b/relay/HuffmanWriter.ts @@ -2,32 +2,24 @@ import { BitStreamWriter } from "./BitStreamWriter.js"; /** Hardcoded character frequency table from the V12 engine (bitStream.cc). */ 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, 0, 0, 0, 0, 0, 0, 0, - 2809, 68, 0, 27, 0, 58, 3, 62, 4, 7, 0, 0, 15, 65, 554, 3, - 394, 404, 189, 117, 30, 51, 27, 15, 34, 32, 80, 1, 142, 3, 142, 39, - 0, 144, 125, 44, 122, 275, 70, 135, 61, 127, 8, 12, 113, 246, 122, 36, - 185, 1, 149, 309, 335, 12, 11, 14, 54, 151, 0, 0, 2, 0, 0, 211, - 0, 2090, 344, 736, 993, 2872, 701, 605, 646, 1552, 328, 305, 1240, 735, 1533, 1713, - 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, 329, 21, 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, + 3, 394, 404, 189, 117, 30, 51, 27, 15, 34, 32, 80, 1, 142, 3, 142, 39, 0, 144, + 125, 44, 122, 275, 70, 135, 61, 127, 8, 12, 113, 246, 122, 36, 185, 1, 149, + 309, 335, 12, 11, 14, 54, 151, 0, 0, 2, 0, 0, 211, 0, 2090, 344, 736, 993, + 2872, 701, 605, 646, 1552, 328, 305, 1240, 735, 1533, 1713, 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, ]; const PROB_BOOST = 1; function isAlphaNumeric(c: number): boolean { - return ( - (c >= 48 && c <= 57) || - (c >= 65 && c <= 90) || - (c >= 97 && c <= 122) - ); + return (c >= 48 && c <= 57) || (c >= 65 && c <= 90) || (c >= 97 && c <= 122); } interface HuffLeaf { @@ -62,7 +54,8 @@ function buildTables(): void { leaves = []; for (let i = 0; i < 256; i++) { 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, numBits: 0, code: 0, diff --git a/relay/auth.ts b/relay/auth.ts index 13da7616..bec39842 100644 --- a/relay/auth.ts +++ b/relay/auth.ts @@ -317,7 +317,10 @@ export class T2csriAuth { // Sanitize: must be hex only const challenge = this.encryptedChallenge.toLowerCase(); authLog.info( - { challengeLen: challenge.length, clientChallengeLen: this.clientChallenge.length }, + { + challengeLen: challenge.length, + clientChallengeLen: this.clientChallenge.length, + }, "Auth: starting challenge decryption", ); for (let i = 0; i < challenge.length; i++) { @@ -339,7 +342,11 @@ export class T2csriAuth { const modulusHex = fields[3]; 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", ); diff --git a/relay/crc.ts b/relay/crc.ts index f6600105..42a599dc 100644 --- a/relay/crc.ts +++ b/relay/crc.ts @@ -49,7 +49,9 @@ export interface CRCDataBlock { } // 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[]]; interface Manifest { resources: Record; @@ -120,8 +122,8 @@ export async function computeGameCRC( console.log( `[crc] starting computation: seed=0x${(seed >>> 0).toString(16)}, ` + - `${sorted.length} ShapeBaseData datablocks (of ${datablocks.length} total), ` + - `includeTextures=${includeTextures}`, + `${sorted.length} ShapeBaseData datablocks (of ${datablocks.length} total), ` + + `includeTextures=${includeTextures}`, ); for (const db of sorted) { @@ -153,7 +155,7 @@ export async function computeGameCRC( console.log( `[crc] #${filesFound} id=${db.objectId} ${db.className} "${db.shapeName}" ` + - `size=${data.length} crc=0x${prevCrc.toString(16)}→0x${crc.toString(16)}`, + `size=${data.length} crc=0x${prevCrc.toString(16)}→0x${crc.toString(16)}`, ); // TODO: If includeTextures && db.className !== "PlayerData", @@ -172,7 +174,7 @@ export async function computeGameCRC( console.log( `[crc] RESULT: ${filesFound} files CRC'd, ${filesMissing} missing, ` + - `crc=0x${crc.toString(16)}, totalSize=${totalSize}, elapsed=${elapsed.toFixed(0)}ms`, + `crc=0x${crc.toString(16)}, totalSize=${totalSize}, elapsed=${elapsed.toFixed(0)}ms`, ); return { crc, totalSize }; diff --git a/relay/gameConnection.ts b/relay/gameConnection.ts index 3a5567a4..fec2e255 100644 --- a/relay/gameConnection.ts +++ b/relay/gameConnection.ts @@ -58,7 +58,10 @@ export class GameConnection extends EventEmitter { private nextSendEventSeq = 0; private pendingEvents: ClientEvent[] = []; /** Events sent but not yet acked, keyed by packet sequence number. */ - private sentEventsByPacket = new Map(); + private sentEventsByPacket = new Map< + number, + { seq: number; event: ClientEvent }[] + >(); /** Events waiting to be sent (new or retransmitted from lost packets). */ private eventSendQueue: { seq: number; event: ClientEvent }[] = []; private stringTable = new ClientNetStringTable(); @@ -178,7 +181,11 @@ export class GameConnection extends EventEmitter { this.rawMessageCount++; if (this.rawMessageCount <= 30 || this.rawMessageCount % 50 === 0) { connLog.debug( - { bytes: msg.length, firstByte: msg[0], rawTotal: this.rawMessageCount }, + { + bytes: msg.length, + firstByte: msg[0], + rawTotal: this.rawMessageCount, + }, "Raw UDP message received", ); } @@ -225,11 +232,16 @@ export class GameConnection extends EventEmitter { case 36: // ConnectAccept this.handleConnectAccept(msg); 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"; if (msg.length > 9) { 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). // Reason is Huffman-encoded via BitStream::writeString (no stringBuffer). const bs = new BitStream(data.subarray(9)); @@ -239,7 +251,10 @@ export class GameConnection extends EventEmitter { // 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.disconnect(); break; @@ -249,16 +264,24 @@ export class GameConnection extends EventEmitter { } } - /** 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 { + 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"; if (msg.length > 5) { - const chars: number[] = []; - for (let i = 5; i < msg.length && msg[i] !== 0; i++) { - chars.push(msg[i]); - } - if (chars.length > 0) { - reason = String.fromCharCode(...chars); + try { + const data = new Uint8Array(msg.buffer, msg.byteOffset, msg.byteLength); + const bs = new BitStream(data.subarray(5)); + const parsed = bs.readString(); + if (parsed) reason = parsed; + } catch { + // Fall back to default reason } } connLog.warn({ reason }, "ChallengeReject received"); @@ -269,18 +292,11 @@ export class GameConnection extends EventEmitter { /** Handle ConnectChallengeResponse. */ private handleChallengeResponse(msg: Buffer): void { if (msg.length < 14) { - connLog.error( - { bytes: msg.length }, - "ChallengeResponse too short", - ); + connLog.error({ bytes: msg.length }, "ChallengeResponse too short"); return; } - const dv = new DataView( - msg.buffer, - msg.byteOffset, - msg.byteLength, - ); + const dv = new DataView(msg.buffer, msg.byteOffset, msg.byteLength); const serverProtocolVersion = dv.getUint32(1, true); this.serverConnectSequence = dv.getUint32(5, true); const echoedClientSeq = dv.getUint32(9, true); @@ -354,14 +370,30 @@ export class GameConnection extends EventEmitter { } /** Handle ConnectReject. */ + /** Handle ConnectReject (type 34): U8(34) + U32(serverSeq) + U32(clientSeq) + HuffString(reason). */ private handleConnectReject(msg: Buffer): void { + if (msg.length < 9) return; + const dv = new DataView(msg.buffer, msg.byteOffset, msg.byteLength); + const serverSeq = dv.getUint32(1, true); + const clientSeq = dv.getUint32(5, true); + 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 > 1) { - const chars: number[] = []; - for (let i = 1; i < msg.length && msg[i] !== 0; i++) { - chars.push(msg[i]); + 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"); this.setStatus("disconnected", reason); @@ -385,7 +417,6 @@ export class GameConnection extends EventEmitter { // We still need to process the dnet header locally to track ack state this.processPacketForAcks(data); - } /** Process a packet's dnet header to maintain ack state. */ @@ -415,7 +446,10 @@ export class GameConnection extends EventEmitter { // The server's processRawPacket calls sendPingResponse on receiving a // PingPacket. Without this response, the server may time us out. 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(); this.sendRaw(pingResponse); } @@ -472,19 +506,15 @@ export class GameConnection extends EventEmitter { } /** Handle a parsed T2csri event from the browser. */ - handleAuthEvent( - eventName: string, - args: string[], - ): void { + handleAuthEvent(eventName: string, args: string[]): void { if (!this.auth) return; switch (eventName) { case "t2csri_pokeClient": { - connLog.info("Auth: received pokeClient, sending certificate + challenge"); - const result = this.auth.onPokeClient( - args[0] || "", - this.host, + connLog.info( + "Auth: received pokeClient, sending certificate + challenge", ); + const result = this.auth.onPokeClient(args[0] || "", this.host); for (const cmd of result.commands) { this.sendCommand(cmd.name, ...cmd.args); } @@ -512,10 +542,7 @@ export class GameConnection extends EventEmitter { this.authDelayTimer = setTimeout(() => { this.authDelayTimer = null; if (this._status !== "authenticating") return; - this.sendCommand( - result.command.name, - ...result.command.args, - ); + this.sendCommand(result.command.name, ...result.command.args); this.enforceObserver(); this.setStatus("connected"); }, delay); @@ -553,11 +580,20 @@ export class GameConnection extends EventEmitter { basePath: string, ): Promise { 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", ); try { - const { crc, totalSize } = await computeGameCRC(seed, datablocks, basePath, includeTextures); + const { crc, totalSize } = await computeGameCRC( + seed, + datablocks, + basePath, + includeTextures, + ); connLog.info( { crc: `0x${(crc >>> 0).toString(16)}`, totalSize }, "CRC computed, sending response", @@ -586,7 +622,10 @@ export class GameConnection extends EventEmitter { /** Send a commandToServer as a RemoteCommandEvent. */ 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); this.pendingEvents.push(...events); this.flushEvents(); @@ -608,9 +647,7 @@ export class GameConnection extends EventEmitter { * 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. */ - private sendDataPacketWithEvents( - move?: ClientMoveData, - ): void { + private sendDataPacketWithEvents(move?: ClientMoveData): void { const events = this.eventSendQueue.splice(0); if (events.length === 0) return; @@ -631,8 +668,12 @@ export class GameConnection extends EventEmitter { this.sentEventsByPacket.set(packetSeq, events); const moveData = move ?? { - x: 0, y: 0, z: 0, - yaw: 0, pitch: 0, roll: 0, + x: 0, + y: 0, + z: 0, + yaw: 0, + pitch: 0, + roll: 0, freeLook: false, trigger: [false, false, false, false, false, false], }; @@ -702,7 +743,14 @@ export class GameConnection extends EventEmitter { this.sendMoveCount++; if (this.sendMoveCount <= 5 || this.sendMoveCount % 100 === 0) { 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", ); } @@ -779,7 +827,8 @@ export class GameConnection extends EventEmitter { let keepaliveCount = 0; this.keepaliveTimer = setInterval(() => { keepaliveCount++; - if (keepaliveCount % 300 === 0) { // ~10s at 32ms tick rate + if (keepaliveCount % 300 === 0) { + // ~10s at 32ms tick rate connLog.info( { dataPackets: this.dataPacketCount, diff --git a/relay/masterQuery.ts b/relay/masterQuery.ts index f905f945..e22175fa 100644 --- a/relay/masterQuery.ts +++ b/relay/masterQuery.ts @@ -133,7 +133,12 @@ async function queryServers(addresses: string[]): Promise { if (info) { pingResults.set(addr, info); masterLog.debug( - { addr, name: info.name, build: info.buildVersion, ping: info.ping }, + { + addr, + name: info.name, + build: info.buildVersion, + ping: info.ping, + }, "Ping response", ); } @@ -246,10 +251,7 @@ async function queryServers(addresses: string[]): Promise { * U32 buildVersion (e.g. 25034) * HuffString serverName (24 chars max) */ -function parsePingResponse( - data: Buffer, - sendTime?: number, -): PingInfo | null { +function parsePingResponse(data: Buffer, sendTime?: number): PingInfo | null { if (data.length < 7 || data[0] !== 16) return null; try { const bs = new BitStream( @@ -301,7 +303,15 @@ function parseInfoResponse(data: Buffer): GameInfo | null { const playerCount = bs.readU8(); const maxPlayers = bs.readU8(); const botCount = bs.readU8(); - return { mod, gameType, mapName, status, playerCount, maxPlayers, botCount }; + return { + mod, + gameType, + mapName, + status, + playerCount, + maxPlayers, + botCount, + }; } catch { return null; } diff --git a/relay/protocol.ts b/relay/protocol.ts index 7e040c4f..9cec6948 100644 --- a/relay/protocol.ts +++ b/relay/protocol.ts @@ -29,9 +29,7 @@ export class ConnectionProtocol { private _sendCount = 0; - buildSendPacketHeader( - packetType: number = DataPacket, - ): BitStreamWriter { + buildSendPacketHeader(packetType: number = DataPacket): BitStreamWriter { const bs = new BitStreamWriter(1500); // gameFlag — always true for data connection packets @@ -42,8 +40,7 @@ export class ConnectionProtocol { // Increment send sequence this.lastSendSeq = (this.lastSendSeq + 1) >>> 0; - this.lastSeqRecvdAtSend[this.lastSendSeq & 0x1f] = - this.lastSeqRecvd >>> 0; + this.lastSeqRecvdAtSend[this.lastSendSeq & 0x1f] = this.lastSeqRecvd >>> 0; // seqNumber (9 bits) bs.writeInt(this.lastSendSeq & 0x1ff, 9); @@ -71,12 +68,13 @@ export class ConnectionProtocol { this._sendCount++; 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( `[proto] SEND #${this._sendCount} seq=${this.lastSendSeq} ` + - `highestAck=${this.lastSeqRecvd} type=${typeName} ` + - `ackBytes=${ackByteCount} mask=0x${mask.toString(16).padStart(8, "0")} ` + - `(${mask.toString(2).replace(/^0+/, "") || "0"})`, + `highestAck=${this.lastSeqRecvd} type=${typeName} ` + + `ackBytes=${ackByteCount} mask=0x${mask.toString(16).padStart(8, "0")} ` + + `(${mask.toString(2).replace(/^0+/, "") || "0"})`, ); } @@ -131,8 +129,7 @@ export class ConnectionProtocol { const isAcked = (header.ackMask & (1 << ((highestAck - ackSeq) & 0x1f))) !== 0; if (isAcked) { - this.lastRecvAckAck = - this.lastSeqRecvdAtSend[ackSeq & 0x1f] >>> 0; + this.lastRecvAckAck = this.lastSeqRecvdAtSend[ackSeq & 0x1f] >>> 0; } if (this.onNotify) { this.onNotify(ackSeq, isAcked); @@ -144,8 +141,7 @@ export class ConnectionProtocol { this.highestAckedSeq = highestAck; const dispatchData = - this.lastSeqRecvd !== seqNumber && - header.packetType === DataPacket; + this.lastSeqRecvd !== seqNumber && header.packetType === DataPacket; this.lastSeqRecvd = seqNumber; return { accepted: true, dispatchData }; @@ -168,9 +164,7 @@ export class ConnectionProtocol { * The caller provides a callback that writes game data to the stream * after the dnet header. */ - buildDataPacket( - writePayload: (bs: BitStreamWriter) => void, - ): Uint8Array { + buildDataPacket(writePayload: (bs: BitStreamWriter) => void): Uint8Array { const bs = this.buildSendPacketHeader(DataPacket); writePayload(bs); return bs.getBuffer(); @@ -343,10 +337,7 @@ export class ClientNetStringTable { } /** Build a NetStringEvent to register a string with the server. */ -export function buildNetStringEvent( - id: number, - value: string, -): ClientEvent { +export function buildNetStringEvent(id: number, value: string): ClientEvent { return { classId: NetStringEventClassId, write(bs: BitStreamWriter) { @@ -489,9 +480,7 @@ export function buildConnectRequest( } /** Build a Disconnect (type 38) OOB packet. */ -export function buildDisconnectPacket( - connectSequence: number, -): Uint8Array { +export function buildDisconnectPacket(connectSequence: number): Uint8Array { const bs = new BitStreamWriter(64); bs.writeU8(38); // Disconnect type bs.writeU32(connectSequence); diff --git a/relay/server.ts b/relay/server.ts index 99bf3de3..d47c67a5 100644 --- a/relay/server.ts +++ b/relay/server.ts @@ -19,8 +19,7 @@ const MANIFEST_PATH = path.resolve(GAME_BASE_PATH, "..", "..", "public", "manifest.json"); const RELAY_PORT = parseInt(process.env.RELAY_PORT || "8765", 10); -const MASTER_SERVER = - process.env.T2_MASTER_SERVER || "master.tribesnext.com"; +const MASTER_SERVER = process.env.T2_MASTER_SERVER || "master.tribesnext.com"; /** HTTP server for health checks; WebSocket upgrades are handled separately. */ 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); 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; } @@ -87,7 +88,11 @@ wss.on("connection", (ws) => { const RETRY_DELAY_MS = 6000; const RETRYABLE_REASONS = ["Server is cycling mission"]; - async function connectToServer(ws: WebSocket, address: string, warriorName?: string): Promise { + async function connectToServer( + ws: WebSocket, + address: string, + warriorName?: string, + ): Promise { if (gameConnection) { gameConnection.disconnect(); } @@ -95,9 +100,7 @@ wss.on("connection", (ws) => { gameConnection = new GameConnection(address, { warriorName }); // Set mapName from the cached server list if available. - const cachedServer = cachedServers.find( - (s) => s.address === address, - ); + const cachedServer = cachedServers.find((s) => s.address === address); if (cachedServer?.mapName) { gameConnection.setMapName(cachedServer.mapName); } @@ -123,7 +126,11 @@ wss.on("connection", (ws) => { ) { retryCount++; relayLog.info( - { attempt: retryCount, maxRetries: MAX_RETRIES, delay: RETRY_DELAY_MS }, + { + attempt: retryCount, + maxRetries: MAX_RETRIES, + delay: RETRY_DELAY_MS, + }, "Retryable disconnect — will reconnect", ); sendToClient(ws, { @@ -243,7 +250,10 @@ wss.on("connection", (ws) => { } 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) { relayLog.info("Disconnecting existing game connection"); gameConnection.disconnect(); @@ -285,10 +295,7 @@ wss.on("connection", (ws) => { { event: message.command }, "Forwarding auth event from browser", ); - gameConnection.handleAuthEvent( - message.command, - message.args, - ); + gameConnection.handleAuthEvent(message.command, message.args); } else { relayLog.debug( { command: message.command }, @@ -315,7 +322,10 @@ wss.on("connection", (ws) => { case "sendCRCCompute": { if (gameConnection) { relayLog.info( - { datablocks: message.datablocks.length, includeTextures: message.includeTextures }, + { + datablocks: message.datablocks.length, + includeTextures: message.includeTextures, + }, "Computing CRC from game files", ); gameConnection.computeAndSendCRC( diff --git a/relay/types.ts b/relay/types.ts index 4e368f45..605fe265 100644 --- a/relay/types.ts +++ b/relay/types.ts @@ -5,15 +5,32 @@ export type ClientMessage = | { type: "disconnect" } | { type: "sendMove"; move: ClientMove } | { 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: "wsPing"; ts: number }; /** Messages from relay server to browser client. */ export type ServerMessage = | { 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: "ping"; ms: number } | { type: "wsPong"; ts: number } diff --git a/scripts/check-mount-points.ts b/scripts/check-mount-points.ts index a73f26ac..74874d8f 100644 --- a/scripts/check-mount-points.ts +++ b/scripts/check-mount-points.ts @@ -215,7 +215,9 @@ for (const rel of weaponModels) { }); if (mpIdx === -1) { - console.log(`${name}: NO Mountpoint node. Nodes: [${nodeNames.join(", ")}]`); + console.log( + `${name}: NO Mountpoint node. Nodes: [${nodeNames.join(", ")}]`, + ); continue; } diff --git a/scripts/compute-mount-world.ts b/scripts/compute-mount-world.ts index 53719b2b..b00671dc 100644 --- a/scripts/compute-mount-world.ts +++ b/scripts/compute-mount-world.ts @@ -111,10 +111,10 @@ function fmt(v: number[]): string { async function main() { 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( - "docs/base/@vl2/shapes.vl2/shapes/weapon_disc.glb" + "docs/base/@vl2/shapes.vl2/shapes/weapon_disc.glb", ); const playerDoc = parseGlb(playerBuf); diff --git a/scripts/convert-wav.ts b/scripts/convert-wav.ts index f6c2a93f..6fba7bbf 100644 --- a/scripts/convert-wav.ts +++ b/scripts/convert-wav.ts @@ -26,7 +26,9 @@ async function run({ try { await fs.stat(oggFile); continue; // .ogg already exists, skip - } catch { /* expected */ } + } catch { + /* expected */ + } } inputFiles.push(wavFile); } diff --git a/scripts/inspect-glb-nodes.ts b/scripts/inspect-glb-nodes.ts index 40fb658b..0dbaca3f 100644 --- a/scripts/inspect-glb-nodes.ts +++ b/scripts/inspect-glb-nodes.ts @@ -72,7 +72,7 @@ function printNodeTree( doc: GltfDocument, nodeIndex: number, depth: number, - visited: Set + visited: Set, ): void { if (visited.has(nodeIndex)) return; visited.add(nodeIndex); @@ -101,12 +101,16 @@ function printNodeTree( // Only show scale if non-identity const s = node.scale; 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 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) { @@ -130,18 +134,20 @@ async function inspectGlb(filePath: string): Promise { const skinCount = doc.skins?.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) if (doc.skins && doc.skins.length > 0) { console.log(`\n--- Skins ---`); for (let i = 0; i < doc.skins.length; 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( - ` 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 { console.log(`\n--- Animations ---`); for (let i = 0; i < doc.animations.length; 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 { } // 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 }[] = []; if (doc.nodes) { for (let i = 0; i < doc.nodes.length; i++) { @@ -191,7 +210,9 @@ async function inspectGlb(filePath: string): Promise { } 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) { console.log(` [${index}] "${name}"`); console.log(` Translation: ${formatVec3(node.translation)}`); @@ -199,13 +220,17 @@ async function inspectGlb(filePath: string): Promise { console.log(` Rotation: ${formatQuat(node.rotation)}`); } 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 if (doc.nodes) { for (let j = 0; j < doc.nodes.length; j++) { 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; } } @@ -218,7 +243,9 @@ async function inspectGlb(filePath: string): Promise { if (doc.nodes) { for (let i = 0; i < doc.nodes.length; 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)}`, + ); } } } diff --git a/scripts/play-demo.ts b/scripts/play-demo.ts index 3ea012e9..86004374 100644 --- a/scripts/play-demo.ts +++ b/scripts/play-demo.ts @@ -38,7 +38,9 @@ if (!demoPath) { console.error(); console.error("Options:"); console.error(" --no-headless Show the browser window"); - console.error(" --wait, -w Seconds to wait after loading (default: 10)"); + console.error( + " --wait, -w Seconds to wait after loading (default: 10)", + ); console.error(" --screenshot, -s Take a screenshot after loading"); console.error(); console.error("Examples:"); diff --git a/scripts/t2-login.ts b/scripts/t2-login.ts index 05bb9ea1..7496f80a 100644 --- a/scripts/t2-login.ts +++ b/scripts/t2-login.ts @@ -185,7 +185,9 @@ async function downloadAccount( const trimmed = buffer.trim(); if (trimmed === "RECOVERERROR") { - reject(new Error("Auth server returned RECOVERERROR (malformed request)")); + reject( + new Error("Auth server returned RECOVERERROR (malformed request)"), + ); return; } if (trimmed === "NOTFOUND") { @@ -202,7 +204,11 @@ async function downloadAccount( // Line 2: EXP: const lines = trimmed.split("\n"); 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; } @@ -300,7 +306,9 @@ async function main() { const existingLines = await readEnvLines(envFilePath); 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_PASSWORD: password, diff --git a/scripts/t2-server-list.ts b/scripts/t2-server-list.ts index 0058aadf..a8d83489 100644 --- a/scripts/t2-server-list.ts +++ b/scripts/t2-server-list.ts @@ -9,8 +9,7 @@ import { queryServerList } from "../relay/masterQuery.js"; -const MASTER_SERVER = - process.env.T2_MASTER_SERVER || "master.tribesnext.com"; +const MASTER_SERVER = process.env.T2_MASTER_SERVER || "master.tribesnext.com"; async function main() { console.log(`Master server: ${MASTER_SERVER}`); @@ -27,10 +26,7 @@ async function main() { console.log(`Found ${servers.length} server(s):\n`); // Print as a table - const nameWidth = Math.max( - 11, - ...servers.map((s) => s.name.length), - ); + const nameWidth = Math.max(11, ...servers.map((s) => s.name.length)); const header = [ "Server Name".padEnd(nameWidth), "Map".padEnd(20), diff --git a/src/components/Accordion.module.css b/src/components/Accordion.module.css new file mode 100644 index 00000000..3685abf0 --- /dev/null +++ b/src/components/Accordion.module.css @@ -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; + } +} diff --git a/src/components/Accordion.tsx b/src/components/Accordion.tsx new file mode 100644 index 00000000..708793a3 --- /dev/null +++ b/src/components/Accordion.tsx @@ -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 ; +} + +export function Accordion({ + value, + label, + children, +}: { + value: string; + label: ReactNode; + children: ReactNode; +}) { + return ( + + + {label} + + +
{children}
+
+
+ ); +} diff --git a/src/components/AudioContext.tsx b/src/components/AudioContext.tsx index 77ac73e4..63e06329 100644 --- a/src/components/AudioContext.tsx +++ b/src/components/AudioContext.tsx @@ -7,7 +7,8 @@ import { } from "react"; import { useThree } from "@react-three/fiber"; import { AudioListener, AudioLoader } from "three"; -import { engineStore } from "../state"; +import { engineStore } from "../state/engineStore"; +import { useSettings } from "./SettingsProvider"; interface AudioContextType { audioLoader: AudioLoader | null; @@ -21,7 +22,8 @@ const AudioContext = createContext(undefined); * Must be rendered inside the Canvas component. */ export function AudioProvider({ children }: { children: ReactNode }) { - const { camera } = useThree(); + const camera = useThree((state) => state.camera); + const { audioVolume } = useSettings(); const [audioContext, setAudioContext] = useState({ audioLoader: null, audioListener: null, @@ -41,8 +43,6 @@ export function AudioProvider({ children }: { children: ReactNode }) { camera.add(listener); } - listener.setMasterVolume(0.8); - setAudioContext({ audioLoader, audioListener: listener, @@ -51,7 +51,7 @@ export function AudioProvider({ children }: { children: ReactNode }) { // Resume the AudioContext on user interaction to satisfy browser autoplay // policy. Without this, sounds won't play until the user clicks/taps. const resumeOnGesture = () => { - const ctx = listener?.context; + const ctx = listener.context; if (!ctx || ctx.state !== "suspended") return; ctx.resume().finally(() => { document.removeEventListener("click", resumeOnGesture); @@ -67,7 +67,7 @@ export function AudioProvider({ children }: { children: ReactNode }) { const unsubscribe = engineStore.subscribe( (state) => state.playback.status, (status) => { - const ctx = listener?.context; + const ctx = listener.context; if (!ctx) return; if (status === "paused") { ctx.suspend(); @@ -85,6 +85,10 @@ export function AudioProvider({ children }: { children: ReactNode }) { }; }, [camera]); + useEffect(() => { + audioContext.audioListener?.setMasterVolume(audioVolume); + }, [audioVolume, audioContext.audioListener]); + return ( {children} diff --git a/src/components/AudioEmitter.tsx b/src/components/AudioEmitter.tsx index e875b439..b0eba79f 100644 --- a/src/components/AudioEmitter.tsx +++ b/src/components/AudioEmitter.tsx @@ -8,11 +8,15 @@ import { PositionalAudio, Vector3, } from "three"; +import { createLogger } from "../logger"; import { audioToUrl } from "../loaders"; import { useAudio } from "./AudioContext"; import { useDebug, useSettings } from "./SettingsProvider"; 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. export const audioBufferCache = new Map(); @@ -50,8 +54,16 @@ export function getSoundGeneration(): number { export function stopAllTrackedSounds(): void { _soundGeneration++; for (const [sound] of _activeSounds) { - try { sound.stop(); } catch { /* already stopped */ } - try { sound.disconnect(); } catch { /* already disconnected */ } + try { + sound.stop(); + } catch { + /* already stopped */ + } + try { + sound.disconnect(); + } catch { + /* already disconnected */ + } } _activeSounds.clear(); } @@ -148,7 +160,11 @@ export function playOneShotSound( sound.play(); sound.source!.onended = () => { _activeSounds.delete(sound); - try { sound.disconnect(); } catch { /* already disconnected */ } + try { + sound.disconnect(); + } catch { + /* already disconnected */ + } parent.remove(sound); }; } else { @@ -160,7 +176,11 @@ export function playOneShotSound( sound.play(); sound.source!.onended = () => { _activeSounds.delete(sound); - try { sound.disconnect(); } catch { /* already disconnected */ } + try { + sound.disconnect(); + } catch { + /* already disconnected */ + } }; } } catch { @@ -185,7 +205,7 @@ export function getCachedAudioBuffer( }, undefined, (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({ entity, }: { - entity: { - audioFileName?: string; - audioVolume?: number; - audioMinDistance?: number; - audioMaxDistance?: number; - audioMinLoopGap?: number; - audioMaxLoopGap?: number; - audioIs3D?: boolean; - audioIsLooping?: boolean; - position?: [number, number, number]; - }; + entity: AudioEmitterEntity; }) { const { debugMode } = useDebug(); const fileName = entity.audioFileName ?? ""; @@ -217,7 +227,8 @@ export const AudioEmitter = memo(function AudioEmitter({ const isLooping = entity.audioIsLooping ?? true; 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 { audioEnabled } = useSettings(); @@ -268,8 +279,16 @@ export const AudioEmitter = memo(function AudioEmitter({ return () => { clearTimers(); - try { sound.stop(); } catch { /* already stopped */ } - try { sound.disconnect(); } catch { /* already disconnected */ } + try { + sound.stop(); + } catch { + /* already stopped */ + } + try { + sound.disconnect(); + } catch { + /* already disconnected */ + } if (is3D) scene.remove(sound); soundRef.current = null; isLoadedRef.current = false; @@ -306,7 +325,9 @@ export const AudioEmitter = memo(function AudioEmitter({ try { sound.play(); setupLooping(sound, gen); - } catch { /* expected */ } + } catch { + /* expected */ + } }, gap); } else { loopGapIntervalRef.current = setTimeout(checkLoop, 100); @@ -337,7 +358,9 @@ export const AudioEmitter = memo(function AudioEmitter({ try { sound.play(); setupLooping(sound, gen); - } catch { /* expected */ } + } catch { + /* expected */ + } } }); } else { @@ -346,7 +369,9 @@ export const AudioEmitter = memo(function AudioEmitter({ sound.play(); setupLooping(sound, gen); } - } catch { /* expected */ } + } catch { + /* expected */ + } } }; @@ -373,7 +398,11 @@ export const AudioEmitter = memo(function AudioEmitter({ } else if (!isNowInRange && wasInRange) { isInRangeRef.current = false; clearTimers(); - try { sound.stop(); } catch { /* expected */ } + try { + sound.stop(); + } catch { + /* expected */ + } } }); @@ -384,7 +413,11 @@ export const AudioEmitter = memo(function AudioEmitter({ if (!audioEnabled) { clearTimers(); - try { sound.stop(); } catch { /* expected */ } + try { + sound.stop(); + } catch { + /* expected */ + } isInRangeRef.current = false; } }, [audioEnabled]); diff --git a/src/components/AudioEnabled.tsx b/src/components/AudioEnabled.tsx new file mode 100644 index 00000000..fa516097 --- /dev/null +++ b/src/components/AudioEnabled.tsx @@ -0,0 +1,7 @@ +import { ReactNode, Suspense } from "react"; +import { useSettings } from "./SettingsProvider"; + +export function AudioEnabled({ children }: { children: ReactNode }) { + const { audioEnabled } = useSettings(); + return audioEnabled ? {children} : null; +} diff --git a/src/components/Camera.tsx b/src/components/Camera.tsx index 28e7d936..808ceb6e 100644 --- a/src/components/Camera.tsx +++ b/src/components/Camera.tsx @@ -9,17 +9,12 @@ export function Camera({ entity }: { entity: CameraEntity }) { const dataBlock = entity.cameraDataBlock; const position = useMemo( - () => - entity.position - ? new Vector3(...entity.position) - : new Vector3(), + () => (entity.position ? new Vector3(...entity.position) : new Vector3()), [entity.position], ); const rotation = useMemo( () => - entity.rotation - ? new Quaternion(...entity.rotation) - : new Quaternion(), + entity.rotation ? new Quaternion(...entity.rotation) : new Quaternion(), [entity.rotation], ); diff --git a/src/components/CamerasProvider.tsx b/src/components/CamerasProvider.tsx index b35c8fbc..0d4d17f4 100644 --- a/src/components/CamerasProvider.tsx +++ b/src/components/CamerasProvider.tsx @@ -35,7 +35,7 @@ export function useCameras() { } export function CamerasProvider({ children }: { children: ReactNode }) { - const { camera } = useThree(); + const camera = useThree((state) => state.camera); const [cameraIndex, setCameraIndex] = useState(-1); const [cameraMap, setCameraMap] = useState>({}); const [initialViewState, setInitialViewState] = useState(() => ({ @@ -53,7 +53,8 @@ export function CamerasProvider({ children }: { children: ReactNode }) { const unregisterCamera = useCallback((camera: CameraEntry) => { setCameraMap((prevCameraMap) => { - const { [camera.id]: _removedCamera, ...remainingCameras } = prevCameraMap; + const { [camera.id]: _removedCamera, ...remainingCameras } = + prevCameraMap; return remainingCameras; }); }, []); diff --git a/src/components/ChatInput.module.css b/src/components/ChatInput.module.css new file mode 100644 index 00000000..b08f00d7 --- /dev/null +++ b/src/components/ChatInput.module.css @@ -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); +} diff --git a/src/components/ChatInput.tsx b/src/components/ChatInput.tsx new file mode 100644 index 00000000..14307b1c --- /dev/null +++ b/src/components/ChatInput.tsx @@ -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 ( +
+ setChatText(e.target.value)} + onKeyDown={(e) => e.stopPropagation()} + onKeyUp={(e) => e.stopPropagation()} + maxLength={255} + /> +
+ ); +} diff --git a/src/components/ChatSoundPlayer.tsx b/src/components/ChatSoundPlayer.tsx index c9949853..0541a5b5 100644 --- a/src/components/ChatSoundPlayer.tsx +++ b/src/components/ChatSoundPlayer.tsx @@ -2,9 +2,14 @@ import { useEffect, useRef } from "react"; import { Audio } from "three"; import { audioToUrl } from "../loaders"; import { useAudio } from "./AudioContext"; -import { getCachedAudioBuffer, getSoundGeneration, trackSound, untrackSound } from "./AudioEmitter"; +import { + getCachedAudioBuffer, + getSoundGeneration, + trackSound, + untrackSound, +} from "./AudioEmitter"; import { useSettings } from "./SettingsProvider"; -import { engineStore, useEngineSelector } from "../state"; +import { engineStore, useEngineSelector } from "../state/engineStore"; import type { ChatMessage } from "../stream/types"; /** @@ -13,8 +18,7 @@ import type { ChatMessage } from "../stream/types"; */ export function ChatSoundPlayer() { const { audioLoader, audioListener } = useAudio(); - const settings = useSettings(); - const audioEnabled = settings?.audioEnabled ?? false; + const { audioEnabled } = useSettings(); const messages = useEngineSelector( (state) => state.playback.streamSnapshot?.chatMessages, ); @@ -24,9 +28,7 @@ export function ChatSoundPlayer() { const playedSetRef = useRef(new WeakSet()); // Track active voice chat sound per sender so a new voice bind from the // same player stops their previous one (matching Tribes 2 behavior). - const activeBySenderRef = useRef( - new Map>(), - ); + const activeBySenderRef = useRef(new Map>()); useEffect(() => { if ( @@ -58,9 +60,17 @@ export function ChatSoundPlayer() { if (sender) { const prev = activeBySender.get(sender); if (prev) { - try { prev.stop(); } catch { /* already stopped */ } + try { + prev.stop(); + } catch { + /* already stopped */ + } untrackSound(prev); - try { prev.disconnect(); } catch { /* already disconnected */ } + try { + prev.disconnect(); + } catch { + /* already disconnected */ + } activeBySender.delete(sender); } } @@ -75,7 +85,11 @@ export function ChatSoundPlayer() { // Clean up the source node once playback finishes. sound.source!.onended = () => { untrackSound(sound); - try { sound.disconnect(); } catch { /* already disconnected */ } + try { + sound.disconnect(); + } catch { + /* already disconnected */ + } if (sender && activeBySender.get(sender) === sound) { activeBySender.delete(sender); } diff --git a/src/components/ChatWindow.module.css b/src/components/ChatWindow.module.css new file mode 100644 index 00000000..ad55aaa2 --- /dev/null +++ b/src/components/ChatWindow.module.css @@ -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); +} diff --git a/src/components/ChatWindow.tsx b/src/components/ChatWindow.tsx new file mode 100644 index 00000000..5969d598 --- /dev/null +++ b/src/components/ChatWindow.tsx @@ -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 (c0–c9 GuiChatHudProfile). */ +const CHAT_COLOR_CLASSES: Record = { + 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(null); + + const lastMessageId = messages[messages.length - 1]?.id; + + useEffect(() => { + if (scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + }, [lastMessageId]); + + return ( +
+
+ {messages.map((msg: ChatMessage) => ( + + ))} +
+ {isLive && ( + + + + )} +
+ ); +}); diff --git a/src/components/CloudLayers.tsx b/src/components/CloudLayers.tsx index 0d0c29be..85de29be 100644 --- a/src/components/CloudLayers.tsx +++ b/src/components/CloudLayers.tsx @@ -471,16 +471,19 @@ export function CloudLayers({ scene }: CloudLayersProps) { const { data: detailMapList } = useDetailMapList(materialList); // 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 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], ); 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], ); @@ -536,7 +539,7 @@ export function CloudLayers({ scene }: CloudLayersProps) { {layers.map((layer, i) => { const url = textureToUrl(layer.texture); return ( - + ; missionName: string; missionType: string; + disabled?: boolean; }) { const { fogEnabled } = useSettings(); const [showCopied, setShowCopied] = useState(false); @@ -57,15 +59,16 @@ export function CopyCoordinatesButton({ ); } diff --git a/src/components/DebugElements.tsx b/src/components/DebugElements.tsx index c37c3846..55cb1a3e 100644 --- a/src/components/DebugElements.tsx +++ b/src/components/DebugElements.tsx @@ -1,11 +1,9 @@ import { Stats, Html } from "@react-three/drei"; -import { useDebug } from "./SettingsProvider"; import { useEffect, useRef } from "react"; import { AxesHelper } from "three"; import styles from "./DebugElements.module.css"; export function DebugElements() { - const { debugMode } = useDebug(); const axesRef = useRef(null); useEffect(() => { @@ -16,7 +14,7 @@ export function DebugElements() { axes.setColors("rgb(153, 255, 0)", "rgb(0, 153, 255)", "rgb(255, 153, 0)"); }); - return debugMode ? ( + return ( <> @@ -43,5 +41,5 @@ export function DebugElements() { - ) : null; + ); } diff --git a/src/components/DebugEnabled.tsx b/src/components/DebugEnabled.tsx new file mode 100644 index 00000000..f29bfabc --- /dev/null +++ b/src/components/DebugEnabled.tsx @@ -0,0 +1,8 @@ +import { ReactNode, Suspense } from "react"; +import { useDebug } from "./SettingsProvider"; + +export function DebugEnabled({ children }: { children: ReactNode }) { + const { debugMode } = useDebug(); + + return debugMode ? {children} : null; +} diff --git a/src/components/DebugSuspense.tsx b/src/components/DebugSuspense.tsx new file mode 100644 index 00000000..9e851498 --- /dev/null +++ b/src/components/DebugSuspense.tsx @@ -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 `` during debugging to track async loading. + */ +export function DebugSuspense({ + name, + fallback = null, + children, +}: { + name: string; + fallback?: ReactNode; + children: ReactNode; +}) { + return ( + {fallback} + } + > + + {children} + + ); +} + +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; +} diff --git a/src/components/DemoPlaybackControls.module.css b/src/components/DemoPlaybackControls.module.css index 9a3475a1..75e2b571 100644 --- a/src/components/DemoPlaybackControls.module.css +++ b/src/components/DemoPlaybackControls.module.css @@ -1,14 +1,8 @@ .Root { - position: fixed; - bottom: 0; - left: 0; - right: 0; display: flex; align-items: center; gap: 10px; padding: 8px 12px; - background: rgba(0, 0, 0, 0.7); - color: #fff; font-size: 13px; z-index: 2; } diff --git a/src/components/DemoPlaybackControls.tsx b/src/components/DemoPlaybackControls.tsx index ce9b153c..7e653e33 100644 --- a/src/components/DemoPlaybackControls.tsx +++ b/src/components/DemoPlaybackControls.tsx @@ -78,6 +78,7 @@ export function DemoPlaybackControls() { className={styles.PlayPause} onClick={isPlaying ? pause : play} aria-label={isPlaying ? "Pause" : "Play"} + autoFocus > {isPlaying ? "\u275A\u275A" : "\u25B6"} diff --git a/src/components/EntityRenderer.tsx b/src/components/EntityRenderer.tsx index d19c12a4..1ad0d348 100644 --- a/src/components/EntityRenderer.tsx +++ b/src/components/EntityRenderer.tsx @@ -1,20 +1,15 @@ -import { lazy, memo, Suspense, useRef } from "react"; +import { lazy, memo, useRef } from "react"; import { useFrame } from "@react-three/fiber"; import type { Group } from "three"; import type { GameEntity, ShapeEntity as ShapeEntityType, - ForceFieldBareEntity as ForceFieldBareEntityType, - PlayerEntity as PlayerEntityType, - ExplosionEntity as ExplosionEntityType, - TracerEntity as TracerEntityType, - SpriteEntity as SpriteEntityType, - AudioEmitterEntity as AudioEmitterEntityType, } from "../state/gameEntityTypes"; -import { streamPlaybackStore } from "../state/streamPlaybackStore"; -import { ShapeRenderer } from "./GenericShape"; +import { ShapeRenderer, ShapePlaceholder } from "./GenericShape"; import { ShapeInfoProvider } from "./ShapeInfoProvider"; import type { StaticShapeType } from "./ShapeInfoProvider"; +import { DebugSuspense } from "./DebugSuspense"; +import { ShapeErrorBoundary } from "./ShapeErrorBoundary"; import { FloatingLabel } from "./FloatingLabel"; import { useSettings } from "./SettingsProvider"; import { Camera } from "./Camera"; @@ -22,44 +17,51 @@ import { WayPoint } from "./WayPoint"; import { TerrainBlock } from "./TerrainBlock"; import { InteriorInstance } from "./InteriorInstance"; import { Sky } from "./Sky"; +import { AudioEnabled } from "./AudioEnabled"; import type { TorqueObject } from "../torqueScript"; -// Lazy-loaded heavy renderers -const PlayerModel = lazy(() => - import("./PlayerModel").then((mod) => ({ default: mod.PlayerModel })), -); +function createLazy( + name: K, + loader: () => Promise>>, +): React.ComponentType<{ entity: GameEntity }> { + const LazyComponent = lazy(() => + loader().then((mod) => { + const NamedComponent = mod[name]; + return { default: NamedComponent }; + }), + ); + const LazyComponentWithSuspense = ({ entity }: { entity: GameEntity }) => { + return ( + + + + ); + }; -const ExplosionShape = lazy(() => - import("./ShapeModel").then((mod) => ({ - default: mod.ExplosionShape, - })), -); + LazyComponentWithSuspense.displayName = `createLazy(${name})`; + return LazyComponentWithSuspense; +} -const TracerProjectile = lazy(() => - import("./Projectiles").then((mod) => ({ - default: mod.TracerProjectile, - })), +const PlayerModel = createLazy("PlayerModel", () => import("./PlayerModel")); +const ExplosionShape = createLazy( + "ExplosionShape", + () => import("./ShapeModel"), ); - -const SpriteProjectile = lazy(() => - import("./Projectiles").then((mod) => ({ - default: mod.SpriteProjectile, - })), +const TracerProjectile = createLazy( + "TracerProjectile", + () => import("./Projectiles"), ); - -const ForceFieldBareRenderer = lazy(() => - import("./ForceFieldBare").then((mod) => ({ - default: mod.ForceFieldBare, - })), +const SpriteProjectile = createLazy( + "SpriteProjectile", + () => import("./Projectiles"), ); - -const AudioEmitter = lazy(() => - import("./AudioEmitter").then((mod) => ({ default: mod.AudioEmitter })), -); - -const WaterBlock = lazy(() => - import("./WaterBlock").then((mod) => ({ default: mod.WaterBlock })), +const ForceFieldBare = createLazy( + "ForceFieldBare", + () => import("./ForceFieldBare"), ); +const AudioEmitter = createLazy("AudioEmitter", () => import("./AudioEmitter")); +const WaterBlock = createLazy("WaterBlock", () => import("./WaterBlock")); +const WeaponModel = createLazy("WeaponModel", () => import("./ShapeModel")); const TEAM_NAMES: Record = { 1: "Storm", @@ -81,17 +83,21 @@ export const EntityRenderer = memo(function EntityRenderer({ case "Shape": return ; case "ForceFieldBare": - return ; + return ; case "Player": - return ; + return ; case "Explosion": - return ; + return ; case "Tracer": - return ; + return ; case "Sprite": - return ; + return ; case "AudioEmitter": - return ; + return ( + + + + ); case "Camera": return ; case "WayPoint": @@ -101,25 +107,21 @@ export const EntityRenderer = memo(function EntityRenderer({ case "InteriorInstance": return ; case "Sky": - return ; + return ; case "Sun": // Sun lighting is handled by SceneLighting (rendered outside EntityScene) return null; case "WaterBlock": - return ( - - - - ); + return ; case "MissionArea": return null; case "None": return null; + default: + return null; } }); -// ── Shape Entity ── - function ShapeEntity({ entity }: { entity: ShapeEntityType }) { const { animationEnabled } = useSettings(); const groupRef = useRef(null); @@ -131,7 +133,9 @@ function ShapeEntity({ entity }: { entity: ShapeEntityType }) { 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 shapeType = (entity.shapeType ?? "StaticShape") as StaticShapeType; @@ -156,7 +160,10 @@ function ShapeEntity({ entity }: { entity: ShapeEntityType }) { type={shapeType} > - + {flagLabel ? ( {flagLabel} ) : null} @@ -172,92 +179,26 @@ function ShapeEntity({ entity }: { entity: ShapeEntityType }) { )} + {entity.weaponShape && ( + + } + > + + } + > + + + + )} ); } - -// ── Force Field Entity ── - -function ForceFieldBareEntity({ entity }: { entity: ForceFieldBareEntityType }) { - if (!entity.forceFieldData) return null; - return ( - - - - ); -} - -// ── Player Entity ── - -function PlayerEntity({ entity }: { entity: PlayerEntityType }) { - if (!entity.shapeName) return null; - - return ( - - - - ); -} - -// ── 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 ( - - - - ); -} - -// ── Tracer Entity ── - -function TracerEntity({ entity }: { entity: TracerEntityType }) { - return ( - - - - ); -} - -// ── Sprite Entity ── - -function SpriteEntity({ entity }: { entity: SpriteEntityType }) { - return ( - - - - ); -} - -// ── Audio Entity ── - -function AudioEntity({ entity }: { entity: AudioEmitterEntityType }) { - const { audioEnabled } = useSettings(); - if (!entity.audioFileName || !audioEnabled) return null; - - return ( - - - - ); -} diff --git a/src/components/EntityScene.tsx b/src/components/EntityScene.tsx index 16edefd8..5477c3e6 100644 --- a/src/components/EntityScene.tsx +++ b/src/components/EntityScene.tsx @@ -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 type { Group } from "three"; import { useFrame } from "@react-three/fiber"; -import { useAllGameEntities } from "../state"; -import type { GameEntity, PositionedEntity, PlayerEntity } from "../state/gameEntityTypes"; +import { useAllGameEntities } from "../state/gameEntityStore"; +import type { + GameEntity, + PositionedEntity, + PlayerEntity, +} from "../state/gameEntityTypes"; import { isSceneEntity } from "../state/gameEntityTypes"; import { streamPlaybackStore } from "../state/streamPlaybackStore"; import { EntityRenderer } from "./EntityRenderer"; +import { ShapeErrorBoundary } from "./ShapeErrorBoundary"; import { PlayerNameplate } from "./PlayerNameplate"; import { FlagMarker } from "./FlagMarker"; -import { FloatingLabel } from "./FloatingLabel"; import { entityTypeColor } from "../stream/playbackUtils"; -import { useDebug } from "./SettingsProvider"; -import { useEngineSelector } from "../state"; - - -const WeaponModel = lazy(() => - import("./ShapeModel").then((mod) => ({ - default: mod.WeaponModel, - })), -); +import { useEngineSelector } from "../state/engineStore"; /** * 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 * populate the store — this component doesn't know or care which is active. */ -export function EntityScene({ missionType }: { missionType?: string }) { - const debug = useDebug(); - const debugMode = debug?.debugMode ?? false; - +export function EntityScene() { const rootRef = useCallback((node: Group | null) => { streamPlaybackStore.setState({ root: node }); }, []); return ( - + ); } /** Renders all game entities. Uses an ID-stable selector so the component * only re-renders when entities are added or removed, not when their - * fields change. Entity references are cached so that once an entity - * renders and loads resources via Suspense, it keeps its reference stable. */ -const EntityLayer = memo(function EntityLayer({ - missionType, - debugMode, -}: { - missionType?: string; - debugMode: boolean; -}) { + * fields change. */ +const EntityLayer = memo(function EntityLayer() { const entities = useAllGameEntities(); // Cache entity references by ID so that in-place field mutations - // (threads, colors, weapon shape) don't cause React to see a new - // object and remount Suspense boundaries. The cache IS updated when - // the store provides a genuinely new object reference (identity - // rebuild: armor change, datablock change, etc.). + // (threads, colors, weapon shape) don't cause unnecessary remounts. + // The cache IS updated when the store provides a genuinely new object + // reference (identity rebuild: armor change, datablock change, etc.). const cacheRef = useRef(new Map()); 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 ( <> - {filtered.map((entity) => ( - + {[...cache.values()].map((entity) => ( + ))} ); @@ -105,13 +71,11 @@ const EntityLayer = memo(function EntityLayer({ const EntityWrapper = memo(function EntityWrapper({ entity, - debugMode, }: { entity: GameEntity; - debugMode: boolean; }) { - // Scene infrastructure handles its own positioning — render directly. - // The named group allows the interpolation loop to identify and skip them. + // Scene infrastructure handles its own positioning and Suspense — render + // directly. The named group allows the interpolation loop to skip them. if (isSceneEntity(entity)) { return ( @@ -123,14 +87,13 @@ const EntityWrapper = memo(function EntityWrapper({ if (entity.renderType === "None") return null; // From here, entity is a PositionedEntity - return ; + return ; }); /** 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 - * on every positioned entity (which was starving Suspense retries for - * shape GLB loading). */ + * on every positioned entity. */ function PlayerNameplateIfVisible({ entity }: { entity: PlayerEntity }) { const controlPlayerGhostId = useEngineSelector( (state) => state.playback.streamSnapshot?.controlPlayerGhostId, @@ -146,13 +109,19 @@ function PlayerNameplateIfVisible({ entity }: { entity: PlayerEntity }) { function FlagMarkerSlot({ entity }: { entity: GameEntity }) { const flagRef = useRef(false); 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; }); flagRef.current = isFlag; 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; if (nowFlag !== flagRef.current) { flagRef.current = nowFlag; @@ -161,19 +130,13 @@ function FlagMarkerSlot({ entity }: { entity: GameEntity }) { }); if (!isFlag) return null; - return ( - - - - ); + return ; } function PositionedEntityWrapper({ entity, - debugMode, }: { entity: PositionedEntity; - debugMode: boolean; }) { const position = entity.position; const scale = entity.scale; @@ -187,7 +150,12 @@ function PositionedEntityWrapper({ // Entities without a resolved shape get a wireframe placeholder. if (entity.renderType === "Shape" && !entity.shapeName) { return ( - + - {debugMode && } ); @@ -212,82 +179,22 @@ function PositionedEntityWrapper({ ); - const shapeName = "shapeName" in entity ? entity.shapeName : undefined; - const weaponShape = "weaponShape" in entity ? entity.weaponShape : undefined; - return ( - + - - - + {isPlayer && ( - - - + )} - {debugMode && !shapeName && entity.renderType !== "Shape" && ( - - )} - {weaponShape && shapeName && !isPlayer && ( - - - - - - - - )} ); } - -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 ", - ); - return {bits.join(" | ")}; -} - -/** 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; - } -} diff --git a/src/components/FlagMarker.tsx b/src/components/FlagMarker.tsx index c29a4e08..f2488ac8 100644 --- a/src/components/FlagMarker.tsx +++ b/src/components/FlagMarker.tsx @@ -3,6 +3,7 @@ import { useFrame, useThree } from "@react-three/fiber"; import { Html } from "@react-three/drei"; import { Group, Vector3 } from "three"; import { textureToUrl } from "../loaders"; + interface FlagEntity { id: string; iffColor?: { r: number; g: number; b: number }; @@ -10,7 +11,6 @@ interface FlagEntity { import styles from "./FlagMarker.module.css"; const FLAG_ICON_HEIGHT = 1.5; - const FLAG_ICON_URL = textureToUrl("commander/MiniIcons/com_flag_grey"); const _tmpVec = new Vector3(); @@ -24,7 +24,7 @@ export function FlagMarker({ entity }: { entity: FlagEntity }) { const markerRef = useRef(null); const iconRef = useRef(null); const distRef = useRef(null); - const { camera } = useThree(); + const camera = useThree((state) => state.camera); useFrame(() => { // Tint imperatively — iffColor is mutated in-place by streaming playback. @@ -52,10 +52,12 @@ export function FlagMarker({ entity }: { entity: FlagEntity }) {
diff --git a/src/components/FogProvider.tsx b/src/components/FogProvider.tsx index f1c42b83..fa003576 100644 --- a/src/components/FogProvider.tsx +++ b/src/components/FogProvider.tsx @@ -208,7 +208,14 @@ export function fogStateFromScene(sky: SceneSky): FogState { const enabled = visibleDistance > fogDistance; - return { fogDistance, visibleDistance, fogColor, fogVolumes, fogLine, enabled }; + return { + fogDistance, + visibleDistance, + fogColor, + fogVolumes, + fogLine, + enabled, + }; } /** diff --git a/src/components/FollowControls.tsx b/src/components/FollowControls.tsx new file mode 100644 index 00000000..e69de29b diff --git a/src/components/ForceFieldBare.tsx b/src/components/ForceFieldBare.tsx index 310041d5..4f78941b 100644 --- a/src/components/ForceFieldBare.tsx +++ b/src/components/ForceFieldBare.tsx @@ -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 { useFrame } from "@react-three/fiber"; import { @@ -10,7 +11,10 @@ import { RepeatWrapping, } 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 { useSettings } from "./SettingsProvider"; import { @@ -126,13 +130,10 @@ function ForceFieldMesh({ * Renders a ForceFieldBare from pre-resolved ForceFieldData. * Used by the unified EntityRenderer — does NOT read from TorqueObject/datablock. */ -export function ForceFieldBare({ - data, - scale, -}: { - data: ForceFieldData; - scale: [number, number, number]; -}) { +export function ForceFieldBare({ entity }: { entity: ForceFieldBareEntity }) { + const data = entity.forceFieldData; + const scale = data.dimensions; + const textureUrls = useMemo( () => data.textures.map((t) => textureToUrl(t)), [data.textures], @@ -149,7 +150,8 @@ export function ForceFieldBare({ } return ( - - + ); } diff --git a/src/components/DialogButton.module.css b/src/components/GameDialog.module.css similarity index 67% rename from src/components/DialogButton.module.css rename to src/components/GameDialog.module.css index 198e4899..d4a5a893 100644 --- a/src/components/DialogButton.module.css +++ b/src/components/GameDialog.module.css @@ -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 { background: linear-gradient( to bottom, diff --git a/src/components/GenericShape.tsx b/src/components/GenericShape.tsx index 07bd949b..0b84f178 100644 --- a/src/components/GenericShape.tsx +++ b/src/components/GenericShape.tsx @@ -2,6 +2,7 @@ import { memo, Suspense, useEffect, useMemo, useRef } from "react"; import { ErrorBoundary } from "react-error-boundary"; import { useGLTF, useTexture } from "@react-three/drei"; import { useFrame } from "@react-three/fiber"; +import { createLogger } from "../logger"; import { FALLBACK_TEXTURE_URL, textureToUrl, shapeToUrl } from "../loaders"; import { MeshStandardMaterial, @@ -21,7 +22,7 @@ import * as SkeletonUtils from "three/examples/jsm/utils/SkeletonUtils.js"; import { setupTexture } from "../textureUtils"; import { useDebug, useSettings } from "./SettingsProvider"; import { useShapeInfo, isOrganicShape } from "./ShapeInfoProvider"; -import { useEngineSelector, effectNow, engineStore } from "../state"; +import { useEngineSelector, effectNow, engineStore } from "../state/engineStore"; import { FloatingLabel } from "./FloatingLabel"; import { useIflTexture, @@ -39,6 +40,8 @@ import { } from "../stream/playbackUtils"; import type { ThreadState as StreamThreadState } from "../stream/types"; +const log = createLogger("GenericShape"); + /** Returns pausable time in seconds for demo mode, real time otherwise. */ function shapeNowSec(): number { const { recording } = engineStore.getState().playback; @@ -271,9 +274,7 @@ const StaticTexture = memo(function StaticTexture({ const url = useMemo(() => { if (!resourcePath) { - console.warn( - `No resource_path was found on "${shapeName}" - rendering fallback.`, - ); + log.warn("No resource_path found on \"%s\" — rendering fallback", shapeName); } return resourcePath ? textureToUrl(resourcePath) : FALLBACK_TEXTURE_URL; }, [resourcePath, shapeName]); @@ -662,10 +663,7 @@ export const ShapeModel = memo(function ShapeModel({ iflMeshAtlasRef.current.set(info.mesh, atlas); }) .catch((err) => { - console.warn( - `[ShapeModel] Failed to load IFL atlas for ${info.iflPath}:`, - err, - ); + log.warn("Failed to load IFL atlas for %s: %o", info.iflPath, err); }); } }, [iflMeshes]); diff --git a/src/components/InputHandlers.tsx b/src/components/InputHandlers.tsx new file mode 100644 index 00000000..65841b58 --- /dev/null +++ b/src/components/InputHandlers.tsx @@ -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 ( + + {children} + + ); +} + +export function InputHandlers() { + const isTouch = useTouchDevice(); + + return ( + <> + + {isTouch ? ( + + + + ) : null} + + ); +} diff --git a/src/components/InspectorControls.module.css b/src/components/InspectorControls.module.css index 02e04494..7ba8953e 100644 --- a/src/components/InspectorControls.module.css +++ b/src/components/InspectorControls.module.css @@ -1,57 +1,170 @@ -.Controls { - position: fixed; - top: 0; - left: 0; - background: rgba(0, 0, 0, 0.5); - color: #fff; - padding: 8px 12px 8px 8px; - border-radius: 0 0 4px 0; +.InspectorControls { + position: relative; font-size: 13px; + line-height: 1.231; z-index: 2; - display: flex; - align-items: center; - justify-content: center; - gap: 20px; } + .Dropdown { display: flex; - align-items: center; + flex-direction: column; + align-items: stretch; 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 { display: flex; + flex-wrap: wrap; align-items: center; justify-content: center; gap: 20px; } -.CheckboxField, + .LabelledButton { display: flex; align-items: center; 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; align-items: center; 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 { + flex: 1 1 auto; position: relative; display: flex; align-items: center; justify-content: center; + width: auto; + height: auto; min-width: 28px; - height: 28px; - margin: 0 0 0 -12px; - font-size: 15px; - padding: 0; + min-height: 32px; + gap: 8px; + margin: 0; + font-family: inherit; + font-size: 18px; + font-weight: 500; + padding: 4px 8px; border-top: 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-bottom: 1px solid rgba(200, 200, 200, 0.3); box-shadow: 0 1px 2px rgba(0, 0, 0, 0.4); - border-radius: 4px; + border-radius: 5px; background: rgba(3, 82, 147, 0.6); color: #fff; cursor: pointer; @@ -60,92 +173,69 @@ background 0.2s, border-color 0.2s; } + .IconButton svg { + flex: 0 0 auto; pointer-events: none; + opacity: 0.6; + transition: opacity 0.2s; } + +.IconButton:disabled { + opacity: 0.6; + cursor: default; +} + @media (hover: hover) { - .IconButton:hover { + .IconButton:not(:disabled):hover { background: rgba(0, 98, 179, 0.8); 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"] { background: rgba(0, 98, 179, 0.7); border-color: rgba(255, 255, 255, 0.3); transform: translate(0, 1px); } + .IconButton[data-active="true"] { background: rgba(0, 117, 213, 0.9); border-color: rgba(255, 255, 255, 0.4); } + .ButtonLabel { - font-size: 12px; + font-size: 14px; } + +.ButtonHint { + font-size: 10px; + opacity: 0.7; +} + .Toggle { composes: IconButton; margin: 0; } + .MapInfoButton { composes: IconButton; composes: LabelledButton; } -.MissionSelectWrapper { -} -@media (max-width: 1279px) { - .Dropdown[data-open="false"] { - display: none; - } - .Dropdown { - position: absolute; - top: calc(100% + 2px); - left: 2px; - right: 2px; - 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; - } + +.ForceRenderButton { + display: grid; + place-content: center; + border: 0; + margin: 0; + padding: 0 2px; + font-size: 16px; + background: transparent; + color: #4cb5ff; + cursor: pointer; } diff --git a/src/components/InspectorControls.tsx b/src/components/InspectorControls.tsx index 9c8b68ae..c53d7a3d 100644 --- a/src/components/InspectorControls.tsx +++ b/src/components/InspectorControls.tsx @@ -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 { useControls, useDebug, useSettings, type TouchMode, } from "./SettingsProvider"; -import { MissionSelect } from "./MissionSelect"; -import { useEffect, useState, useRef, RefObject } from "react"; import { CopyCoordinatesButton } from "./CopyCoordinatesButton"; import { LoadDemoButton } from "./LoadDemoButton"; import { JoinServerButton } from "./JoinServerButton"; -import { useRecording } from "./RecordingProvider"; -import { useLiveSelector } from "../state/liveConnectionStore"; -import { FiInfo, FiSettings } from "react-icons/fi"; -import { Camera } from "three"; +import { Accordion, AccordionGroup } from "./Accordion"; 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({ missionName, missionType, - onChangeMission, onOpenMapInfo, onOpenServerBrowser, - isTouch, + onChooseMap, + onCancelChoosingMap, + choosingMap, cameraRef, + invalidateRef, }: { missionName: string; missionType: string; - onChangeMission: ({ - missionName, - missionType, - }: { - missionName: string; - missionType: string; - }) => void; onOpenMapInfo: () => void; onOpenServerBrowser?: () => void; - isTouch: boolean | null; + onChooseMap?: () => void; + onCancelChoosingMap?: () => void; + choosingMap?: boolean; cameraRef: RefObject; + 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 { fogEnabled, setFogEnabled, @@ -44,18 +64,25 @@ export function InspectorControls({ setFov, audioEnabled, setAudioEnabled, + audioVolume, + setAudioVolume, animationEnabled, setAnimationEnabled, } = useSettings(); - const { speedMultiplier, setSpeedMultiplier, touchMode, setTouchMode } = - useControls(); - const { debugMode, setDebugMode } = useDebug(); - const demoRecording = useRecording(); - const isLive = useLiveSelector((s) => s.adapter != null); - const isStreaming = demoRecording != null || isLive; - // Hide FOV/speed controls during .rec playback (faithfully replaying), - // but show them in .mis browsing and live observer mode. - const hideViewControls = isStreaming && !isLive; + const { + speedMultiplier, + setSpeedMultiplier, + touchMode, + setTouchMode, + invertScroll, + setInvertScroll, + invertDrag, + setInvertDrag, + invertJoystick, + setInvertJoystick, + } = useControls(); + const { debugMode, setDebugMode, renderOnDemand, setRenderOnDemand } = + useDebug(); const [settingsOpen, setSettingsOpen] = useState(false); const dropdownRef = useRef(null); const buttonRef = useRef(null); @@ -81,34 +108,8 @@ export function InspectorControls({ } }; return ( -
e.stopPropagation()} - onPointerDown={(e) => e.stopPropagation()} - onClick={(e) => e.stopPropagation()} - > -
- -
+
-
-
+
+
+ + + {onOpenServerBrowser && ( + + )} +
- - {onOpenServerBrowser && ( - - )}
-
-
- { - setFogEnabled(event.target.checked); - }} - /> - -
-
- { - setAudioEnabled(event.target.checked); - }} - /> - -
+
+ + +
+ + + setSpeedMultiplier(parseFloat(event.target.value)) + } + /> +

+ How fast you move in free-flying mode. + {isTouch === false + ? " Use your scroll wheel or trackpad to adjust while flying." + : ""} +

+
+ {isTouch ? ( +
+ {" "} + +

+ Single stick has a unified move + look control. Dual stick + has independent move + look. +

+
+ ) : null} + {isTouch === false ? ( +
+ { + setInvertScroll(event.target.checked); + }} + /> + +

+ Reverse which scroll direction increases and decreases fly + speed. +

+
+ ) : null} + {isTouch ? ( +
+ { + setInvertJoystick(event.target.checked); + }} + /> + +

+ Reverse joystick look direction. +

+
+ ) : null} +
+ { + setInvertDrag(event.target.checked); + }} + /> + +

+ Reverse how dragging the viewport aims the camera. +

+
+
+ +
+ +
+ {fov}° + setFov(parseInt(event.target.value))} + /> +
+
+
+ +
+ { + setAudioEnabled(event.target.checked); + }} + /> + +
+
+ +
+ + {Math.round(audioVolume * 100)}% + + + setAudioVolume(parseFloat(event.target.value)) + } + /> +
+
+
+ +
+ { + setFogEnabled(event.target.checked); + }} + /> + +
+
+ { + setAnimationEnabled(event.target.checked); + }} + /> + +
+
+ +
+ { + setDebugMode(event.target.checked); + }} + /> + +
+
+ { + setRenderOnDemand(event.target.checked); + }} + /> +
+ + +
+

+ Significantly decreases CPU and GPU usage by only rendering + frames when requested. Helpful when developing parts of the + app unrelated to rendering. +

+
+
+
-
-
- { - setAnimationEnabled(event.target.checked); - }} - /> - -
-
- { - setDebugMode(event.target.checked); - }} - /> - -
-
-
- {hideViewControls ? null : ( -
- - setFov(parseInt(event.target.value))} - /> - {fov} -
- )} - {hideViewControls ? null : ( -
- - - setSpeedMultiplier(parseFloat(event.target.value)) - } - /> -
- )} -
- {isTouch && ( -
-
- {" "} - -
-
- )}
diff --git a/src/components/InteriorInstance.tsx b/src/components/InteriorInstance.tsx index c27032a4..7125a809 100644 --- a/src/components/InteriorInstance.tsx +++ b/src/components/InteriorInstance.tsx @@ -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 { createLogger } from "../logger"; import { Mesh, Material, @@ -24,6 +26,8 @@ import { injectCustomFog } from "../fogShader"; import { globalFogUniforms } from "../globalFogUniforms"; import { injectInteriorLighting } from "../interiorMaterial"; +const log = createLogger("InteriorInstance"); + /** * Load a .gltf file that was converted from a .dif, used for "interior" models. */ @@ -151,7 +155,8 @@ function InteriorMesh({ node }: { node: Mesh }) { return ( {node.material ? ( - )} - + ) : null} ); @@ -253,18 +258,18 @@ export const InteriorInstance = memo(function InteriorInstance({ /> } onError={(error) => { - console.warn( - `[interior] Failed to load ${scene.interiorFile}:`, - error.message, - ); + log.error("Failed to load %s: %s", scene.interiorFile, error.message); }} > - }> + } + > - + ); diff --git a/src/components/JoinServerButton.module.css b/src/components/JoinServerButton.module.css index d6a851ea..8be4fa6f 100644 --- a/src/components/JoinServerButton.module.css +++ b/src/components/JoinServerButton.module.css @@ -1,7 +1,6 @@ .Root { composes: IconButton from "./InspectorControls.module.css"; composes: LabelledButton from "./InspectorControls.module.css"; - padding: 0 5px; } /* Text label ("Connect", "Connecting...") follows standard breakpoint rules. */ .TextLabel { @@ -14,7 +13,7 @@ margin-right: 2px; } .LiveIcon { - font-size: 15px; + /* font-size: 15px; */ } .Pulsing { animation: blink 1.2s ease-out infinite; @@ -27,3 +26,7 @@ opacity: 0.25; } } + +.ButtonHint { + composes: ButtonHint from "./InspectorControls.module.css"; +} diff --git a/src/components/JoinServerButton.tsx b/src/components/JoinServerButton.tsx index debea400..eb479333 100644 --- a/src/components/JoinServerButton.tsx +++ b/src/components/JoinServerButton.tsx @@ -3,12 +3,14 @@ import { useLiveSelector, selectPing } from "../state/liveConnectionStore"; import styles from "./JoinServerButton.module.css"; function formatPing(ms: number): string { - return ms >= 1000 ? ms.toLocaleString() + "ms" : ms + "ms"; + return `${ms.toLocaleString()} ms`; } export function JoinServerButton({ + isActive, onOpenServerBrowser, }: { + isActive: boolean; onOpenServerBrowser: () => void; }) { const gameStatus = useLiveSelector((s) => s.gameStatus); @@ -26,8 +28,8 @@ export function JoinServerButton({ ); } diff --git a/src/components/JoystickContext.tsx b/src/components/JoystickContext.tsx new file mode 100644 index 00000000..514f4d33 --- /dev/null +++ b/src/components/JoystickContext.tsx @@ -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; + lookState: RefObject; + setMoveState: (state: Partial) => void; + setLookState: (state: Partial) => void; +}; + +export const JoystickContext = createContext(null); + +export function useJoystick() { + const context = useContext(JoystickContext); + if (!context) { + throw new Error( + "No JoystickContext found. Did you forget to add a ?", + ); + } + return context; +} + +export function JoystickProvider({ children }: { children: ReactNode }) { + const moveState = useRef({ angle: 0, force: 0 }); + const lookState = useRef({ angle: 0, force: 0 }); + + const setMoveState = useCallback( + ({ angle, force }: Partial) => { + if (angle != null) { + moveState.current.angle = angle; + } + if (force != null) { + moveState.current.force = force; + } + }, + [], + ); + + const setLookState = useCallback( + ({ angle, force }: Partial) => { + 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 {children}; +} diff --git a/src/components/ObserverControls.tsx b/src/components/KeyboardAndMouseHandler.tsx similarity index 92% rename from src/components/ObserverControls.tsx rename to src/components/KeyboardAndMouseHandler.tsx index d9d1fcf7..6db3546c 100644 --- a/src/components/ObserverControls.tsx +++ b/src/components/KeyboardAndMouseHandler.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef } from "react"; +import { useEffect, useEffectEvent, useRef } from "react"; import { Euler, Vector3 } from "three"; import { useFrame, useThree } from "@react-three/fiber"; import { useKeyboardControls } from "@react-three/drei"; @@ -29,6 +29,28 @@ export enum Controls { 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 MIN_SPEED_ADJUSTMENT = 0.05; 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 ARROW_LOOK_SPEED = 1; // radians/sec -function CameraMovement() { - const { speedMultiplier, setSpeedMultiplier } = useControls(); +export function KeyboardAndMouseHandler() { + // 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(); - const { camera, gl } = useThree(); + const camera = useThree((state) => state.camera); + const gl = useThree((state) => state.gl); const { nextCamera, setCameraIndex, cameraCount } = useCameras(); const controlsRef = useRef(null); + const getInvertScroll = useEffectEvent(() => invertScroll); + const getInvertDrag = useEffectEvent(() => invertDrag); + // Scratch vectors/euler to avoid allocations each frame const forwardVec = useRef(new Vector3()); const sideVec = useRef(new Vector3()); @@ -92,9 +140,10 @@ function CameraMovement() { } didDrag = true; + const dragSign = getInvertDrag() ? -1 : 1; euler.setFromQuaternion(camera.quaternion, "YXZ"); - euler.y -= e.movementX * MOUSE_SENSITIVITY; - euler.x -= e.movementY * MOUSE_SENSITIVITY; + euler.y += dragSign * e.movementX * MOUSE_SENSITIVITY; + euler.x += dragSign * e.movementY * MOUSE_SENSITIVITY; euler.x = Math.max(-MAX_PITCH, Math.min(MAX_PITCH, euler.x)); camera.quaternion.setFromEuler(euler); }; @@ -154,7 +203,8 @@ function CameraMovement() { const handleWheel = (e: WheelEvent) => { e.preventDefault(); - const direction = e.deltaY > 0 ? -1 : 1; + const scrollSign = getInvertScroll() ? -1 : 1; + const direction = (e.deltaY > 0 ? -1 : 1) * scrollSign; const delta = // Helps normalize sensitivity; trackpad scrolling will have many small @@ -253,50 +303,3 @@ function CameraMovement() { 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 ; -} diff --git a/src/components/KeyboardOverlay.module.css b/src/components/KeyboardOverlay.module.css index 048c5195..e18156b2 100644 --- a/src/components/KeyboardOverlay.module.css +++ b/src/components/KeyboardOverlay.module.css @@ -1,5 +1,5 @@ .Root { - position: fixed; + position: absolute; bottom: 16px; left: 50%; transform: translateX(-50%); diff --git a/src/components/KeyboardOverlay.tsx b/src/components/KeyboardOverlay.tsx index 2bdf2f48..913da458 100644 --- a/src/components/KeyboardOverlay.tsx +++ b/src/components/KeyboardOverlay.tsx @@ -1,5 +1,5 @@ import { useKeyboardControls } from "@react-three/drei"; -import { Controls } from "./ObserverControls"; +import { Controls } from "./KeyboardAndMouseHandler"; import { useRecording } from "./RecordingProvider"; import styles from "./KeyboardOverlay.module.css"; diff --git a/src/components/LiveConnection.tsx b/src/components/LiveConnection.tsx deleted file mode 100644 index 449a36d1..00000000 --- a/src/components/LiveConnection.tsx +++ /dev/null @@ -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; -} diff --git a/src/components/LiveObserver.tsx b/src/components/LiveObserver.tsx index d8c1cd0c..efd0093d 100644 --- a/src/components/LiveObserver.tsx +++ b/src/components/LiveObserver.tsx @@ -2,19 +2,26 @@ import { useRef, useEffect } from "react"; import { useFrame, useThree } from "@react-three/fiber"; import { Vector3 } from "three"; 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 { 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 { useTick, TICK_RATE } from "./TickProvider"; -import { - yawPitchToQuaternion, - MAX_PITCH, -} from "../stream/streamHelpers"; +import { yawPitchToQuaternion, MAX_PITCH } from "../stream/streamHelpers"; import type { StreamRecording, StreamCamera } from "../stream/types"; import type { LiveStreamAdapter } from "../stream/liveStreaming"; +const log = createLogger("LiveObserver"); + const TICK_INTERVAL = 1 / TICK_RATE; // Scratch objects to avoid per-frame allocations. @@ -50,7 +57,7 @@ export function LiveObserver() { const store = useEngineStoreApi(); const { speedMultiplier } = useControls(); const activeAdapterRef = useRef(null); - const { gl } = useThree(); + const gl = useThree((state) => state.gl); const [, getKeys] = useKeyboardControls(); // Accumulated rotation deltas since last move was sent. Mouse events and @@ -73,15 +80,22 @@ export function LiveObserver() { // Wire adapter to engine store. useEffect(() => { - if (adapter && (gameStatus === "connected" || gameStatus === "authenticating")) { + if ( + adapter && + (gameStatus === "connected" || gameStatus === "authenticating") + ) { 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 = { source: "live", duration: Infinity, - missionName: null, + missionName: liveState.mapName ?? null, gameType: null, + serverDisplayName: liveState.serverName ?? null, + recorderName: liveState.warriorName ?? null, + recordingDate: null, streamingPlayback: adapter, }; @@ -92,7 +106,11 @@ export function LiveObserver() { predRef.current.initialized = false; predRef.current.lastSyncedCamera = null; } else if (!adapter && activeAdapterRef.current) { - store.getState().setRecording(null); + // Only clear the recording if it's still the live one we set. + const current = store.getState().playback.recording; + if (current?.source === "live") { + store.getState().setRecording(null); + } activeAdapterRef.current = null; predRef.current.initialized = false; } @@ -165,7 +183,7 @@ export function LiveObserver() { if (!activeAdapterRef.current) return; activeAdapterRef.current.toggleObserverMode(); - console.log(`[LiveObserver] observer mode: ${activeAdapterRef.current.observerMode}`); + log.info("observer mode: %s", activeAdapterRef.current.observerMode); }; window.addEventListener("keydown", handleKey); return () => window.removeEventListener("keydown", handleKey); @@ -274,12 +292,15 @@ export function LiveObserver() { // Interpolate between previous and current tick prediction, then add // pending (unconsumed) mouse/arrow deltas so rotation responds at frame // rate rather than waiting for the next useTick to consume them. - const interpYaw = pred.prevYaw + (pred.yaw - pred.prevYaw) * t + deltaYawRef.current; + const interpYaw = + pred.prevYaw + (pred.yaw - pred.prevYaw) * t + deltaYawRef.current; const interpPitch = Math.max( -MAX_PITCH, Math.min( MAX_PITCH, - pred.prevPitch + (pred.pitch - pred.prevPitch) * t + deltaPitchRef.current, + pred.prevPitch + + (pred.pitch - pred.prevPitch) * t + + deltaPitchRef.current, ), ); @@ -314,7 +335,9 @@ export function LiveObserver() { if (_orbitDir.lengthSq() > 1e-8) { _orbitDir.normalize(); const orbitDistance = Math.max(0.1, serverCam.orbitDistance ?? 4); - state.camera.position.copy(_orbitTarget).addScaledVector(_orbitDir, orbitDistance); + state.camera.position + .copy(_orbitTarget) + .addScaledVector(_orbitDir, orbitDistance); state.camera.lookAt(_orbitTarget); } } @@ -323,13 +346,16 @@ export function LiveObserver() { // from StreamingController's server snapshot interpolation). state.camera.quaternion.set(qx, qy, qz, qw); } - }, 1); + }); // Clean up on unmount. useEffect(() => { return () => { if (activeAdapterRef.current) { - store.getState().setRecording(null); + const current = store.getState().playback.recording; + if (current?.source === "live") { + store.getState().setRecording(null); + } activeAdapterRef.current = null; } }; diff --git a/src/components/LoadDemoButton.module.css b/src/components/LoadDemoButton.module.css index f3b524dc..e50829b4 100644 --- a/src/components/LoadDemoButton.module.css +++ b/src/components/LoadDemoButton.module.css @@ -7,6 +7,10 @@ composes: ButtonLabel from "./InspectorControls.module.css"; } -.DemoIcon { - font-size: 19px; +.ButtonHint { + composes: ButtonHint from "./InspectorControls.module.css"; +} + +.DemoIcon { + /* font-size: 20px; */ } diff --git a/src/components/LoadDemoButton.tsx b/src/components/LoadDemoButton.tsx index cb50a2b5..6317afc8 100644 --- a/src/components/LoadDemoButton.tsx +++ b/src/components/LoadDemoButton.tsx @@ -1,10 +1,21 @@ import { useCallback, useRef } from "react"; import { MdOndemandVideo } from "react-icons/md"; +import { createLogger } from "../logger"; +import { liveConnectionStore } from "../state/liveConnectionStore"; import { usePlaybackActions, useRecording } from "./RecordingProvider"; -import { createDemoStreamingRecording } from "../stream/demoStreaming"; 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 isDemoLoaded = recording?.source === "demo"; const { setRecording } = usePlaybackActions(); @@ -12,14 +23,18 @@ export function LoadDemoButton() { const parseTokenRef = useRef(0); const handleClick = useCallback(() => { + if (choosingMap && isDemoLoaded) { + onCancelChoosingMap?.(); + return; + } if (isDemoLoaded) { - // Unload the current recording. + // Unload the recording/parser but leave entities frozen in the store. parseTokenRef.current += 1; setRecording(null); return; } inputRef.current?.click(); - }, [isDemoLoaded, setRecording]); + }, [isDemoLoaded, choosingMap, onCancelChoosingMap, setRecording]); const handleFileChange = useCallback( async (e: React.ChangeEvent) => { @@ -31,14 +46,20 @@ export function LoadDemoButton() { const buffer = await file.arrayBuffer(); const parseToken = parseTokenRef.current + 1; parseTokenRef.current = parseToken; + const { createDemoStreamingRecording } = + await import("../stream/demoStreaming"); const recording = await createDemoStreamingRecording(buffer); if (parseTokenRef.current !== parseToken) { 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. setRecording(recording); } catch (err) { - console.error("Failed to load demo:", err); + log.error("Failed to load demo: %o", err); } }, [setRecording], @@ -59,12 +80,16 @@ export function LoadDemoButton() { aria-label={isDemoLoaded ? "Unload demo" : "Load demo (.rec)"} title={isDemoLoaded ? "Unload demo" : "Load demo (.rec)"} onClick={handleClick} - data-active={isDemoLoaded ? "true" : undefined} - disabled={recording != null && !isDemoLoaded} + data-active={isActive} > - - {isDemoLoaded ? "Unload demo" : "Demo"} + Demo + + {choosingMap && isDemoLoaded + ? "Return to demo" + : isDemoLoaded + ? "Click to unload" + : "Load a .rec file"} diff --git a/app/page.module.css b/src/components/LoadingIndicator.module.css similarity index 90% rename from app/page.module.css rename to src/components/LoadingIndicator.module.css index 1d9723fd..f4926bfb 100644 --- a/app/page.module.css +++ b/src/components/LoadingIndicator.module.css @@ -1,12 +1,3 @@ -.CanvasContainer { - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - z-index: 0; -} - .LoadingIndicator { position: absolute; top: 50%; diff --git a/src/components/LoadingIndicator.tsx b/src/components/LoadingIndicator.tsx new file mode 100644 index 00000000..84aa8339 --- /dev/null +++ b/src/components/LoadingIndicator.tsx @@ -0,0 +1,22 @@ +import styles from "./LoadingIndicator.module.css"; + +export function LoadingIndicator({ + isLoading, + progress, +}: { + isLoading: boolean; + progress: number; +}) { + return ( +
+
+
+
+
+
{Math.round(progress * 100)}%
+
+ ); +} diff --git a/src/components/MapInfoDialog.module.css b/src/components/MapInfoDialog.module.css index a5e2b803..4e2b9db5 100644 --- a/src/components/MapInfoDialog.module.css +++ b/src/components/MapInfoDialog.module.css @@ -1,36 +1,14 @@ .Dialog { - position: relative; + composes: Dialog from "./GameDialog.module.css"; width: 800px; height: 600px; - max-width: calc(100dvw - 40px); - max-height: calc(100dvh - 40px); display: grid; grid-template-columns: 100%; 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 { - position: fixed; - inset: 0; - background: rgba(0, 0, 0, 0.7); - z-index: 10; - display: flex; - align-items: center; - justify-content: center; - padding: 20px; + composes: Overlay from "./GameDialog.module.css"; } .Body { @@ -99,7 +77,7 @@ .MapQuote cite { font-style: normal; font-size: 12px; - color: rgba(255, 255, 255, 0.45); + color: rgba(215, 237, 203, 0.5); display: block; } @@ -125,7 +103,7 @@ .MusicTrack { margin-top: 16px; font-size: 14px; - color: rgba(202, 208, 172, 0.5); + color: rgba(215, 237, 203, 0.5); font-style: italic; display: flex; align-items: center; @@ -174,7 +152,7 @@ } .CloseButton { - composes: DialogButton from "./DialogButton.module.css"; + composes: DialogButton from "./GameDialog.module.css"; } .Hint { diff --git a/src/components/MapInfoDialog.tsx b/src/components/MapInfoDialog.tsx index 43fc6507..677102b5 100644 --- a/src/components/MapInfoDialog.tsx +++ b/src/components/MapInfoDialog.tsx @@ -50,7 +50,9 @@ function getBitmapUrl( try { const key = getStandardTextureResourceKey(`textures/gui/${bitmap}`); return getUrlForPath(key); - } catch { /* expected */ } + } catch { + /* expected */ + } } // Fall back to Load_.png convention (multiplayer missions) try { @@ -58,7 +60,9 @@ function getBitmapUrl( `textures/gui/Load_${missionName}`, ); return getUrlForPath(key); - } catch { /* expected */ } + } catch { + /* expected */ + } return null; } @@ -161,12 +165,10 @@ function MusicPlayer({ track }: { track: string }) { } export function MapInfoDialog({ - open, onClose, missionName, missionType, }: { - open: boolean; onClose: () => void; missionName: string; missionType: string; @@ -175,19 +177,18 @@ export function MapInfoDialog({ const dialogRef = useRef(null); useEffect(() => { - if (open) { - dialogRef.current?.focus(); - try { - document.exitPointerLock(); - } catch { /* expected */ } + dialogRef.current?.focus(); + try { + document.exitPointerLock(); + } catch { + /* expected */ } - }, [open]); + }, []); // While open: block keyboard events from reaching drei, and handle close keys. useEffect(() => { - if (!open) return; const handleKeyDown = (e: KeyboardEvent) => { - if (e.code === "KeyI" || e.key === "Escape") { + if (e.key === "Escape") { onClose(); } else if (e.key === "k" && (e.metaKey || e.ctrlKey)) { onClose(); @@ -204,9 +205,7 @@ export function MapInfoDialog({ window.removeEventListener("keydown", handleKeyDown, { capture: true }); window.removeEventListener("keyup", handleKeyUp, { capture: true }); }; - }, [open, onClose]); - - if (!open) return null; + }, [onClose]); const missionGroupProps = parsedMission ? getMissionGroupProps(parsedMission.ast) @@ -322,7 +321,7 @@ export function MapInfoDialog({ - I or Esc to close + Esc to close
diff --git a/src/components/MapInspector.module.css b/src/components/MapInspector.module.css new file mode 100644 index 00000000..63974493 --- /dev/null +++ b/src/components/MapInspector.module.css @@ -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; + } +} diff --git a/src/components/MapInspector.tsx b/src/components/MapInspector.tsx new file mode 100644 index 00000000..eef89ae7 --- /dev/null +++ b/src/components/MapInspector.tsx @@ -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; + }>, +) { + 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(null); + const invalidateRef = useRef(null); + + return ( +
+ + +
e.stopPropagation()} + onPointerDown={(e) => e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + > + + + + + + + + {choosingMap && ( + + )} + +
+ {sidebarOpen ?
: null} + + +
e.stopPropagation()} + onPointerDown={(e) => e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + data-open={sidebarOpen} + > + setMapInfoOpen(true)} + onOpenServerBrowser={ + features.live ? () => setServerBrowserOpen(true) : undefined + } + onChooseMap={ + hasStreamData + ? () => { + setChoosingMap(true); + } + : undefined + } + onCancelChoosingMap={() => { + setChoosingMap(false); + }} + choosingMap={choosingMap} + cameraRef={cameraRef} + invalidateRef={invalidateRef} + /> +
+
+
+ +
+
+ { + cameraRef.current = state.camera; + invalidateRef.current = state.invalidate; + }} + > + + + + + + + + + + + + + + + + {recording ? ( + + + + ) : null} + {!hasStreamData ? ( + + + + ) : null} + {hasLiveAdapter ? ( + + + + ) : null} + + + + +
+ {hasStreamData ? ( + + + + ) : null} + + {showLoadingIndicator && ( + + )} +
+
+
e.stopPropagation()} + onPointerDown={(e) => e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + > + {recording?.source === "demo" ? ( + + + + ) : null} +
+ {mapInfoOpen ? ( + + setMapInfoOpen(false)} + missionName={missionName} + missionType={missionType ?? ""} + /> + + ) : null} + {serverBrowserOpen ? ( + + setServerBrowserOpen(false)} /> + + ) : null} + + +
+ ); +} diff --git a/src/components/Mission.tsx b/src/components/Mission.tsx index c127832e..facab7c8 100644 --- a/src/components/Mission.tsx +++ b/src/components/Mission.tsx @@ -19,9 +19,13 @@ import { getSourceAndPath, } from "../manifest"; 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 { walkMissionTree } from "../stream/missionEntityBridge"; +import { createLogger } from "../logger"; + +const log = createLogger("Mission"); const loadScript = createScriptLoader(); // Shared cache for parsed scripts - survives runtime restarts @@ -105,8 +109,16 @@ function useExecutedMission( engineStore.getState().setRuntime(runtime); const missionGroup = runtime.getObjectByName("MissionGroup"); if (missionGroup) { - const gameEntities = walkMissionTree(missionGroup, runtime); + const gameEntities = walkMissionTree( + missionGroup, + runtime, + missionType, + ); gameEntityStore.getState().setAllEntities(gameEntities); + gameEntityStore.getState().setMissionInfo({ + missionName, + missionType: missionType ?? undefined, + }); } setState({ ready: true, runtime, progress: 1 }); }) @@ -114,7 +126,7 @@ function useExecutedMission( if (err instanceof Error && err.name === "AbortError") { 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 diff --git a/src/components/MissionSelect.module.css b/src/components/MissionSelect.module.css index 63b05f30..0490908d 100644 --- a/src/components/MissionSelect.module.css +++ b/src/components/MissionSelect.module.css @@ -2,6 +2,8 @@ position: relative; display: flex; align-items: center; + margin: 10px 10px 10px 4px; + z-index: 2; } .Shortcut { @@ -41,7 +43,10 @@ } .Input::placeholder { - color: transparent; + font-size: 12px; + font-family: inherit; + color: #777; + /* color: transparent; */ } .SelectedValue { @@ -63,6 +68,7 @@ color: #fff; font-weight: 600; font-size: 14px; + line-height: calc(18 / 14); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -158,6 +164,7 @@ .ItemType { font-size: 10px; font-weight: 600; + line-height: calc(13 / 10); padding: 2px 5px; border-radius: 3px; background: rgba(255, 157, 0, 0.4); @@ -179,3 +186,13 @@ color: rgba(255, 255, 255, 0.5); text-align: center; } + +.Backdrop { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 1; +} diff --git a/src/components/MissionSelect.tsx b/src/components/MissionSelect.tsx index d6e90c86..d3c4080b 100644 --- a/src/components/MissionSelect.tsx +++ b/src/components/MissionSelect.tsx @@ -1,4 +1,5 @@ import { + Activity, Fragment, startTransition, useEffect, @@ -15,6 +16,7 @@ import { ComboboxGroup, ComboboxGroupLabel, useComboboxStore, + useStoreState, } from "@ariakit/react"; import { matchSorter } from "match-sorter"; import { getMissionInfo, getMissionList, getSourceAndPath } from "../manifest"; @@ -154,6 +156,7 @@ export function MissionSelect({ missionType, onChange, disabled, + autoFocus, }: { value: string; missionType: string; @@ -165,6 +168,7 @@ export function MissionSelect({ missionType: string | undefined; }) => void; disabled?: boolean; + autoFocus?: boolean; }) { const [searchValue, setSearchValue] = useState(""); const inputRef = useRef(null); @@ -195,6 +199,8 @@ export function MissionSelect({ }, }); + const isOpen = useStoreState(combobox, "open"); + useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "k" && (e.metaKey || e.ctrlKey)) { @@ -264,17 +270,23 @@ export function MissionSelect({ return ( + +
+
{ try { document.exitPointerLock(); - } catch { /* expected */ } + } catch { + /* expected */ + } combobox.show(); }} onKeyDown={(e) => { @@ -283,14 +295,16 @@ export function MissionSelect({ } }} /> -
- {displayValue} - {missionType && ( - - {missionType} - - )} -
+ {selectedMission && ( +
+ {displayValue} + {missionType && ( + + {missionType} + + )} +
+ )} {isMac ? "⌘K" : "^K"}
, -): number { - const sizes = expBlock.sizes as Array<{ x: number; y: number; z: number }> | undefined; +function getExplosionRadius(expBlock: Record): number { + const sizes = expBlock.sizes as + | Array<{ x: number; y: number; z: number }> + | undefined; if (Array.isArray(sizes) && sizes.length > 0) { let maxVal = 0; for (const s of sizes) { @@ -552,14 +561,17 @@ function checkShaderCompilation( material: ShaderMaterial, label: string, ): 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; if (!program) return; // Not yet compiled. const glProgram = program!.program; const glContext = renderer.getContext(); if (!glContext.getProgramParameter(glProgram, glContext.LINK_STATUS)) { - console.error( - `[ParticleFX] Shader LINK ERROR (${label}):`, + log.error( + "Shader LINK ERROR (%s): %s", + label, glContext.getProgramInfoLog(glProgram), ); } @@ -847,7 +859,8 @@ export function ParticleEffects({ group.add(sphereMesh); 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.frustumCulled = false; group.add(labelSprite); @@ -891,9 +904,8 @@ export function ParticleEffects({ } // Clamp denormalized velocity values (parser bug workaround). - const initVelocity = Math.abs(swData.velocity) > 1e-10 - ? swData.velocity - : 0; + const initVelocity = + Math.abs(swData.velocity) > 1e-10 ? swData.velocity : 0; activeShockwavesRef.current.push({ entityId: entity.id as string, @@ -910,7 +922,6 @@ export function ParticleEffects({ }); } } - } // Detect projectile entities with trail emitters (maintainEmitterId). @@ -918,7 +929,10 @@ export function ParticleEffects({ for (const entity of snapshot.entities) { currentEntityIds.add(entity.id); - if (!entity.maintainEmitterId || trailEntitiesRef.current.has(entity.id)) { + if ( + !entity.maintainEmitterId || + trailEntitiesRef.current.has(entity.id) + ) { continue; } trailEntitiesRef.current.add(entity.id); @@ -933,7 +947,10 @@ export function ParticleEffects({ ? [...entity.position] : [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 geometry = createParticleGeometry(MAX_PARTICLES_PER_EMITTER); @@ -980,7 +997,11 @@ export function ParticleEffects({ // One-time shader compilation check. if (!entry.shaderChecked) { - checkShaderCompilation(gl, entry.material, entry.isBurst ? "burst" : "stream"); + checkShaderCompilation( + gl, + entry.material, + entry.isBurst ? "burst" : "stream", + ); entry.shaderChecked = true; } @@ -1166,7 +1187,13 @@ export function ParticleEffects({ // ── Audio: explosion impact sounds ── // Only process new audio events while playing to avoid triggering // 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) { if ( entity.type !== "Explosion" || @@ -1205,7 +1232,11 @@ export function ParticleEffects({ const projSounds = projectileSoundsRef.current; for (const entity of snapshot.entities) { - if (entity.type !== "Projectile" || !entity.dataBlockId || !entity.position) { + if ( + entity.type !== "Projectile" || + !entity.dataBlockId || + !entity.position + ) { continue; } if (projSounds.has(entity.id)) { @@ -1268,8 +1299,16 @@ export function ParticleEffects({ for (const [entityId, sound] of projSounds) { if (!currentEntityIds.has(entityId)) { untrackSound(sound); - try { sound.stop(); } catch { /* already stopped */ } - try { sound.disconnect(); } catch { /* already disconnected */ } + try { + sound.stop(); + } catch { + /* already stopped */ + } + try { + sound.disconnect(); + } catch { + /* already disconnected */ + } groupRef.current?.remove(sound); projSounds.delete(entityId); } @@ -1360,8 +1399,16 @@ export function ParticleEffects({ // Clean up projectile sounds. for (const [, sound] of projectileSoundsRef.current) { untrackSound(sound); - try { sound.stop(); } catch { /* already stopped */ } - try { sound.disconnect(); } catch { /* already disconnected */ } + try { + sound.stop(); + } catch { + /* already stopped */ + } + try { + sound.disconnect(); + } catch { + /* already disconnected */ + } if (group) group.remove(sound); } projectileSoundsRef.current.clear(); diff --git a/src/components/PlayerHUD.module.css b/src/components/PlayerHUD.module.css index d3b56b6f..8263bc46 100644 --- a/src/components/PlayerHUD.module.css +++ b/src/components/PlayerHUD.module.css @@ -8,12 +8,10 @@ pointer-events: none; } -/* ── Top-right cluster: compass + bars ── */ - .TopRight { position: absolute; - top: 56px; - right: 8px; + top: 10px; + right: 10px; display: flex; align-items: flex-start; gap: 6px; @@ -87,103 +85,12 @@ 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) ── */ .TeamScores { position: absolute; - bottom: 130px; - left: 0; + bottom: 8px; + left: 8px; font-family: monospace; font-size: 12px; } @@ -226,7 +133,7 @@ .PackInventoryHUD { position: absolute; - bottom: 100px; + bottom: 8px; right: 8px; display: flex; align-items: center; diff --git a/src/components/PlayerHUD.tsx b/src/components/PlayerHUD.tsx index 8652111d..535d78c6 100644 --- a/src/components/PlayerHUD.tsx +++ b/src/components/PlayerHUD.tsx @@ -1,20 +1,16 @@ -import { useRef, useEffect, useState, useCallback } from "react"; -import { useRecording } from "./RecordingProvider"; -import { useEngineSelector } from "../state"; +import { useEngineSelector } from "../state/engineStore"; import { textureToUrl } from "../loaders"; -import { liveConnectionStore } from "../state/liveConnectionStore"; -import type { - ChatSegment, - ChatMessage, - StreamEntity, - TeamScore, - WeaponsHudSlot, -} from "../stream/types"; +import type { StreamEntity, TeamScore, WeaponsHudSlot } from "../stream/types"; import styles from "./PlayerHUD.module.css"; -// ── Compass ── +import { ChatWindow } from "./ChatWindow"; + const COMPASS_URL = textureToUrl("gui/hud_new_compass"); 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; // The ring notch is the fixed heading indicator (always "forward" at top). // The NSEW letters rotate to show world cardinal directions relative to @@ -33,41 +29,51 @@ function Compass({ yaw }: { yaw: number | undefined }) {
); } -// ── Health / Energy bars ── -function HealthBar({ value }: { value: number }) { - const pct = Math.max(0, Math.min(100, value * 100)); + +function HealthBar() { + 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 (
); } -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 (
); } -// ── Reticle ── + const RETICLE_TEXTURES: Record = { weapon_sniper: "gui/hud_ret_sniper", weapon_shocklance: "gui/hud_ret_shocklance", weapon_targeting: "gui/hud_ret_targlaser", }; + function normalizeWeaponName(shape: string | undefined): string { if (!shape) return ""; return shape.replace(/\.dts$/i, "").toLowerCase(); } + function Reticle() { const weaponShape = useEngineSelector((state) => { const snap = state.playback.streamSnapshot; if (!snap || snap.camera?.mode !== "first-person") return undefined; const ctrl = snap.controlPlayerGhostId; if (!ctrl) return undefined; - return snap.entities.find((e: StreamEntity) => e.id === ctrl) - ?.weaponShape; + return snap.entities.find((e: StreamEntity) => e.id === ctrl)?.weaponShape; }); if (weaponShape === undefined) return null; const weapon = normalizeWeaponName(weaponShape); @@ -75,7 +81,6 @@ function Reticle() { if (textureName) { return (
- {/* eslint-disable-next-line @next/next/no-img-element */} ); } -// ── Weapon HUD (right side weapon list) ── + /** Maps $WeaponsHudData indices to simple icon textures (no baked background) * and labels. Mortar uses hud_new_ because no simple variant exists. */ const WEAPON_HUD_SLOTS: Record = { @@ -114,6 +119,7 @@ const WEAPON_HUD_SLOTS: Record = { 16: { icon: "gui/hud_shocklance", label: "Shocklance" }, 17: { icon: "gui/hud_new_mortar", label: "Mortar" }, }; + // Precompute URLs so we don't call textureToUrl on every render. const WEAPON_HUD_ICON_URLS = new Map( Object.entries(WEAPON_HUD_SLOTS).map(([idx, w]) => [ @@ -121,9 +127,11 @@ const WEAPON_HUD_ICON_URLS = new Map( textureToUrl(w.icon), ]), ); + /** Targeting laser HUD indices (standard + TR2 variants). */ const TARGETING_LASER_INDICES = new Set([9, 14, 15]); const INFINITY_ICON_URL = textureToUrl("gui/hud_infinity"); + function WeaponSlotIcon({ slot, isSelected, @@ -138,14 +146,12 @@ function WeaponSlotIcon({
- {/* eslint-disable-next-line @next/next/no-img-element */} {info.label} {isInfinite ? ( - // eslint-disable-next-line @next/next/no-img-element \u221E ); } + function WeaponHUD() { const weaponsHud = useEngineSelector( (state) => state.playback.streamSnapshot?.weaponsHud, @@ -191,7 +198,7 @@ function WeaponHUD() {
); } -// ── Team Scores (bottom-left) ── + /** Default team names from serverDefaults.cs. */ const DEFAULT_TEAM_NAMES: Record = { 1: "Storm", @@ -201,6 +208,7 @@ const DEFAULT_TEAM_NAMES: Record = { 5: "Blood Eagle", 6: "Phoenix", }; + function TeamScores() { const teamScores = useEngineSelector( (state) => state.playback.streamSnapshot?.teamScores, @@ -242,101 +250,7 @@ function TeamScores() {
); } -// ── Chat Window (top-left) ── -/** Map a colorCode to a CSS module class name (c0–c9 GuiChatHudProfile). */ -const CHAT_COLOR_CLASSES: Record = { - 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(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 ( -
- {hasMessages && ( -
- {messages!.map((msg: ChatMessage, i: number) => ( -
- {msg.segments ? ( - msg.segments.map((seg: ChatSegment, j: number) => ( - - {seg.text} - - )) - ) : ( - - {msg.sender ? `${msg.sender}: ` : ""} - {msg.text} - - )} -
- ))} -
- )} - {isLive && ( -
- setChatText(e.target.value)} - onKeyDown={(e) => e.stopPropagation()} - onKeyUp={(e) => e.stopPropagation()} - maxLength={255} - /> -
- )} -
- ); -} // ── Backpack + Inventory HUD (bottom-right) ── /** Maps $BackpackHudData indices to icon textures. */ const BACKPACK_ICONS: Record = { @@ -429,7 +343,6 @@ function PackAndInventoryHUD() {
- {/* eslint-disable-next-line @next/next/no-img-element */} {backpackHud!.text || "\u00A0"} @@ -442,7 +355,6 @@ function PackAndInventoryHUD() { if (!info || !iconUrl) return null; return (
- {/* eslint-disable-next-line @next/next/no-img-element */} {info.label} ); } -// ── Main HUD ── -export function PlayerHUD({ isLive = false }: { isLive?: boolean } = {}) { - const recording = useRecording(); - const streamSnapshot = useEngineSelector( - (state) => state.playback.streamSnapshot, + +export function PlayerHUD() { + const hasControlPlayer = useEngineSelector( + (state) => !!state.playback.streamSnapshot?.controlPlayerGhostId, ); - if (!recording && !isLive) return null; - const status = streamSnapshot?.status; return (
- - {status && ( - <> -
-
- - -
- + +
+ {hasControlPlayer && ( +
+ +
+ )} + +
+ {hasControlPlayer && ( + <> - )} +
); } diff --git a/src/components/PlayerModel.tsx b/src/components/PlayerModel.tsx index ebb8fc22..341537ac 100644 --- a/src/components/PlayerModel.tsx +++ b/src/components/PlayerModel.tsx @@ -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 { useFrame } from "@react-three/fiber"; import { @@ -26,8 +26,9 @@ import { pickMoveAnimation } from "../stream/playerAnimation"; import { WeaponImageStateMachine } from "../stream/weaponStateMachine"; import type { WeaponAnimState } from "../stream/weaponStateMachine"; import { getAliasedActions } from "../torqueScript/shapeConstructor"; -import { useStaticShape } from "./GenericShape"; -import { ShapeErrorBoundary } from "./EntityScene"; +import { useStaticShape, ShapePlaceholder } from "./GenericShape"; +import { ShapeErrorBoundary } from "./ShapeErrorBoundary"; +import { DebugSuspense } from "./DebugSuspense"; import { useAudio } from "./AudioContext"; import { resolveAudioProfile, @@ -39,7 +40,7 @@ import { } from "./AudioEmitter"; import { audioToUrl } from "../loaders"; import { useSettings } from "./SettingsProvider"; -import { useEngineStoreApi, useEngineSelector } from "../state"; +import { useEngineStoreApi, useEngineSelector } from "../state/engineStore"; import { streamPlaybackStore } from "../state/streamPlaybackStore"; import type { PlayerEntity } from "../state/gameEntityTypes"; @@ -60,8 +61,16 @@ function getArmThread(weaponShape: string | undefined): string { const NUM_TABLE_ACTION_ANIMS = 8; /** 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 { /** GLB clip name (lowercase, e.g. "diehead"). */ @@ -90,7 +99,10 @@ function buildActionAnimMap( const spaceIdx = entry.indexOf(" "); if (spaceIdx === -1) continue; 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")) continue; const clipName = dsqFile.slice(shapePrefix.length, -4); @@ -131,8 +143,16 @@ function stopLoopingSound( const sound = soundRef.current; if (!sound) return; untrackSound(sound); - try { sound.stop(); } catch { /* already stopped */ } - try { sound.disconnect(); } catch { /* already disconnected */ } + try { + sound.stop(); + } catch { + /* already stopped */ + } + try { + sound.disconnect(); + } catch { + /* already disconnected */ + } parent?.remove(sound); soundRef.current = null; stateRef.current = -1; @@ -152,36 +172,44 @@ export function PlayerModel({ entity }: { entity: PlayerEntity }) { const gltf = useStaticShape(shapeName!); const shapeAliases = useEngineSelector((state) => { const sn = shapeName?.toLowerCase(); - return sn - ? state.runtime.sequenceAliases.get(sn) - : undefined; + return sn ? state.runtime.sequenceAliases.get(sn) : undefined; }); // Clone scene preserving skeleton bindings, create mixer, find mount bones. - const { clonedScene, mixer, mount0, mount1, iflInitializers } = useMemo(() => { - const scene = SkeletonUtils.clone(gltf.scene) as Group; - const iflInits = processShapeScene(scene); + const { clonedScene, mixer, mount0, mount1, mount2, iflInitializers } = + useMemo(() => { + const scene = SkeletonUtils.clone(gltf.scene) as Group; + const iflInits = processShapeScene(scene); - // Use front-face-only rendering so the camera can see out from inside the - // model in first-person (backface culling hides interior faces). - scene.traverse((n: any) => { - if (n.isMesh && n.material) { - const mats = Array.isArray(n.material) ? n.material : [n.material]; - for (const m of mats) m.side = FrontSide; - } - }); + // Use front-face-only rendering so the camera can see out from inside the + // model in first-person (backface culling hides interior faces). + scene.traverse((n: any) => { + if (n.isMesh && n.material) { + const mats = Array.isArray(n.material) ? n.material : [n.material]; + for (const m of mats) m.side = FrontSide; + } + }); - const mix = new AnimationMixer(scene); + const mix = new AnimationMixer(scene); - let m0: Object3D | null = null; - let m1: Object3D | null = null; - scene.traverse((n) => { - if (!m0 && n.name === "Mount0") m0 = n; - if (!m1 && n.name === "Mount1") m1 = n; - }); + let m0: Object3D | null = null; + let m1: Object3D | null = null; + let m2: Object3D | null = null; + scene.traverse((n) => { + if (!m0 && n.name === "Mount0") m0 = 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 }; - }, [gltf]); + return { + clonedScene: scene, + mixer: mix, + mount0: m0, + mount1: m1, + mount2: m2, + iflInitializers: iflInits, + }; + }, [gltf]); // Build case-insensitive clip lookup with alias support. const animActionsRef = useRef(new Map()); @@ -226,7 +254,10 @@ export function PlayerModel({ entity }: { entity: PlayerEntity }) { // place) since multiple player entities share the same GLTF cache. // 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 [ { key: "head" as const, names: ["head"] }, { key: "headside" as const, names: ["headside"] }, @@ -318,6 +349,8 @@ export function PlayerModel({ entity }: { entity: PlayerEntity }) { ); const packShapeRef = useRef(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. useFrame((_, delta) => { @@ -329,6 +362,10 @@ export function PlayerModel({ entity }: { entity: PlayerEntity }) { packShapeRef.current = entity.packShape; setCurrentPackShape(entity.packShape); } + if (entity.flagShape !== flagShapeRef.current) { + flagShapeRef.current = entity.flagShape; + setCurrentFlagShape(entity.flagShape); + } const playback = engineStore.getState().playback; const isPlaying = playback.status === "playing"; const time = streamPlaybackStore.getState().time; @@ -343,9 +380,8 @@ export function PlayerModel({ entity }: { entity: PlayerEntity }) { isDeadRef.current = true; // The server sends the death animation as an actionAnim index. - const deathEntry = kf.actionAnim != null - ? actionAnimMap.get(kf.actionAnim) - : undefined; + const deathEntry = + kf.actionAnim != null ? actionAnimMap.get(kf.actionAnim) : undefined; if (deathEntry) { const deathAction = actions.get(deathEntry.clipName); if (deathAction) { @@ -368,7 +404,9 @@ export function PlayerModel({ entity }: { entity: PlayerEntity }) { isDeadRef.current = false; actionAnimRef.current = undefined; - const deathAction = actions.get(currentAnimRef.current.name.toLowerCase()); + const deathAction = actions.get( + currentAnimRef.current.name.toLowerCase(), + ); if (deathAction) { deathAction.stop(); deathAction.setLoop(LoopRepeat, Infinity); @@ -386,8 +424,10 @@ export function PlayerModel({ entity }: { entity: PlayerEntity }) { const prevActionAnim = actionAnimRef.current; if (!isDeadRef.current && actionAnim !== prevActionAnim) { actionAnimRef.current = actionAnim; - const isNonTableAction = actionAnim != null && actionAnim >= NUM_TABLE_ACTION_ANIMS; - const wasNonTableAction = prevActionAnim != null && prevActionAnim >= NUM_TABLE_ACTION_ANIMS; + const isNonTableAction = + actionAnim != null && actionAnim >= NUM_TABLE_ACTION_ANIMS; + const wasNonTableAction = + prevActionAnim != null && prevActionAnim >= NUM_TABLE_ACTION_ANIMS; if (isNonTableAction) { // Start or change action animation. @@ -395,7 +435,9 @@ export function PlayerModel({ entity }: { entity: PlayerEntity }) { if (entry) { const actionAction = actions.get(entry.clipName); 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); actionAction.setLoop(LoopOnce, 1); actionAction.clampWhenFinished = true; @@ -421,7 +463,11 @@ export function PlayerModel({ entity }: { entity: PlayerEntity }) { } // 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); if (entry) { 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). - const playingActionAnim = actionAnimRef.current != null && + const playingActionAnim = + actionAnimRef.current != null && actionAnimRef.current >= NUM_TABLE_ACTION_ANIMS; if (!isDeadRef.current && !playingActionAnim) { const anim = pickMoveAnimation( @@ -518,24 +565,52 @@ export function PlayerModel({ entity }: { entity: PlayerEntity }) { {currentWeaponShape && mount0 && ( - - - } + > + + } + > + - + )} {currentPackShape && mount1 && ( - - - - + } + > + + } + > + + + + )} + {currentFlagShape && mount2 && ( + } + > + + } + > + + )} @@ -556,7 +631,9 @@ function buildSeqIndexToName( try { const names: string[] = JSON.parse(raw); return names.map((n) => n.toLowerCase()); - } catch { /* fall through */ } + } catch { + /* fall through */ + } } 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 * without triggering React re-renders. */ -function AnimatedWeaponModel({ +function WeaponModel({ entity, weaponShape, mount0, @@ -584,55 +661,60 @@ function AnimatedWeaponModel({ const weaponGltf = useStaticShape(weaponShape); // Clone weapon with skeleton bindings, create dedicated mixer. - const { weaponClone, weaponMixer, seqIndexToName, visNodesBySequence, weaponIflInitializers } = - useMemo(() => { - const clone = SkeletonUtils.clone(weaponGltf.scene) as Group; - const iflInits = processShapeScene(clone); + const { + weaponClone, + weaponMixer, + seqIndexToName, + visNodesBySequence, + weaponIflInitializers, + } = useMemo(() => { + const clone = SkeletonUtils.clone(weaponGltf.scene) as Group; + const iflInits = processShapeScene(clone); - // Compute Mountpoint inverse offset so the weapon's grip aligns to Mount0. - const mp = getPosedNodeTransform( - weaponGltf.scene, - weaponGltf.animations, - "Mountpoint", - ); - if (mp) { - const invQuat = mp.quaternion.clone().invert(); - const invPos = mp.position.clone().negate().applyQuaternion(invQuat); - clone.position.copy(invPos); - clone.quaternion.copy(invQuat); + // Compute Mountpoint inverse offset so the weapon's grip aligns to Mount0. + const mp = getPosedNodeTransform( + weaponGltf.scene, + weaponGltf.animations, + "Mountpoint", + ); + if (mp) { + const invQuat = mp.quaternion.clone().invert(); + const invPos = mp.position.clone().negate().applyQuaternion(invQuat); + clone.position.copy(invPos); + clone.quaternion.copy(invQuat); + } + + // Collect vis-animated meshes grouped by controlling sequence name. + // E.g. the disc launcher's Disc mesh has vis_sequence="discSpin" and is + // hidden by default (vis=0). When "discSpin" plays, the mesh becomes + // visible; when a different sequence plays, it hides again. + const visBySeq = new Map(); + clone.traverse((node: any) => { + if (!node.isMesh) return; + const ud = node.userData; + const seqName = (ud?.vis_sequence ?? "").toLowerCase(); + if (!seqName) return; + let list = visBySeq.get(seqName); + if (!list) { + list = []; + visBySeq.set(seqName, list); } + list.push(node); + }); - // Collect vis-animated meshes grouped by controlling sequence name. - // E.g. the disc launcher's Disc mesh has vis_sequence="discSpin" and is - // hidden by default (vis=0). When "discSpin" plays, the mesh becomes - // visible; when a different sequence plays, it hides again. - const visBySeq = new Map(); - clone.traverse((node: any) => { - if (!node.isMesh) return; - const ud = node.userData; - const seqName = (ud?.vis_sequence ?? "").toLowerCase(); - if (!seqName) return; - let list = visBySeq.get(seqName); - if (!list) { - list = []; - visBySeq.set(seqName, list); - } - list.push(node); - }); - - const mix = new AnimationMixer(clone); - const seq = buildSeqIndexToName( - weaponGltf.scene as Group, - weaponGltf.animations, - ); - return { - weaponClone: clone, - weaponMixer: mix, - seqIndexToName: seq, - visNodesBySequence: visBySeq, - weaponIflInitializers: iflInits, - }; - }, [weaponGltf]); + const mix = new AnimationMixer(clone); + const seq = buildSeqIndexToName( + weaponGltf.scene as Group, + weaponGltf.animations, + ); + return { + weaponClone: clone, + weaponMixer: mix, + seqIndexToName: seq, + visNodesBySequence: visBySeq, + weaponIflInitializers: iflInits, + }; + }, [weaponGltf]); // Build case-insensitive action map for weapon animations. const weaponActionsRef = useRef(new Map()); @@ -760,8 +842,10 @@ function AnimatedWeaponModel({ audioListener && animState.soundDataBlockIds.length > 0 ) { - const getDb = playback.recording?.streamingPlayback.getDataBlockData - .bind(playback.recording.streamingPlayback); + const getDb = + playback.recording?.streamingPlayback.getDataBlockData.bind( + playback.recording.streamingPlayback, + ); if (getDb) { for (const soundDbId of animState.soundDataBlockIds) { const resolved = resolveAudioProfile(soundDbId, getDb); @@ -795,7 +879,9 @@ function AnimatedWeaponModel({ loopingSoundRef.current = sound; loopingSoundStateRef.current = currentIdx; }); - } catch { /* expected */ } + } catch { + /* expected */ + } } } else { playOneShotSound( @@ -814,7 +900,6 @@ function AnimatedWeaponModel({ if (spinActionRef.current) { spinActionRef.current.timeScale = animState.spinTimeScale; } - } // Advance the weapon mixer. @@ -898,9 +983,8 @@ function applyWeaponAnim( // Scale animation to fit the state timeout if requested. if (animState.scaleAnimation && animState.timeoutValue > 0) { const clipDuration = action.getClip().duration; - action.timeScale = clipDuration > 0 - ? clipDuration / animState.timeoutValue - : 1; + action.timeScale = + clipDuration > 0 ? clipDuration / animState.timeoutValue : 1; } else { action.timeScale = animState.reverse ? -1 : 1; } @@ -921,7 +1005,7 @@ function applyWeaponAnim( * mounted images (no state machine or animation) — just positioned via * the pack shape's Mountpoint node inverse offset, same as weapons. */ -function MountedPackModel({ +function PackModel({ packShape, mountBone, }: { diff --git a/src/components/PlayerNameplate.tsx b/src/components/PlayerNameplate.tsx index 4442ca04..e8904e25 100644 --- a/src/components/PlayerNameplate.tsx +++ b/src/components/PlayerNameplate.tsx @@ -29,7 +29,7 @@ const _tmpVec = new Vector3(); */ export function PlayerNameplate({ entity }: { entity: PlayerEntity }) { const gltf = useStaticShape((entity.shapeName ?? entity.dataBlock)!); - const { camera } = useThree(); + const camera = useThree((state) => state.camera); const groupRef = useRef(null); const iffContainerRef = useRef(null); const nameContainerRef = useRef(null); @@ -75,7 +75,10 @@ export function PlayerNameplate({ entity }: { entity: PlayerEntity }) { if (!shouldBeVisible) return; // 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; if (kf?.damageState != null && kf.damageState >= 1) { if (iffContainerRef.current) iffContainerRef.current.style.opacity = "0"; diff --git a/src/components/Projectiles.tsx b/src/components/Projectiles.tsx index f857d522..b0feeecc 100644 --- a/src/components/Projectiles.tsx +++ b/src/components/Projectiles.tsx @@ -16,7 +16,7 @@ import { setQuaternionFromDir, } from "../stream/playbackUtils"; import { textureToUrl } from "../loaders"; -import type { TracerVisual, SpriteVisual } from "../stream/types"; +import { SpriteEntity, TracerEntity } from "../state/gameEntityTypes"; const _tracerDir = new Vector3(); const _tracerDirFromCam = new Vector3(); @@ -26,7 +26,8 @@ const _tracerEnd = new Vector3(); const _tracerWorldPos = new Vector3(); 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 texture = useTexture(url, (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. 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], ); @@ -55,13 +61,8 @@ export function SpriteProjectile({ visual }: { visual: SpriteVisual }) { ); } -export function TracerProjectile({ - entity, - visual, -}: { - entity: { keyframes?: Array<{ position?: [number, number, number]; velocity?: [number, number, number] }>; direction?: [number, number, number] }; - visual: TracerVisual; -}) { +export function TracerProjectile({ entity }: { entity: TracerEntity }) { + const { visual } = entity; const tracerRef = useRef(null); const tracerPosRef = useRef(null); const crossRef = useRef(null); @@ -167,14 +168,12 @@ export function TracerProjectile({ /> + - + - {children}; diff --git a/src/components/RuntimeProvider.tsx b/src/components/RuntimeProvider.tsx index 18dd0c35..6a1e0dfa 100644 --- a/src/components/RuntimeProvider.tsx +++ b/src/components/RuntimeProvider.tsx @@ -1,7 +1,6 @@ import { createContext, ReactNode, useContext } from "react"; import type { TorqueRuntime } from "../torqueScript"; - const RuntimeContext = createContext(null); export interface RuntimeProviderProps { @@ -24,4 +23,3 @@ export function useRuntime(): TorqueRuntime { } return runtime; } - diff --git a/src/components/SceneLighting.tsx b/src/components/SceneLighting.tsx index d4bc8881..14af2b4e 100644 --- a/src/components/SceneLighting.tsx +++ b/src/components/SceneLighting.tsx @@ -1,9 +1,12 @@ import { useEffect, useMemo } from "react"; import { Color, Vector3 } from "three"; +import { createLogger } from "../logger"; import { useSceneSun } from "../state/gameEntityStore"; import { torqueToThree } from "../scene/coordinates"; import { updateGlobalSunUniforms } from "../globalSunUniforms"; +const log = createLogger("SceneLighting"); + /** * Renders scene-global lights (directional sun + ambient) derived from the * Sun entity in the game entity store. Rendered outside EntityScene so that @@ -14,6 +17,25 @@ import { updateGlobalSunUniforms } from "../globalSunUniforms"; export function SceneLighting() { 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) { // Fallback lighting when no Sun entity exists yet return ; @@ -22,7 +44,11 @@ export function SceneLighting() { return ; } -function SunLighting({ sunData }: { sunData: NonNullable> }) { +function SunLighting({ + sunData, +}: { + sunData: NonNullable>; +}) { const direction = useMemo(() => { const [x, y, z] = torqueToThree(sunData.direction); const len = Math.sqrt(x * x + y * y + z * z); diff --git a/src/components/ServerBrowser.module.css b/src/components/ServerBrowser.module.css index f78ee271..5010ccd8 100644 --- a/src/components/ServerBrowser.module.css +++ b/src/components/ServerBrowser.module.css @@ -1,36 +1,14 @@ .Dialog { - position: relative; + composes: Dialog from "./GameDialog.module.css"; width: 860px; height: 560px; - max-width: calc(100dvw - 40px); - max-height: calc(100dvh - 40px); display: grid; grid-template-columns: 100%; 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 { - position: fixed; - inset: 0; - background: rgba(0, 0, 0, 0.7); - z-index: 10; - display: flex; - align-items: center; - justify-content: center; - padding: 20px; + composes: Overlay from "./GameDialog.module.css"; } .Header { @@ -50,8 +28,18 @@ flex: 1; } +.HiddenRadio { + position: absolute; + width: 0; + height: 0; + opacity: 0; + z-index: -1; + overflow: hidden; + pointer-events: none; +} + .RefreshButton { - composes: DialogButton from "./DialogButton.module.css"; + composes: DialogButton from "./GameDialog.module.css"; padding: 3px 14px; font-size: 12px; } @@ -74,6 +62,7 @@ .Table th { position: sticky; + z-index: 1; top: 0; background: rgba(10, 25, 26, 0.95); padding: 6px 12px; @@ -100,6 +89,8 @@ } .Table td { + position: relative; + z-index: 0; padding: 3px 12px; border-bottom: 1px solid rgba(255, 255, 255, 0.04); white-space: nowrap; @@ -109,15 +100,19 @@ font-weight: 500; } -.Table tbody tr { +.Table td.EmptyServer { + opacity: 0.4; +} + +.Table tbody tr:not(.Empty) { cursor: pointer; } -.Table tbody tr:hover { +.Table tbody tr:not(.Empty):hover { background: rgba(65, 131, 139, 0.12); } -.Selected { +.Table tbody tr:has(input:checked) { background: rgba(93, 255, 225, 0.9) !important; color: #1e2828; } @@ -128,7 +123,7 @@ font-size: 11px; } -.Empty { +.Empty td { text-align: center; color: rgba(201, 220, 216, 0.3); padding: 32px 12px !important; @@ -146,12 +141,12 @@ } .JoinButton { - composes: DialogButton from "./DialogButton.module.css"; + composes: DialogButton from "./GameDialog.module.css"; min-width: 100px; } .CloseButton { - composes: Secondary from "./DialogButton.module.css"; + composes: Secondary from "./GameDialog.module.css"; } .WarriorField { diff --git a/src/components/ServerBrowser.tsx b/src/components/ServerBrowser.tsx index 4894e6e6..c81aaf2a 100644 --- a/src/components/ServerBrowser.tsx +++ b/src/components/ServerBrowser.tsx @@ -1,57 +1,47 @@ import { useState, useEffect, useCallback, useRef, useMemo } from "react"; import type { ServerInfo } from "../../relay/types"; import styles from "./ServerBrowser.module.css"; -export function ServerBrowser({ - open, - onClose, - servers, - loading, - onRefresh, - onJoin, - wsPing, - warriorName, - onWarriorNameChange, -}: { - 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; -}) { +import { useLiveSelector } from "../state/liveConnectionStore"; +import { useSettings } from "./SettingsProvider"; + +export function ServerBrowser({ onClose }: { 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 { warriorName, setWarriorName } = useSettings(); const [selectedAddress, setSelectedAddress] = useState(null); + const handleJoinSelected = () => { + if (selectedAddress) { + joinServer(selectedAddress, warriorName); + onClose(); + } + }; + + const handleJoin = (address: string) => { + joinServer(address, warriorName); + onClose(); + }; const [sortKey, setSortKey] = useState("ping"); const [sortDir, setSortDir] = useState<"asc" | "desc">("asc"); const dialogRef = useRef(null); - const onRefreshRef = useRef(onRefresh); - onRefreshRef.current = onRefresh; - const didAutoRefreshRef = useRef(false); + useEffect(() => { - if (open) { - dialogRef.current?.focus(); - try { - document.exitPointerLock(); - } catch { - /* expected */ - } - } else { - didAutoRefreshRef.current = false; + dialogRef.current?.focus(); + try { + document.exitPointerLock(); + } catch { + /* expected */ } - }, [open]); - // Refresh on open if no servers cached + }, []); + useEffect(() => { - if (open && servers.length === 0 && !didAutoRefreshRef.current) { - didAutoRefreshRef.current = true; - onRefreshRef.current(); - } - }, [open]); // eslint-disable-line react-hooks/exhaustive-deps + listServers(); + }, [listServers]); + // Block keyboard events from reaching Three.js while open useEffect(() => { - if (!open) return; const handleKeyDown = (e: KeyboardEvent) => { e.stopPropagation(); if (e.key === "Escape") { @@ -60,7 +50,8 @@ export function ServerBrowser({ }; window.addEventListener("keydown", handleKeyDown, true); return () => window.removeEventListener("keydown", handleKeyDown, true); - }, [open, onClose]); + }, [onClose]); + const handleSort = useCallback( (key: keyof ServerInfo) => { if (sortKey === key) { @@ -85,15 +76,6 @@ export function ServerBrowser({ }); }, [servers, sortDir, sortKey]); - const handleJoin = useCallback(() => { - if (selectedAddress) { - onJoin(selectedAddress); - onClose(); - } - }, [selectedAddress, onJoin, onClose]); - - if (!open) return null; - return (
- - - - - - - - - - - - - {sorted.map((server) => ( - setSelectedAddress(server.address)} - onDoubleClick={() => { - setSelectedAddress(server.address); - onJoin(server.address); - onClose(); - }} - > - - - - - - - - ))} - {sorted.length === 0 && !loading && ( + +
handleSort("name")}>Server Name handleSort("playerCount")}>Players handleSort("ping")}>Ping handleSort("mapName")}>Map handleSort("gameType")}>Type handleSort("mod")}>Mod
- {server.passwordRequired && ( - 🔒 - )} - {server.name} - - {server.playerCount}/{server.maxPlayers} - - {wsPing != null - ? (server.ping + wsPing).toLocaleString() - : "\u2014"} - {server.mapName}{server.gameType}{server.mod}
+ - + + + + + + - )} - {loading && sorted.length === 0 && ( - - - - )} - -
- No servers found - handleSort("name")}>Server Name handleSort("playerCount")}>Players handleSort("ping")}>Ping handleSort("mapName")}>Map handleSort("gameType")}>Type handleSort("mod")}>Mod
- Querying master server... -
+ + + {sorted.map((server) => ( + { + setSelectedAddress(server.address); + const form = document.forms["serverList"]; + const inputs: RadioNodeList = + form.elements["serverAddress"]; + const input = Array.from(inputs).find( + (input) => input.value === server.address, + ); + input.focus(); + }} + onDoubleClick={() => { + setSelectedAddress(server.address); + handleJoin(server.address); + onClose(); + }} + > + + { + setSelectedAddress(event.target.value); + }} + /> + {server.passwordRequired && ( + 🔒 + )} + {server.name} + + + {server.playerCount} / {server.maxPlayers} + + + {browserToRelayPing != null + ? (server.ping + browserToRelayPing).toLocaleString() + : "\u2014"} + + {server.mapName} + {server.gameType} + {server.mod} + + ))} + {sorted.length === 0 && !serversLoading && ( + + No servers found + + )} + {serversLoading && sorted.length === 0 && ( + + Querying master server… + + )} + + +
@@ -189,7 +189,7 @@ export function ServerBrowser({ className={styles.WarriorInput} type="text" value={warriorName} - onChange={(e) => onWarriorNameChange(e.target.value)} + onChange={(e) => setWarriorName(e.target.value)} placeholder="Name thyself…" maxLength={24} /> @@ -199,7 +199,7 @@ export function ServerBrowser({ Cancel + ) : isLive ? ( + + ) : null} +
+ ); +} diff --git a/src/components/TSStatic.tsx b/src/components/TSStatic.tsx index 5fd16c4f..6ac7cc30 100644 --- a/src/components/TSStatic.tsx +++ b/src/components/TSStatic.tsx @@ -1,5 +1,8 @@ import { useMemo } from "react"; +import { createLogger } from "../logger"; import type { SceneTSStatic } from "../scene/types"; + +const log = createLogger("TSStatic"); import { torqueToThree, torqueScaleToThree, @@ -18,10 +21,7 @@ export function TSStatic({ scene }: { scene: SceneTSStatic }) { ); const scale = useMemo(() => torqueScaleToThree(scene.scale), [scene.scale]); if (!scene.shapeName) { - console.error( - " missing shapeName for ghostIndex", - scene.ghostIndex, - ); + log.error("TSStatic missing shapeName for ghostIndex %d", scene.ghostIndex); } return ( diff --git a/src/components/TerrainBlock.tsx b/src/components/TerrainBlock.tsx index 257cb57e..4ad8f4cc 100644 --- a/src/components/TerrainBlock.tsx +++ b/src/components/TerrainBlock.tsx @@ -17,7 +17,10 @@ import { Vector3, } from "three"; import type { SceneTerrainBlock } from "../scene/types"; +import { createLogger } from "../logger"; import { torqueToThree } from "../scene/coordinates"; + +const log = createLogger("TerrainBlock"); import { useSceneSky, useSceneSun } from "../state/gameEntityStore"; import { loadTerrain } from "../loaders"; import { uint16ToFloat32 } from "../arrayUtils"; @@ -413,10 +416,25 @@ function generateTerrainLightmap( * Load a .ter file, used for terrain heightmap and texture info. */ function useTerrain(terrainFile: string) { - return useQuery({ + const result = useQuery({ 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 @@ -607,6 +625,13 @@ export const TerrainBlock = memo(function TerrainBlock({ !sharedDisplacementMap || !sharedAlphaTextures ) { + log.debug( + "Not ready: terrain=%s geometry=%s displacement=%s alpha=%s", + !!terrain, + !!sharedGeometry, + !!sharedDisplacementMap, + !!sharedAlphaTextures, + ); return null; } return ( diff --git a/src/components/ThreeCanvas.tsx b/src/components/ThreeCanvas.tsx new file mode 100644 index 00000000..d1810ee6 --- /dev/null +++ b/src/components/ThreeCanvas.tsx @@ -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 ( + + {children} + + ); +} diff --git a/src/components/TouchControls.tsx b/src/components/TouchControls.tsx deleted file mode 100644 index 3a051134..00000000 --- a/src/components/TouchControls.tsx +++ /dev/null @@ -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(".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(".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; - joystickZone: RefObject; - lookJoystickState: RefObject; - lookJoystickZone: RefObject; -}; - -/** 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 ( - <> -
e.preventDefault()} - onTouchStart={blurActiveElement} - /> -
e.preventDefault()} - onTouchStart={blurActiveElement} - /> - - ); - } - - return ( -
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(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; -} diff --git a/src/components/TouchHandler.tsx b/src/components/TouchHandler.tsx new file mode 100644 index 00000000..a829052e --- /dev/null +++ b/src/components/TouchHandler.tsx @@ -0,0 +1,218 @@ +import { useEffect, useEffectEvent, useRef } from "react"; +import { Euler, Vector3 } from "three"; +import { useFrame, useThree } from "@react-three/fiber"; +import { useControls } from "./SettingsProvider"; +import { useJoystick } from "./JoystickContext"; + +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; +}; + +/** Handles touch look and joystick-driven movement. Place inside Canvas. */ +export function TouchHandler() { + const { speedMultiplier, touchMode, invertDrag, invertJoystick } = + useControls(); + const camera = useThree((state) => state.camera); + const gl = useThree((state) => state.gl); + const { moveState, lookState } = useJoystick(); + + // Touch look state + const euler = useRef(new Euler(0, 0, 0, "YXZ")); + const lookTouchId = useRef(null); + const lastTouchPos = useRef({ x: 0, y: 0 }); + const getInvertDrag = useEffectEvent(() => invertDrag); + + // Scratch vectors + const forwardVec = useRef(new Vector3()); + const sideVec = useRef(new Vector3()); + const moveVec = useRef(new Vector3()); + + // Initialize euler from current camera rotation on mount + useEffect(() => { + euler.current.setFromQuaternion(camera.quaternion, "YXZ"); + }, [camera]); + + // 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 }; + + const dragSign = getInvertDrag() ? -1 : 1; + euler.current.setFromQuaternion(camera.quaternion, "YXZ"); + euler.current.y += dragSign * dx * LOOK_SENSITIVITY; + euler.current.x += dragSign * dy * LOOK_SENSITIVITY; + euler.current.x = Math.max( + -MAX_PITCH, + Math.min(MAX_PITCH, euler.current.x), + ); + camera.quaternion.setFromEuler(euler.current); + 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, touchMode]); + + useFrame((_state, delta) => { + const { force: moveForce, angle: moveAngle } = moveState.current; + const { force: lookForce, angle: lookAngle } = lookState.current; + + if (touchMode === "dualStick") { + // Right stick → camera rotation + if (lookForce > DUAL_LOOK_DEADZONE) { + const normalizedLookForce = + (lookForce - DUAL_LOOK_DEADZONE) / (1 - DUAL_LOOK_DEADZONE); + const lookX = Math.cos(lookAngle); + const lookY = Math.sin(lookAngle); + + const joySign = invertJoystick ? -1 : 1; + euler.current.setFromQuaternion(camera.quaternion, "YXZ"); + euler.current.y -= + joySign * + lookX * + normalizedLookForce * + STICK_LOOK_SENSITIVITY * + delta; + euler.current.x += + joySign * + lookY * + normalizedLookForce * + STICK_LOOK_SENSITIVITY * + delta; + euler.current.x = Math.max( + -MAX_PITCH, + Math.min(MAX_PITCH, euler.current.x), + ); + camera.quaternion.setFromEuler(euler.current); + } + + // Left stick → movement + if (moveForce > DUAL_MOVE_DEADZONE) { + const normalizedMoveForce = + (moveForce - DUAL_MOVE_DEADZONE) / (1 - DUAL_MOVE_DEADZONE); + const speed = BASE_SPEED * speedMultiplier * normalizedMoveForce; + const joyX = Math.cos(moveAngle); + const joyY = Math.sin(moveAngle); + + camera.getWorldDirection(forwardVec.current); + forwardVec.current.normalize(); + sideVec.current.crossVectors(camera.up, forwardVec.current).normalize(); + + moveVec.current + .set(0, 0, 0) + .addScaledVector(forwardVec.current, joyY) + .addScaledVector(sideVec.current, -joyX); + + if (moveVec.current.lengthSq() > 0) { + moveVec.current.normalize().multiplyScalar(speed * delta); + camera.position.add(moveVec.current); + } + } + } else if (touchMode === "moveLookStick") { + if (moveForce > 0) { + // Move forward at half the configured speed. + const speed = BASE_SPEED * speedMultiplier * 0.5; + camera.getWorldDirection(forwardVec.current); + forwardVec.current.normalize(); + moveVec.current.copy(forwardVec.current).multiplyScalar(speed * delta); + camera.position.add(moveVec.current); + + if (moveForce >= SINGLE_STICK_DEADZONE) { + // Outer zone: also control camera look (yaw + pitch). + const lookX = Math.cos(moveAngle); + const lookY = Math.sin(moveAngle); + const normalizedLookForce = + (moveForce - SINGLE_STICK_DEADZONE) / (1 - SINGLE_STICK_DEADZONE); + + const singleJoySign = invertJoystick ? -1 : 1; + euler.current.setFromQuaternion(camera.quaternion, "YXZ"); + euler.current.y -= + singleJoySign * + lookX * + normalizedLookForce * + STICK_LOOK_SENSITIVITY * + 0.5 * + delta; + euler.current.x += + singleJoySign * + lookY * + normalizedLookForce * + STICK_LOOK_SENSITIVITY * + 0.5 * + delta; + euler.current.x = Math.max( + -MAX_PITCH, + Math.min(MAX_PITCH, euler.current.x), + ); + camera.quaternion.setFromEuler(euler.current); + } + } + } + }); + + return null; +} diff --git a/src/components/TouchControls.module.css b/src/components/TouchJoystick.module.css similarity index 92% rename from src/components/TouchControls.module.css rename to src/components/TouchJoystick.module.css index 4e426ecf..60bceede 100644 --- a/src/components/TouchControls.module.css +++ b/src/components/TouchJoystick.module.css @@ -1,5 +1,5 @@ .Joystick { - position: fixed; + position: absolute; bottom: 20px; left: 50%; transform: translateX(-50%); diff --git a/src/components/TouchJoystick.tsx b/src/components/TouchJoystick.tsx new file mode 100644 index 00000000..e515006c --- /dev/null +++ b/src/components/TouchJoystick.tsx @@ -0,0 +1,130 @@ +import { useEffect, useState } from "react"; +import type nipplejs from "nipplejs"; +import { useControls } from "./SettingsProvider"; +import { useJoystick } from "./JoystickContext"; +import styles from "./TouchJoystick.module.css"; + +/** Apply styles to nipplejs-generated `.back` and `.front` elements imperatively. */ +function applyNippleStyles(zone: HTMLElement) { + const back = zone.querySelector(".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(".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)"; + } +} + +export function TouchJoystick() { + const { touchMode } = useControls(); + const [moveZone, setMoveZone] = useState(null); + const [lookZone, setLookZone] = useState(null); + const { moveState, lookState, setMoveState, setLookState } = useJoystick(); + + // Move joystick + useEffect(() => { + if (!moveZone) return; + + let manager: nipplejs.JoystickManager | null = null; + let cancelled = false; + + import("nipplejs").then((mod) => { + if (cancelled) return; + manager = mod.default.create({ + zone: moveZone, + mode: "static", + position: { left: "70px", bottom: "70px" }, + size: 120, + restOpacity: 0.9, + }); + + applyNippleStyles(moveZone); + + manager.on("move", (_event, data) => { + setMoveState({ + angle: data.angle.radian, + force: Math.min(1, data.force), + }); + }); + + manager.on("end", () => { + setMoveState({ force: 0 }); + }); + }); + + return () => { + cancelled = true; + manager?.destroy(); + }; + }, [moveState, moveZone, setMoveState]); + + // Look joystick (dual stick mode only) + useEffect(() => { + if (!lookZone) return; + + let manager: nipplejs.JoystickManager | null = null; + let cancelled = false; + + import("nipplejs").then((mod) => { + if (cancelled) return; + manager = mod.default.create({ + zone: lookZone, + mode: "static", + position: { right: "70px", bottom: "70px" }, + size: 120, + restOpacity: 0.9, + }); + + applyNippleStyles(lookZone); + + manager.on("move", (_event, data) => { + setLookState({ + angle: data.angle.radian, + force: Math.min(1, data.force), + }); + }); + + manager.on("end", () => { + setLookState({ force: 0 }); + }); + }); + + return () => { + cancelled = true; + manager?.destroy(); + }; + }, [lookState, lookZone, setLookState]); + + const blurActiveElement = () => { + if (document.activeElement instanceof HTMLElement) { + document.activeElement.blur(); + } + }; + + return ( + <> +
e.preventDefault()} + onTouchStart={blurActiveElement} + /> + {touchMode === "dualStick" ? ( +
e.preventDefault()} + onTouchStart={blurActiveElement} + /> + ) : null} + + ); +} diff --git a/src/components/Turret.tsx b/src/components/Turret.tsx index e6a6772b..2cabe6f8 100644 --- a/src/components/Turret.tsx +++ b/src/components/Turret.tsx @@ -1,9 +1,12 @@ import { useMemo } from "react"; import type { TorqueObject } from "../torqueScript"; +import { createLogger } from "../logger"; import { getPosition, getProperty, getRotation, getScale } from "../mission"; import { ShapeRenderer } from "./GenericShape"; import { ShapeInfoProvider } from "./ShapeInfoProvider"; import { useDatablock } from "./useDatablock"; + +const log = createLogger("Turret"); export function Turret({ object }: { object: TorqueObject }) { const datablockName = getProperty(object, "dataBlock") ?? ""; const barrelDatablockName = getProperty(object, "initialBarrel"); @@ -15,14 +18,12 @@ export function Turret({ object }: { object: TorqueObject }) { const shapeName = getProperty(datablock, "shapeFile"); const barrelShapeName = getProperty(barrelDatablock, "shapeFile"); if (!shapeName) { - console.error(` missing shape for datablock: ${datablockName}`); + log.error("Turret missing shape for datablock: %s", datablockName); } // `initialBarrel` is optional - turrets can exist without a barrel mounted. // But if we do have one, it needs a shape name. if (barrelDatablockName && !barrelShapeName) { - console.error( - ` missing shape for barrel datablock: ${barrelDatablockName}`, - ); + log.error("Turret missing shape for barrel datablock: %s", barrelDatablockName); } return ( diff --git a/src/components/VisualInput.tsx b/src/components/VisualInput.tsx new file mode 100644 index 00000000..25530823 --- /dev/null +++ b/src/components/VisualInput.tsx @@ -0,0 +1,29 @@ +import { lazy, Suspense } from "react"; +import { useTouchDevice } from "./useTouchDevice"; + +const TouchJoystick = lazy(() => + import("@/src/components/TouchJoystick").then((mod) => ({ + default: mod.TouchJoystick, + })), +); + +const KeyboardOverlay = lazy(() => + import("@/src/components/KeyboardOverlay").then((mod) => ({ + default: mod.KeyboardOverlay, + })), +); + +export function VisualInput() { + const isTouch = useTouchDevice(); + + return ( + + {isTouch ? : null} + {isTouch === false ? ( + // isTouch can be `null` before we know for sure; make sure this doesn't + // render until it's definitively false + + ) : null} + + ); +} diff --git a/src/components/WaterBlock.tsx b/src/components/WaterBlock.tsx index 7faef7c7..02ce7249 100644 --- a/src/components/WaterBlock.tsx +++ b/src/components/WaterBlock.tsx @@ -4,11 +4,16 @@ import { useFrame, useThree } from "@react-three/fiber"; import { DoubleSide, NoColorSpace, PlaneGeometry, RepeatWrapping } from "three"; import { textureToUrl } from "../loaders"; import type { SceneWaterBlock } from "../scene/types"; -import { torqueToThree, torqueScaleToThree, matrixFToQuaternion } from "../scene"; +import { + torqueToThree, + torqueScaleToThree, + matrixFToQuaternion, +} from "../scene"; import { setupTexture } from "../textureUtils"; import { createWaterMaterial } from "../waterMaterial"; import { useDebug, useSettings } from "./SettingsProvider"; import { usePositionTracker } from "./usePositionTracker"; +import { WaterBlockEntity } from "../state/gameEntityTypes"; const REP_SIZE = 2048; @@ -87,13 +92,20 @@ export function WaterMaterial({ * - Renders 9 reps (3x3 grid) centered on camera's rep */ export const WaterBlock = memo(function WaterBlock({ - scene, + entity, }: { - scene: SceneWaterBlock; + entity: WaterBlockEntity; }) { + const scene = entity.waterData; const { debugMode } = useDebug(); - const q = useMemo(() => matrixFToQuaternion(scene.transform), [scene.transform]); - const position = useMemo(() => torqueToThree(scene.transform.position), [scene.transform]); + const q = useMemo( + () => matrixFToQuaternion(scene.transform), + [scene.transform], + ); + const position = useMemo( + () => torqueToThree(scene.transform.position), + [scene.transform], + ); const scale = useMemo(() => torqueScaleToThree(scene.scale), [scene.scale]); const [scaleX, scaleY, scaleZ] = scale; const camera = useThree((state) => state.camera); diff --git a/src/components/useDatablock.ts b/src/components/useDatablock.ts index 6d04fc1c..5dd46fdc 100644 --- a/src/components/useDatablock.ts +++ b/src/components/useDatablock.ts @@ -1,5 +1,5 @@ import type { TorqueObject } from "../torqueScript"; -import { useDatablockByName } from "../state"; +import { useDatablockByName } from "../state/engineStore"; /** Look up a datablock by name from runtime state (reactive). */ export function useDatablock( diff --git a/src/components/useDistanceFromCamera.ts b/src/components/useDistanceFromCamera.ts index 676b4ceb..61cc7c3f 100644 --- a/src/components/useDistanceFromCamera.ts +++ b/src/components/useDistanceFromCamera.ts @@ -6,7 +6,7 @@ import { useWorldPosition } from "./useWorldPosition"; export function useDistanceFromCamera( ref: RefObject, ): RefObject { - const { camera } = useThree(); + const camera = useThree((state) => state.camera); const distanceRef = useRef(null); const worldPosRef = useWorldPosition(ref); diff --git a/src/components/useIflTexture.ts b/src/components/useIflTexture.ts index 28919a8e..a00ba000 100644 --- a/src/components/useIflTexture.ts +++ b/src/components/useIflTexture.ts @@ -103,10 +103,7 @@ export function updateAtlasFrame(atlas: IflAtlas, frameIndex: number) { * Find the frame index for a given time in seconds. Matches Torque's * `animateIfls()` lookup using cumulative `iflFrameOffTimes`. */ -export function getFrameIndexForTime( - atlas: IflAtlas, - seconds: number, -): number { +export function getFrameIndexForTime(atlas: IflAtlas, seconds: number): number { const dur = atlas.totalDurationSeconds; if (dur <= 0) return 0; let t = seconds; diff --git a/src/components/usePublicWindowAPI.ts b/src/components/usePublicWindowAPI.ts new file mode 100644 index 00000000..31870668 --- /dev/null +++ b/src/components/usePublicWindowAPI.ts @@ -0,0 +1,29 @@ +import { useEffect, useEffectEvent } from "react"; +import { getMissionInfo, getMissionList } from "../manifest"; +import { usePlaybackActions } from "./RecordingProvider"; + +export function usePublicWindowAPI({ onChangeMission }) { + const { setRecording } = usePlaybackActions(); + const handleChangeMission = useEffectEvent(onChangeMission); + + useEffect(() => { + // For automation, like the t2-maps app! + window.setMissionName = (missionName: string) => { + const availableMissionTypes = getMissionInfo(missionName).missionTypes; + handleChangeMission({ + missionName, + missionType: availableMissionTypes[0], + }); + }; + window.getMissionList = getMissionList; + window.getMissionInfo = getMissionInfo; + window.loadDemoRecording = setRecording; + + return () => { + delete window.setMissionName; + delete window.getMissionList; + delete window.getMissionInfo; + delete window.loadDemoRecording; + }; + }, [setRecording]); +} diff --git a/src/components/useQueryParams.ts b/src/components/useQueryParams.ts new file mode 100644 index 00000000..5fcb6794 --- /dev/null +++ b/src/components/useQueryParams.ts @@ -0,0 +1,50 @@ +import { createParser, parseAsBoolean, useQueryState } from "nuqs"; +import { getMissionInfo } from "../manifest"; + +export type CurrentMission = { + missionName: string; + missionType?: string; +}; + +const defaultMission: CurrentMission = { + missionName: "RiverDance", + missionType: "CTF", +}; + +const parseAsMissionWithType = createParser({ + 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 (!missionType || availableMissionTypes.length === 1) { + return missionName; + } + return `${missionName}~${missionType}`; + }, + eq(a, b) { + return a.missionName === b.missionName && a.missionType === b.missionType; + }, +}).withDefault(defaultMission); + +export function useMissionQueryState() { + const [currentMission, setCurrentMission] = useQueryState( + "mission", + parseAsMissionWithType, + ); + return [currentMission, setCurrentMission] as const; +} + +export function useFogQueryState() { + const [fogEnabledOverride, setFogEnabledOverride] = useQueryState( + "fog", + parseAsBoolean, + ); + return [fogEnabledOverride, setFogEnabledOverride] as const; +} diff --git a/src/components/useSceneObject.ts b/src/components/useSceneObject.ts index 80e1ea05..e734c438 100644 --- a/src/components/useSceneObject.ts +++ b/src/components/useSceneObject.ts @@ -1,5 +1,5 @@ import type { TorqueObject } from "../torqueScript"; -import { useRuntimeObjectByName } from "../state"; +import { useRuntimeObjectByName } from "../state/engineStore"; /** * Look up a scene object by name from the runtime. diff --git a/src/loaders.ts b/src/loaders.ts index 0451c26a..976189cd 100644 --- a/src/loaders.ts +++ b/src/loaders.ts @@ -1,4 +1,5 @@ import { parseImageFileList } from "./imageFileList"; +import { createLogger } from "./logger"; import { getActualResourceKey, getMissionInfo, @@ -9,6 +10,8 @@ import { parseMissionScript } from "./mission"; import { normalizePath } from "./stringUtils"; import { parseTerrainBuffer, type TerrainFile } from "./terrain"; +const log = createLogger("loaders"); + export type { TerrainFile }; export const BASE_URL = "/t2-mapper"; @@ -21,9 +24,7 @@ export function getUrlForPath(resourcePath: string, fallbackUrl?: string) { resourceKey = getActualResourceKey(resourcePath); } catch (err) { if (fallbackUrl) { - console.warn( - `Resource "${resourcePath}" not found - rendering fallback.`, - ); + log.warn("Resource \"%s\" not found — rendering fallback", resourcePath); return fallbackUrl; } else { throw err; @@ -108,8 +109,16 @@ export async function loadMission(name: string) { } export async function loadTerrain(fileName: string) { - const res = await fetch(getUrlForPath(`terrains/${fileName}`)); + const url = getUrlForPath(`terrains/${fileName}`); + log.debug("Fetching terrain: %s", url); + const res = await fetch(url); + if (!res.ok) { + throw new Error( + `[loadTerrain] Failed to fetch ${url}: ${res.status} ${res.statusText}`, + ); + } const terrainBuffer = await res.arrayBuffer(); + log.debug("Loaded terrain %s: %d bytes", fileName, terrainBuffer.byteLength); return parseTerrainBuffer(terrainBuffer); } diff --git a/src/logger.ts b/src/logger.ts new file mode 100644 index 00000000..d207cb4e --- /dev/null +++ b/src/logger.ts @@ -0,0 +1,88 @@ +import pino from "pino"; + +/** + * Module-scoped browser logging via pino. + * + * Control via NEXT_PUBLIC_LOG (comma-separated): + * "debug" → all modules at debug + * "liveStreaming:debug,DebugSuspense:trace" → those modules only, rest silent + * "debug,liveStreaming:trace" → all at debug, liveStreaming at trace + * + * Bare level names (no colon) set the global default. Entries with colons + * set per-module overrides. If no global level is specified but module + * overrides exist, unlisted modules default to silent. + * + * Unset or empty → all modules default to "info". + * + * At runtime: `logger.level = "debug"` on any module logger. + */ + +const PINO_LEVELS = new Set([ + "trace", + "debug", + "info", + "warn", + "error", + "fatal", + "silent", +]); + +/** Parse NEXT_PUBLIC_LOG into a global default and per-module overrides. */ +function parseLogConfig(): { + globalLevel: string; + modules: Map; +} { + const raw = process.env.NEXT_PUBLIC_LOG?.trim(); + if (!raw) return { globalLevel: "info", modules: new Map() }; + + let globalLevel: string | null = null; + const modules = new Map(); + + for (const entry of raw.split(",")) { + const trimmed = entry.trim(); + if (!trimmed) continue; + if (trimmed.includes(":")) { + const [name, level] = trimmed.split(":"); + if (name && level) modules.set(name, level); + } else if (PINO_LEVELS.has(trimmed)) { + globalLevel = trimmed; + } + } + + // If only module overrides were given, default the rest to silent. + globalLevel ??= modules.size > 0 ? "silent" : "info"; + + return { globalLevel, modules }; +} + +const { globalLevel, modules: moduleLevels } = parseLogConfig(); + +// Map pino numeric levels → console methods. +const LEVEL_TO_CONSOLE: Record = { + 10: "debug", // trace + 20: "debug", + 30: "log", // info + 40: "warn", + 50: "error", + 60: "error", // fatal +}; + +/** Custom write function so pino formats the message (resolving %s etc.) + * before we output it. Setting `write` implies `asObject: true`. */ +function write(o: { level: number; module?: string; msg: string }) { + const method = LEVEL_TO_CONSOLE[o.level] ?? "log"; + const prefix = o.module ? `[${o.module}]` : "[t2-mapper]"; + console[method](prefix, o.msg); +} + +export const rootLogger = pino({ + name: "t2-mapper", + level: "trace", // allow children to go as low as they want + browser: { write }, +}); + +/** Create a named child logger. */ +export function createLogger(name: string): pino.Logger { + const level = moduleLevels.get(name) ?? globalLevel; + return rootLogger.child({ module: name }, { level }); +} diff --git a/src/manifest.ts b/src/manifest.ts index b99c414c..03b49883 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -140,6 +140,10 @@ export function getMissionInfo(missionName: string) { return missionInfo; } +export function hasMission(missionName: string): boolean { + return missionName in manifest.missions; +} + export function getMissionList() { return Object.keys(manifest.missions); } diff --git a/src/mission.ts b/src/mission.ts index c3ec1e35..c63837cb 100644 --- a/src/mission.ts +++ b/src/mission.ts @@ -19,7 +19,7 @@ interface CommentSection { comments: string[]; } -const normalizedMissionTypes = { +export const normalizedMissionTypes = { arena: "Arena", bounty: "Bounty", cnh: "CnH", diff --git a/src/particles/ParticleSystem.ts b/src/particles/ParticleSystem.ts index 14323a07..347eaaf5 100644 --- a/src/particles/ParticleSystem.ts +++ b/src/particles/ParticleSystem.ts @@ -67,8 +67,8 @@ export function resolveParticleData( b: k.b ?? 1, a: k.a ?? 1, // V12 packs size as size/MaxParticleSize; parser returns [0,1]. - size: (k.size ?? (1 / MAX_PARTICLE_SIZE)) * MAX_PARTICLE_SIZE, - time: i === 0 ? 0 : k.time ?? 1, + size: (k.size ?? 1 / MAX_PARTICLE_SIZE) * MAX_PARTICLE_SIZE, + time: i === 0 ? 0 : (k.time ?? 1), }); } } @@ -98,7 +98,8 @@ export function resolveParticleData( inheritedVelFactor: getNumber(raw, "inheritedVelFactor", 0), constantAcceleration: getNumber(raw, "constantAcceleration", 0), lifetimeMS: getNumber(raw, "lifetimeMS", 31) << LIFETIME_SHIFT, - lifetimeVarianceMS: getNumber(raw, "lifetimeVarianceMS", 0) << LIFETIME_SHIFT, + lifetimeVarianceMS: + getNumber(raw, "lifetimeVarianceMS", 0) << LIFETIME_SHIFT, spinSpeed: getNumber(raw, "spinSpeed", 0), // V12 packs spinRandom as value+1000; parser returns raw integer. spinRandomMin: getNumber(raw, "spinRandomMin", 1000) + SPIN_RANDOM_OFFSET, @@ -142,7 +143,8 @@ export function resolveEmitterData( orientParticles: getBool(raw, "orientParticles", false), orientOnVelocity: getBool(raw, "orientOnVelocity", true), lifetimeMS: getNumber(raw, "lifetimeMS", 0) << LIFETIME_SHIFT, - lifetimeVarianceMS: getNumber(raw, "lifetimeVarianceMS", 0) << LIFETIME_SHIFT, + lifetimeVarianceMS: + getNumber(raw, "lifetimeVarianceMS", 0) << LIFETIME_SHIFT, particles: resolveParticleData(particleRaw), }; } @@ -268,7 +270,11 @@ export class EmitterInstance { count: number, axis: [number, number, number] = [0, 0, 1], ): void { - for (let i = 0; i < count && this.particles.length < this.maxParticles; i++) { + for ( + let i = 0; + i < count && this.particles.length < this.maxParticles; + i++ + ) { this.addParticle(pos, axis); } } @@ -322,10 +328,7 @@ export class EmitterInstance { this.emitterAge += dtMS; // Check emitter lifetime (V12 uses strictly greater). - if ( - this.emitterLifetime > 0 && - this.emitterAge > this.emitterLifetime - ) { + if (this.emitterLifetime > 0 && this.emitterAge > this.emitterLifetime) { this.emitterDead = true; } @@ -408,13 +411,21 @@ export class EmitterInstance { // Rotate axis by theta around axisx, then by phi around original axis. [ejX, ejY, ejZ] = rotateAroundAxis( - ejX, ejY, ejZ, - axisx[0], axisx[1], axisx[2], + ejX, + ejY, + ejZ, + axisx[0], + axisx[1], + axisx[2], theta, ); [ejX, ejY, ejZ] = rotateAroundAxis( - ejX, ejY, ejZ, - axis[0], axis[1], axis[2], + ejX, + ejY, + ejZ, + axis[0], + axis[1], + axis[2], phi, ); diff --git a/src/scene/coordinates.spec.ts b/src/scene/coordinates.spec.ts index 167c4a85..d52fdcdf 100644 --- a/src/scene/coordinates.spec.ts +++ b/src/scene/coordinates.spec.ts @@ -42,12 +42,7 @@ describe("matrixFToQuaternion", () => { const c = Math.cos(angleRad); const s = Math.sin(angleRad); - const elements = [ - c, s, 0, 0, - -s, c, 0, 0, - 0, 0, 1, 0, - 0, 0, 0, 1, - ]; + const elements = [c, s, 0, 0, -s, c, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; const m: MatrixF = { elements, position: { x: 0, y: 0, z: 0 } }; const q = matrixFToQuaternion(m); @@ -69,12 +64,7 @@ describe("matrixFToQuaternion", () => { const c = Math.cos(angleRad); const s = Math.sin(angleRad); - const elements = [ - 1, 0, 0, 0, - 0, c, s, 0, - 0, -s, c, 0, - 0, 0, 0, 1, - ]; + const elements = [1, 0, 0, 0, 0, c, s, 0, 0, -s, c, 0, 0, 0, 0, 1]; const m: MatrixF = { elements, position: { x: 0, y: 0, z: 0 } }; const q = matrixFToQuaternion(m); @@ -96,12 +86,7 @@ describe("matrixFToQuaternion", () => { const c = Math.cos(angleRad); const s = Math.sin(angleRad); - const elements = [ - c, 0, -s, 0, - 0, 1, 0, 0, - s, 0, c, 0, - 0, 0, 0, 1, - ]; + const elements = [c, 0, -s, 0, 0, 1, 0, 0, s, 0, c, 0, 0, 0, 0, 1]; const m: MatrixF = { elements, position: { x: 0, y: 0, z: 0 } }; const q = matrixFToQuaternion(m); @@ -120,7 +105,9 @@ describe("matrixFToQuaternion", () => { it("produces unit quaternion from valid rotation matrix", () => { // Arbitrary rotation: 45° around Torque (1,1,0) normalized const len = Math.sqrt(2); - const nx = 1 / len, ny = 1 / len, nz = 0; + const nx = 1 / len, + ny = 1 / len, + nz = 0; const angle = Math.PI / 4; const c = Math.cos(angle); const s = Math.sin(angle); @@ -184,17 +171,15 @@ describe("torqueAxisAngleToQuaternion", () => { it("agrees with matrixFToQuaternion for same rotation", () => { // 60° around Torque Z-axis // Both functions should produce the same quaternion. - const ax = 0, ay = 0, az = 1, angleDeg = 60; + const ax = 0, + ay = 0, + az = 1, + angleDeg = 60; const angleRad = angleDeg * (Math.PI / 180); const c = Math.cos(angleRad); const s = Math.sin(angleRad); - const elements = [ - c, s, 0, 0, - -s, c, 0, 0, - 0, 0, 1, 0, - 0, 0, 0, 1, - ]; + const elements = [c, s, 0, 0, -s, c, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; const m: MatrixF = { elements, position: { x: 0, y: 0, z: 0 } }; const qFromMatrix = matrixFToQuaternion(m); diff --git a/src/scene/coordinates.ts b/src/scene/coordinates.ts index 59225215..e119f6ef 100644 --- a/src/scene/coordinates.ts +++ b/src/scene/coordinates.ts @@ -103,7 +103,9 @@ export function torqueAxisAngleToQuaternion( const threeAx = ay; const threeAy = az; const threeAz = ax; - const len = Math.sqrt(threeAx * threeAx + threeAy * threeAy + threeAz * threeAz); + const len = Math.sqrt( + threeAx * threeAx + threeAy * threeAy + threeAz * threeAz, + ); if (len < 1e-8) return new Quaternion(); const angleRad = -angleDeg * (Math.PI / 180); return new Quaternion().setFromAxisAngle( diff --git a/src/scene/crossValidation.spec.ts b/src/scene/crossValidation.spec.ts index 15014b7a..5d25f5d1 100644 --- a/src/scene/crossValidation.spec.ts +++ b/src/scene/crossValidation.spec.ts @@ -90,12 +90,7 @@ describe("misToScene ↔ ghostToScene cross-validation", () => { // Build the same matrix manually: 90° around Z const c = Math.cos(Math.PI / 2); const s = Math.sin(Math.PI / 2); - const elements = [ - c, s, 0, 0, - -s, c, 0, 0, - 0, 0, 1, 0, - 0, 0, 0, 1, - ]; + const elements = [c, s, 0, 0, -s, c, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; const ghostResult = interiorFromGhost(42, { interiorFile: "building.dif", @@ -132,7 +127,9 @@ describe("misToScene ↔ ghostToScene cross-validation", () => { expect(misResult.shapeName).toBe(ghostResult.shapeName); expect(misResult.scale).toEqual(ghostResult.scale); - expect(misResult.transform.position).toEqual(ghostResult.transform.position); + expect(misResult.transform.position).toEqual( + ghostResult.transform.position, + ); }); it("Sky: fog and cloud data match", () => { diff --git a/src/scene/ghostToScene.spec.ts b/src/scene/ghostToScene.spec.ts index 2b999f2a..b3ce61a6 100644 --- a/src/scene/ghostToScene.spec.ts +++ b/src/scene/ghostToScene.spec.ts @@ -71,7 +71,12 @@ describe("skyFromGhost", () => { skySolidColor: { r: 0.1, g: 0.2, b: 0.3 }, useSkyTextures: true, fogVolumes: [ - { visibleDistance: 500, minHeight: 0, maxHeight: 300, color: { r: 0.5, g: 0.5, b: 0.5 } }, + { + visibleDistance: 500, + minHeight: 0, + maxHeight: 300, + color: { r: 0.5, g: 0.5, b: 0.5 }, + }, ], cloudLayers: [ { texture: "cloud1.png", heightPercent: 0.35, speed: 0.001 }, diff --git a/src/scene/ghostToScene.ts b/src/scene/ghostToScene.ts index dc22d1ee..7e9eea75 100644 --- a/src/scene/ghostToScene.ts +++ b/src/scene/ghostToScene.ts @@ -12,6 +12,9 @@ import type { Color3, Color4, } from "./types"; +import { createLogger } from "../logger"; + +const log = createLogger("ghostToScene"); type GhostData = Record; @@ -43,26 +46,39 @@ function matrixF(v: unknown): MatrixF { return v as MatrixF; } // readAffineTransform() returns {position, rotation} — convert to MatrixF. - if ( - v && - typeof v === "object" && - "position" in v && - "rotation" in v - ) { + if (v && typeof v === "object" && "position" in v && "rotation" in v) { const { position: pos, rotation: q } = v as { position: { x: number; y: number; z: number }; rotation: { x: number; y: number; z: number; w: number }; }; // Quaternion to column-major 4×4 matrix (idx = row + col*4). - const xx = q.x * q.x, yy = q.y * q.y, zz = q.z * q.z; - const xy = q.x * q.y, xz = q.x * q.z, yz = q.y * q.z; - const wx = q.w * q.x, wy = q.w * q.y, wz = q.w * q.z; + const xx = q.x * q.x, + yy = q.y * q.y, + zz = q.z * q.z; + const xy = q.x * q.y, + xz = q.x * q.z, + yz = q.y * q.z; + const wx = q.w * q.x, + wy = q.w * q.y, + wz = q.w * q.z; return { elements: [ - 1 - 2 * (yy + zz), 2 * (xy + wz), 2 * (xz - wy), 0, - 2 * (xy - wz), 1 - 2 * (xx + zz), 2 * (yz + wx), 0, - 2 * (xz + wy), 2 * (yz - wx), 1 - 2 * (xx + yy), 0, - pos.x, pos.y, pos.z, 1, + 1 - 2 * (yy + zz), + 2 * (xy + wz), + 2 * (xz - wy), + 0, + 2 * (xy - wz), + 1 - 2 * (xx + zz), + 2 * (yz + wx), + 0, + 2 * (xz + wy), + 2 * (yz - wx), + 1 - 2 * (xx + yy), + 0, + pos.x, + pos.y, + pos.z, + 1, ], position: { x: pos.x, y: pos.y, z: pos.z }, }; @@ -118,12 +134,14 @@ export function tsStaticFromGhost( export function skyFromGhost(ghostIndex: number, data: GhostData): SceneSky { const fogVolumes = Array.isArray(data.fogVolumes) - ? (data.fogVolumes as Array<{ - visibleDistance?: number; - minHeight?: number; - maxHeight?: number; - color?: Color3; - }>).map((v) => ({ + ? ( + data.fogVolumes as Array<{ + visibleDistance?: number; + minHeight?: number; + maxHeight?: number; + color?: Color3; + }> + ).map((v) => ({ visibleDistance: v.visibleDistance ?? 0, minHeight: v.minHeight ?? 0, maxHeight: v.maxHeight ?? 0, @@ -132,11 +150,13 @@ export function skyFromGhost(ghostIndex: number, data: GhostData): SceneSky { : []; const cloudLayers = Array.isArray(data.cloudLayers) - ? (data.cloudLayers as Array<{ - texture?: string; - heightPercent?: number; - speed?: number; - }>).map((c) => ({ + ? ( + data.cloudLayers as Array<{ + texture?: string; + heightPercent?: number; + speed?: number; + }> + ).map((c) => ({ texture: c.texture ?? "", heightPercent: c.heightPercent ?? 0, speed: c.speed ?? 0, @@ -210,17 +230,41 @@ export function ghostToSceneObject( ghostIndex: number, data: GhostData, ): SceneObject | null { + let result: SceneObject | null; switch (className) { case "TerrainBlock": - return terrainFromGhost(ghostIndex, data); + result = terrainFromGhost(ghostIndex, data); + log.debug("TerrainBlock #%d: terrFileName=%s", ghostIndex, (result as SceneTerrainBlock).terrFileName); + return result; case "InteriorInstance": - return interiorFromGhost(ghostIndex, data); + result = interiorFromGhost(ghostIndex, data); + log.debug("InteriorInstance #%d: interiorFile=%s", ghostIndex, (result as SceneInteriorInstance).interiorFile); + return result; case "TSStatic": return tsStaticFromGhost(ghostIndex, data); - case "Sky": - return skyFromGhost(ghostIndex, data); - case "Sun": - return sunFromGhost(ghostIndex, data); + case "Sky": { + result = skyFromGhost(ghostIndex, data); + const sky = result as SceneSky; + log.debug( + "Sky #%d: materialList=%s fogColor=(%s, %s, %s) visibleDist=%d fogDist=%d useSkyTextures=%s", + ghostIndex, sky.materialList, + sky.fogColor.r.toFixed(3), sky.fogColor.g.toFixed(3), sky.fogColor.b.toFixed(3), + sky.visibleDistance, sky.fogDistance, sky.useSkyTextures, + ); + return result; + } + case "Sun": { + result = sunFromGhost(ghostIndex, data); + const sun = result as SceneSun; + log.debug( + "Sun #%d: dir=(%s, %s, %s) color=(%s, %s, %s) ambient=(%s, %s, %s)", + ghostIndex, + sun.direction.x.toFixed(3), sun.direction.y.toFixed(3), sun.direction.z.toFixed(3), + sun.color.r.toFixed(3), sun.color.g.toFixed(3), sun.color.b.toFixed(3), + sun.ambient.r.toFixed(3), sun.ambient.g.toFixed(3), sun.ambient.b.toFixed(3), + ); + return result; + } case "MissionArea": return missionAreaFromGhost(ghostIndex, data); case "WaterBlock": diff --git a/src/scene/misToScene.spec.ts b/src/scene/misToScene.spec.ts index a103e965..2c2daa18 100644 --- a/src/scene/misToScene.spec.ts +++ b/src/scene/misToScene.spec.ts @@ -162,8 +162,8 @@ describe("skyFromMis", () => { cloudText1: "cloud1.png", cloudText2: "cloud2.png", cloudText3: "", - "cloudheightper0": "0.35", - "cloudheightper1": "0.25", + cloudheightper0: "0.35", + cloudheightper1: "0.25", cloudSpeed1: "0.001", }); const result = skyFromMis(obj); diff --git a/src/scene/misToScene.ts b/src/scene/misToScene.ts index 76200d88..f2e35d07 100644 --- a/src/scene/misToScene.ts +++ b/src/scene/misToScene.ts @@ -41,7 +41,10 @@ function propInt(obj: TorqueObject, name: string): number | undefined { return Number.isFinite(n) ? n : undefined; } -function parseVec3(s: string | undefined, fallback: Vec3 = { x: 0, y: 0, z: 0 }): Vec3 { +function parseVec3( + s: string | undefined, + fallback: Vec3 = { x: 0, y: 0, z: 0 }, +): Vec3 { if (!s) return fallback; const parts = s.split(" ").map(Number); return { @@ -51,7 +54,10 @@ function parseVec3(s: string | undefined, fallback: Vec3 = { x: 0, y: 0, z: 0 }) }; } -function parseColor3(s: string | undefined, fallback: Color3 = { r: 0, g: 0, b: 0 }): Color3 { +function parseColor3( + s: string | undefined, + fallback: Color3 = { r: 0, g: 0, b: 0 }, +): Color3 { if (!s) return fallback; const parts = s.split(" ").map(Number); return { @@ -93,7 +99,9 @@ function buildMatrixF( // Normalize axis const len = Math.sqrt(ax * ax + ay * ay + az * az); - let nx = 0, ny = 0, nz = 1; + let nx = 0, + ny = 0, + nz = 1; if (len > 1e-8) { nx = ax / len; ny = ay / len; @@ -191,8 +199,12 @@ export function skyFromMis(obj: TorqueObject): SceneSky { const cloudLayers: SceneSkyCloudLayer[] = []; for (let i = 0; i < 3; i++) { const texture = prop(obj, `cloudText${i + 1}`) ?? ""; - const heightPercent = propFloat(obj, `cloudHeightPer[${i}]`) ?? propFloat(obj, `cloudheightper${i}`) ?? [0.35, 0.25, 0.2][i]; - const speed = propFloat(obj, `cloudSpeed${i + 1}`) ?? [0.0001, 0.0002, 0.0003][i]; + const heightPercent = + propFloat(obj, `cloudHeightPer[${i}]`) ?? + propFloat(obj, `cloudheightper${i}`) ?? + [0.35, 0.25, 0.2][i]; + const speed = + propFloat(obj, `cloudSpeed${i + 1}`) ?? [0.0001, 0.0002, 0.0003][i]; cloudLayers.push({ texture, heightPercent, speed }); } diff --git a/src/state/engineStore.ts b/src/state/engineStore.ts index 7f74a5c6..1d98c0d8 100644 --- a/src/state/engineStore.ts +++ b/src/state/engineStore.ts @@ -42,7 +42,10 @@ export interface EngineStoreState { playback: PlaybackSliceState; setRuntime(runtime: TorqueRuntime): void; clearRuntime(): void; - applyRuntimeBatch(events: RuntimeMutationEvent[], tickInfo?: RuntimeTickInfo): void; + applyRuntimeBatch( + events: RuntimeMutationEvent[], + tickInfo?: RuntimeTickInfo, + ): void; setRecording(recording: StreamRecording | null): void; setPlaybackTime(ms: number): void; setPlaybackStatus(status: PlaybackStatus): void; @@ -65,7 +68,9 @@ function clamp(value: number, min: number, max: number): number { return value; } -function buildRuntimeIndexes(runtime: TorqueRuntime): Pick< +function buildRuntimeIndexes( + runtime: TorqueRuntime, +): Pick< RuntimeSliceState, | "objectVersionById" | "globalVersionByName" @@ -165,7 +170,10 @@ export const engineStore = createStore()( })); }, - applyRuntimeBatch(events: RuntimeMutationEvent[], tickInfo?: RuntimeTickInfo) { + applyRuntimeBatch( + events: RuntimeMutationEvent[], + tickInfo?: RuntimeTickInfo, + ) { if (events.length === 0) { return; } @@ -249,11 +257,14 @@ export const engineStore = createStore()( ...state, playback: { recording, - status: "stopped", - timeMs: 0, - rate: 1, + status: recording ? "stopped" : state.playback.status, + timeMs: recording ? 0 : state.playback.timeMs, + rate: recording ? 1 : state.playback.rate, durationMs, - streamSnapshot: null, + // Preserve the last snapshot so HUD/chat persist after unload. + streamSnapshot: recording + ? null + : state.playback.streamSnapshot, }, })); }, @@ -301,7 +312,6 @@ export const engineStore = createStore()( }, })); }, - })), ); @@ -475,4 +485,3 @@ export function useRuntimeChildIds( return parent._children.map((child) => child._id); } - diff --git a/src/state/gameEntityStore.ts b/src/state/gameEntityStore.ts index f3df2c7c..b1ccca8e 100644 --- a/src/state/gameEntityStore.ts +++ b/src/state/gameEntityStore.ts @@ -1,6 +1,9 @@ import { createStore } from "zustand/vanilla"; import { useStoreWithEqualityFn } from "zustand/traditional"; import type { GameEntity, RenderType } from "./gameEntityTypes"; +import { normalizedMissionTypes } from "../mission"; + +export type DataSource = "map" | "demo" | "live"; export interface GameEntityState { /** @@ -16,6 +19,24 @@ export interface GameEntityState { streamEntities: Map; /** True when a demo/live source is actively driving entity state. */ isStreaming: boolean; + /** Which data source is currently populating entities, or null if empty. */ + dataSource: DataSource | null; + /** Mission slug (e.g. "ScarabRae") — the $MissionName / $CurrentMission value. */ + missionName: string | null; + /** Mission type short code (e.g. "CTF"), as used in .mis MissionTypes. */ + missionType: string | null; + /** Mission type display name (e.g. "Capture the Flag"), from MsgMissionDropInfo. */ + missionTypeDisplayName: string | null; + /** Mission display name (e.g. "Scarabrae"), from MsgMissionDropInfo. */ + missionDisplayName: string | null; + /** Game class name (e.g. "CTFGame"), from MsgClientReady. */ + gameClassName: string | null; + /** Server display name. */ + serverDisplayName: string | null; + /** Name of the player who recorded the demo / connected to the server. */ + recorderName: string | null; + /** Recording date string from readplayerinfo (e.g. "May-4-2025 10:37PM"). */ + recordingDate: string | null; /** Monotonically increasing version counter, bumped on any mutation. */ version: number; @@ -26,9 +47,21 @@ export interface GameEntityState { setAllEntities(entities: GameEntity[]): void; clearEntities(): void; + /** Update mission info fields. Pass null to clear a field, omit to leave unchanged. */ + setMissionInfo(info: { + missionName?: string | null; + missionType?: string | null; + missionTypeDisplayName?: string | null; + missionDisplayName?: string | null; + gameClassName?: string | null; + serverDisplayName?: string | null; + recorderName?: string | null; + recordingDate?: string | null; + }): void; + // ── Stream entity mutations ── /** Begin streaming mode. Stream entities will be rendered instead of mission entities. */ - beginStreaming(): void; + beginStreaming(source: "demo" | "live"): void; /** End streaming mode and clear stream entities. Mission entities become active again. */ endStreaming(): void; setStreamEntity(entity: GameEntity): void; @@ -42,6 +75,15 @@ export const gameEntityStore = createStore()((set) => ({ missionEntities: new Map(), streamEntities: new Map(), isStreaming: false, + dataSource: null, + missionName: null, + missionType: null, + missionTypeDisplayName: null, + missionDisplayName: null, + gameClassName: null, + serverDisplayName: null, + recorderName: null, + recordingDate: null, version: 0, // ── Mission entity mutations ── @@ -74,35 +116,112 @@ export const gameEntityStore = createStore()((set) => ({ }, setAllEntities(entities: GameEntity[]) { - set(() => { + set((state) => { const next = new Map(); for (const entity of entities) { next.set(entity.id, entity); } - return { missionEntities: next }; + return { + missionEntities: next, + dataSource: state.isStreaming ? state.dataSource : "map", + }; }); }, clearEntities() { set((state) => { if (state.missionEntities.size === 0) return state; - return { missionEntities: new Map(), version: state.version + 1 }; + // When streaming is active, only clear mission entities — don't + // touch dataSource or metadata, those belong to the stream. + if (state.isStreaming) { + return { + missionEntities: new Map(), + version: state.version + 1, + }; + } + return { + missionEntities: new Map(), + dataSource: null, + missionName: null, + missionType: null, + missionTypeDisplayName: null, + missionDisplayName: null, + gameClassName: null, + serverDisplayName: null, + recorderName: null, + recordingDate: null, + version: state.version + 1, + }; }); }, + setMissionInfo(info) { + const updates: Partial = {}; + if (info.missionName !== undefined) updates.missionName = info.missionName; + if (info.missionType !== undefined) updates.missionType = info.missionType; + if (info.missionTypeDisplayName !== undefined) + updates.missionTypeDisplayName = info.missionTypeDisplayName; + if (info.missionDisplayName !== undefined) + updates.missionDisplayName = info.missionDisplayName; + if (info.gameClassName !== undefined) { + updates.gameClassName = info.gameClassName; + // Derive missionType from gameClassName (e.g. "CTFGame" → "CTF") + // unless missionType was explicitly provided. + if (info.missionType === undefined) { + if (info.gameClassName) { + const raw = info.gameClassName.replace(/Game$/i, ""); + updates.missionType = + normalizedMissionTypes[raw.toLowerCase()] ?? raw; + } else { + updates.missionType = null; + } + } + } + if (info.serverDisplayName !== undefined) + updates.serverDisplayName = info.serverDisplayName; + if (info.recorderName !== undefined) + updates.recorderName = info.recorderName; + if (info.recordingDate !== undefined) + updates.recordingDate = info.recordingDate; + set((state) => ({ ...updates, version: state.version + 1 })); + }, + // ── Stream entity mutations ── - beginStreaming() { - set((state) => { - if (state.isStreaming) return state; - return { isStreaming: true, streamEntities: new Map(), version: state.version + 1 }; - }); + beginStreaming(source: "demo" | "live") { + set((state) => ({ + isStreaming: true, + dataSource: source, + streamEntities: new Map(), + missionName: null, + missionType: null, + missionTypeDisplayName: null, + missionDisplayName: null, + gameClassName: null, + serverDisplayName: null, + recorderName: null, + recordingDate: null, + version: state.version + 1, + })); }, endStreaming() { set((state) => { if (!state.isStreaming) return state; - return { isStreaming: false, streamEntities: new Map(), version: state.version + 1 }; + return { + isStreaming: false, + dataSource: state.missionEntities.size > 0 ? "map" : null, + missionName: null, + missionType: null, + missionTypeDisplayName: null, + missionDisplayName: null, + gameClassName: null, + serverDisplayName: null, + recorderName: null, + recordingDate: null, + streamEntities: new Map(), + version: state.version + 1, + }; }); }, @@ -135,17 +254,17 @@ export const gameEntityStore = createStore()((set) => ({ setAllStreamEntities(entities: GameEntity[]) { set((state) => { - const prev = state.streamEntities; const next = new Map(); for (const entity of entities) { next.set(entity.id, entity); } - // Only update (and bump version) when the entity set changed - // (adds/removes). Render-field-only updates (threads, colors, etc.) - // are applied via mutateStreamEntities below instead. This prevents - // frequent Zustand set() calls from starving React Suspense. - if (next.size === prev.size && [...next.keys()].every((id) => prev.has(id))) { - return state; // same set — no store update at all + // Skip store update if the entity key set is unchanged. + const prev = state.streamEntities; + if ( + next.size === prev.size && + [...next.keys()].every((id) => prev.has(id)) + ) { + return state; } return { streamEntities: next, version: state.version + 1 }; }); @@ -221,7 +340,9 @@ export function useAllGameEntities(): GameEntity[] { } /** Hook returning entities filtered by render type. */ -export function useGameEntitiesByRenderType(renderType: RenderType): GameEntity[] { +export function useGameEntitiesByRenderType( + renderType: RenderType, +): GameEntity[] { const entities = useGameEntitiesInternal(); const result: GameEntity[] = []; for (const entity of entities.values()) { @@ -248,18 +369,16 @@ export function useGameEntity(id: string): GameEntity | undefined { // ── Scene infrastructure queries ── -import type { - SceneSky, - SceneSun, - SceneMissionArea, -} from "../scene/types"; +import type { SceneSky, SceneSun, SceneMissionArea } from "../scene/types"; // Scene infrastructure selectors use Object.is equality (default) on the // extracted data object — these are set once and referentially stable, so // the hooks won't re-render when unrelated (dynamic) entities update. function selectSkyData(state: GameEntityState): SceneSky | null { - const entities = state.isStreaming ? state.streamEntities : state.missionEntities; + const entities = state.isStreaming + ? state.streamEntities + : state.missionEntities; for (const e of entities.values()) { if (e.renderType === "Sky") return e.skyData; } @@ -267,15 +386,21 @@ function selectSkyData(state: GameEntityState): SceneSky | null { } function selectSunData(state: GameEntityState): SceneSun | null { - const entities = state.isStreaming ? state.streamEntities : state.missionEntities; + const entities = state.isStreaming + ? state.streamEntities + : state.missionEntities; for (const e of entities.values()) { if (e.renderType === "Sun") return e.sunData; } return null; } -function selectMissionAreaData(state: GameEntityState): SceneMissionArea | null { - const entities = state.isStreaming ? state.streamEntities : state.missionEntities; +function selectMissionAreaData( + state: GameEntityState, +): SceneMissionArea | null { + const entities = state.isStreaming + ? state.streamEntities + : state.missionEntities; for (const e of entities.values()) { if (e.renderType === "MissionArea") return e.missionAreaData; } @@ -296,3 +421,75 @@ export function useSceneSun(): SceneSun | null { export function useSceneMissionArea(): SceneMissionArea | null { return useStoreWithEqualityFn(gameEntityStore, selectMissionAreaData); } + +/** Hook returning which data source is currently populating entities. */ +export function useDataSource(): DataSource | null { + return useStoreWithEqualityFn( + gameEntityStore, + (state) => state.dataSource, + ); +} + +/** Hook returning the current mission name. */ +export function useMissionName(): string | null { + return useStoreWithEqualityFn( + gameEntityStore, + (state) => state.missionName, + ); +} + +/** Hook returning the mission type short code (e.g. "CTF"). */ +export function useMissionType(): string | null { + return useStoreWithEqualityFn( + gameEntityStore, + (state) => state.missionType, + ); +} + +/** Hook returning the mission type display name (e.g. "Capture the Flag"). */ +export function useMissionTypeDisplayName(): string | null { + return useStoreWithEqualityFn( + gameEntityStore, + (state) => state.missionTypeDisplayName, + ); +} + +/** Hook returning the mission display name (e.g. "Scarabrae"). */ +export function useMissionDisplayName(): string | null { + return useStoreWithEqualityFn( + gameEntityStore, + (state) => state.missionDisplayName, + ); +} + +/** Hook returning the game class name (e.g. "CTFGame"). */ +export function useGameClassName(): string | null { + return useStoreWithEqualityFn( + gameEntityStore, + (state) => state.gameClassName, + ); +} + +/** Hook returning the server display name. */ +export function useServerDisplayName(): string | null { + return useStoreWithEqualityFn( + gameEntityStore, + (state) => state.serverDisplayName, + ); +} + +/** Hook returning the name of the player who recorded the demo / connected. */ +export function useRecorderName(): string | null { + return useStoreWithEqualityFn( + gameEntityStore, + (state) => state.recorderName, + ); +} + +/** Hook returning the demo recording date string. */ +export function useRecordingDate(): string | null { + return useStoreWithEqualityFn( + gameEntityStore, + (state) => state.recordingDate, + ); +} diff --git a/src/state/gameEntityTypes.ts b/src/state/gameEntityTypes.ts index 98c7b9f7..dd97b12b 100644 --- a/src/state/gameEntityTypes.ts +++ b/src/state/gameEntityTypes.ts @@ -140,6 +140,8 @@ export interface PlayerEntity extends PositionedBase { dataBlock?: string; weaponShape?: string; packShape?: string; + /** DTS shape name for the carried flag (slot 3, Mount2 bone). */ + flagShape?: string; falling?: boolean; jetting?: boolean; playerName?: string; @@ -185,13 +187,13 @@ export interface SpriteEntity extends PositionedBase { export interface AudioEmitterEntity extends PositionedBase { renderType: "AudioEmitter"; audioFileName?: string; - audioVolume?: number; audioIs3D?: boolean; audioIsLooping?: boolean; - audioMinDistance?: number; audioMaxDistance?: number; - audioMinLoopGap?: number; audioMaxLoopGap?: number; + audioMinDistance?: number; + audioMinLoopGap?: number; + audioVolume?: number; } export interface CameraEntity extends PositionedBase { diff --git a/src/state/index.ts b/src/state/index.ts deleted file mode 100644 index 015ed9f4..00000000 --- a/src/state/index.ts +++ /dev/null @@ -1,62 +0,0 @@ -export type { - EngineStoreState, - PlaybackStatus, - RuntimeTickInfo, - RuntimeSliceState, - PlaybackSliceState, -} from "./engineStore"; - -export { - engineStore, - effectNow, - advanceEffectClock, - resetEffectClock, - useEngineSelector, - useEngineStoreApi, - useRuntimeObjectById, - useRuntimeObjectByName, - useRuntimeObjectField, - useRuntimeGlobal, - useDatablockByName, - useRuntimeChildIds, -} from "./engineStore"; - -export type { - GameEntity, - PositionedEntity, - SceneEntity, - RenderType, - ForceFieldData, - ShapeEntity, - PlayerEntity, - ForceFieldBareEntity, - ExplosionEntity, - TracerEntity, - SpriteEntity, - AudioEmitterEntity, - CameraEntity, - WayPointEntity, - NoneEntity, - TerrainBlockEntity, - InteriorInstanceEntity, - SkyEntity, - SunEntity, - WaterBlockEntity, - MissionAreaEntity, -} from "./gameEntityTypes"; - -export { isSceneEntity } from "./gameEntityTypes"; - -export type { GameEntityState } from "./gameEntityStore"; - -export { - gameEntityStore, - useGameEntities, - useAllGameEntities, - useGameEntitiesByRenderType, - useGameEntitiesByClass, - useGameEntity, - useSceneSky, - useSceneSun, - useSceneMissionArea, -} from "./gameEntityStore"; diff --git a/src/state/liveConnectionStore.ts b/src/state/liveConnectionStore.ts index 38f72334..8b8c4ce0 100644 --- a/src/state/liveConnectionStore.ts +++ b/src/state/liveConnectionStore.ts @@ -1,18 +1,22 @@ import { createStore } from "zustand/vanilla"; import { useStoreWithEqualityFn } from "zustand/traditional"; +import { createLogger } from "../logger"; import { RelayClient } from "../stream/relayClient"; import { LiveStreamAdapter } from "../stream/liveStreaming"; +import { gameEntityStore } from "./gameEntityStore"; import type { ClientMove, ServerInfo, ConnectionStatus, } from "../../relay/types"; +const log = createLogger("liveConnectionStore"); + export interface LiveConnectionState { relayConnected: boolean; gameStatus: ConnectionStatus | null; gameStatusMessage?: string; - /** Map name from the server being joined (from GameInfoResponse or status). */ + /** Mission name from the server (updated on map cycle). */ mapName?: string; /** Display name of the joined server. */ serverName?: string; @@ -25,6 +29,8 @@ export interface LiveConnectionState { adapter: LiveStreamAdapter | null; /** True once the first ghost entity arrives (game is rendering). */ liveReady: boolean; + /** Warrior name used when joining the server. */ + warriorName?: string; } export interface LiveConnectionStore extends LiveConnectionState { @@ -79,8 +85,11 @@ export const liveConnectionStore = createStore( s._pending = []; }, onStatus(status, message, _connectSequence, statusMapName) { - console.log( - `[relay] game status: ${status}${message ? ` — ${message}` : ""}${statusMapName ? ` map=${statusMapName}` : ""}`, + log.info( + "game status: %s%s%s", + status, + message ? ` — ${message}` : "", + statusMapName ? ` map=${statusMapName}` : "", ); set({ gameStatus: status, @@ -95,9 +104,7 @@ export const liveConnectionStore = createStore( onGamePacket(data) { const a = get()._adapter; if (!a) { - console.warn( - "[relay] received game packet but no adapter is active", - ); + log.warn("received game packet but no adapter is active"); } a?.feedPacket(data); }, @@ -108,7 +115,7 @@ export const liveConnectionStore = createStore( set({ browserToRelayPing: ms }); }, onError(message) { - console.error("Relay error:", message); + log.error("error: %s", message); get()._listInFlight = false; set({ serversLoading: false }); }, @@ -188,23 +195,54 @@ export const liveConnectionStore = createStore( const cachedServer = s.servers.find((sv) => sv.address === address); const newAdapter = new LiveStreamAdapter(s._relay); newAdapter.onReady = () => set({ liveReady: true }); + newAdapter.onMissionChange = (missionName) => { + log.info("mission changed: %s", missionName); + set({ mapName: missionName, liveReady: false }); + // Set the new mission name and clear stale fields — they'll be + // re-populated when MsgClientReady / MsgMissionDropInfo arrive. + gameEntityStore.getState().setMissionInfo({ + missionName, + missionType: null, + missionTypeDisplayName: null, + missionDisplayName: null, + gameClassName: null, + }); + }; + newAdapter.onMissionInfoChange = () => { + gameEntityStore.getState().setMissionInfo({ + missionDisplayName: newAdapter.missionDisplayName ?? undefined, + missionTypeDisplayName: + newAdapter.missionTypeDisplayName ?? undefined, + gameClassName: newAdapter.gameClassName ?? undefined, + serverDisplayName: newAdapter.serverDisplayName ?? undefined, + recorderName: newAdapter.connectedPlayerName ?? undefined, + }); + }; s._adapter = newAdapter; set({ mapName: cachedServer?.mapName ?? s.mapName, serverName: cachedServer?.name, + warriorName, liveReady: false, gameStatus: null, adapter: newAdapter, }); + // Set initial mission info from the server browser's cached data. + gameEntityStore.getState().setMissionInfo({ + missionName: cachedServer?.mapName ?? undefined, + missionTypeDisplayName: cachedServer?.gameType ?? undefined, + serverDisplayName: cachedServer?.name ?? undefined, + recorderName: warriorName ?? undefined, + }); + s._relay.joinServer(address, warriorName); }, disconnectServer() { const s = get(); s._relay?.disconnectServer(); - s._adapter?.reset(); s._adapter = null; set({ adapter: null, @@ -238,7 +276,7 @@ export function useLiveSelector( export function selectPing(s: LiveConnectionStore): number | null { return s.relayToGameServerPing != null && s.browserToRelayPing != null ? s.relayToGameServerPing + s.browserToRelayPing - : s.relayToGameServerPing ?? null; + : (s.relayToGameServerPing ?? null); } /** Dispose the relay connection (for cleanup on unmount). */ diff --git a/src/state/streamPlaybackStore.ts b/src/state/streamPlaybackStore.ts index 21c6290d..c5cd117b 100644 --- a/src/state/streamPlaybackStore.ts +++ b/src/state/streamPlaybackStore.ts @@ -39,6 +39,10 @@ export const streamPlaybackStore = createStore()(() => ({ /** Reset all streaming playback state. Called when streaming ends. */ export function resetStreamPlayback(): void { - streamPlaybackStore.setState({ time: 0, playback: null, freeFlyCamera: false }); + streamPlaybackStore.setState({ + time: 0, + playback: null, + freeFlyCamera: false, + }); // root is managed by the React ref callback in EntityScene — don't clear it } diff --git a/src/stream/StreamEngine.ts b/src/stream/StreamEngine.ts index 27b0681c..5168ed2b 100644 --- a/src/stream/StreamEngine.ts +++ b/src/stream/StreamEngine.ts @@ -1,5 +1,4 @@ import { ghostToSceneObject } from "../scene"; -import { getTerrainHeightAt } from "../terrainHeight"; import type { SceneObject } from "../scene/types"; import { linearProjectileClassNames, @@ -51,6 +50,9 @@ import type { WeaponImageState, WeaponImageDataBlockState, } from "./types"; +import { createLogger } from "../logger"; + +const log = createLogger("StreamEngine"); export type { Vec3 }; @@ -97,19 +99,20 @@ export interface MutableEntity { weaponImageStates?: WeaponImageDataBlockState[]; weaponImageStatesDbId?: number; packShape?: string; + flagShape?: string; falling?: boolean; jetting?: boolean; headPitch?: number; headYaw?: number; targetRenderFlags?: number; carryingFlag?: boolean; - /** Item physics simulation state (dropped weapons/items). */ + /** Item velocity interpolation state (dropped weapons/items). + * The real Tribes 2 client does NOT simulate physics (gravity/collision) + * for items — it just interpolates position using server-sent velocity + * until the next server update arrives. */ itemPhysics?: { velocity: [number, number, number]; atRest: boolean; - elasticity: number; - friction: number; - gravityMod: number; }; label?: string; audioFileName?: string; @@ -164,6 +167,9 @@ export abstract class StreamEngine implements StreamingPlayback { // ── Chat & audio ── protected chatMessages: ChatMessage[] = []; protected chatMessageIdCounter = 0; + private _chatGen = 0; + private _chatSnapshotGen = -1; + private _chatSnapshot: ChatMessage[] = []; protected audioEvents: PendingAudioEvent[] = []; // ── Net strings ── @@ -210,6 +216,20 @@ export abstract class StreamEngine implements StreamingPlayback { protected teamScores: TeamScore[] = []; protected playerRoster = new Map(); + // ── Mission info (from server messages) ── + /** Mission display name (e.g. "Riverdance"), from MsgMissionDropInfo/MsgLoadInfo. */ + missionDisplayName: string | null = null; + /** Game type display name (e.g. "Capture the Flag"), from MsgMissionDropInfo/MsgLoadInfo. */ + missionTypeDisplayName: string | null = null; + /** Game class name (e.g. "CTFGame"), from MsgClientReady. */ + gameClassName: string | null = null; + /** Server name from MsgMissionDropInfo. */ + serverDisplayName: string | null = null; + /** Server-assigned name of the connected/recording player. */ + connectedPlayerName: string | null = null; + /** Called when mission info changes (mission name, game type, etc.). */ + onMissionInfoChange?: () => void; + // ── Explosions ── protected nextExplosionId = 0; @@ -285,6 +305,9 @@ export abstract class StreamEngine implements StreamingPlayback { this.camera = null; this.chatMessages = []; this.chatMessageIdCounter = 0; + this._chatGen = 0; + this._chatSnapshotGen = -1; + this._chatSnapshot = []; this.audioEvents = []; this.netStrings.clear(); this.targetNames.clear(); @@ -315,6 +338,11 @@ export abstract class StreamEngine implements StreamingPlayback { this.teamScores = []; this.playerRoster.clear(); this.nextExplosionId = 0; + this.missionDisplayName = null; + this.missionTypeDisplayName = null; + this.gameClassName = null; + this.serverDisplayName = null; + this.connectedPlayerName = null; } // ── Net string resolution ── @@ -385,7 +413,10 @@ export abstract class StreamEngine implements StreamingPlayback { this.isPiloting = !!( controlData.pilot || controlData.controlObjectGhost != null ); - if (this.isPiloting && typeof controlData.controlObjectGhost === "number") { + if ( + this.isPiloting && + typeof controlData.controlObjectGhost === "number" + ) { this.lastPilotGhostIndex = controlData.controlObjectGhost; } else if (!this.isPiloting) { this.lastPilotGhostIndex = undefined; @@ -450,6 +481,17 @@ export abstract class StreamEngine implements StreamingPlayback { const ghostIndex = data.ghostIndex as number | undefined; const classId = data.classId as number | undefined; const objectData = data.objectData as Record | undefined; + const hasData = data._hasObjectData as boolean | undefined; + const className = typeof classId === "number" + ? this.registry.getGhostParser(classId)?.name ?? `classId=${classId}` + : "?"; + log.debug( + "GhostAlwaysObjectEvent: ghost=%d class=%s hasData=%s %s", + ghostIndex, + className, + hasData, + objectData ? `keys=[${Object.keys(objectData).join(",")}]` : "(no data)", + ); if (ghostIndex != null && classId != null) { this.processGhostUpdate({ index: ghostIndex, @@ -509,7 +551,9 @@ export abstract class StreamEngine implements StreamingPlayback { const rf = this.targetRenderFlags.get(targetId); for (const entity of this.entities.values()) { if (entity.targetId === targetId) { - if (name) entity.playerName = name; + if (name) { + entity.playerName = name; + } if (team != null) entity.sensorGroup = team; if (rf != null) entity.targetRenderFlags = rf; } @@ -518,10 +562,7 @@ export abstract class StreamEngine implements StreamingPlayback { return; } - if ( - type === "SetSensorGroupEvent" || - eventName === "SetSensorGroupEvent" - ) { + if (type === "SetSensorGroupEvent" || eventName === "SetSensorGroupEvent") { const sg = data.sensorGroup as number | undefined; if (sg != null) this.playerSensorGroup = sg; return; @@ -558,10 +599,7 @@ export abstract class StreamEngine implements StreamingPlayback { return; } - if ( - type === "RemoteCommandEvent" || - eventName === "RemoteCommandEvent" - ) { + if (type === "RemoteCommandEvent" || eventName === "RemoteCommandEvent") { const funcName = this.resolveNetString(data.funcName as string); const args = data.args as string[]; const timeSec = this.getTimeSec(); @@ -607,9 +645,7 @@ export abstract class StreamEngine implements StreamingPlayback { }); } } else if (funcName === "CannedChatMessage" && args.length >= 6) { - const cannedColorCode = detectColorCode( - this.resolveNetString(args[1]), - ); + const cannedColorCode = detectColorCode(this.resolveNetString(args[1])); const name = stripTaggedStringMarkup(this.resolveNetString(args[2])); const keys = stripTaggedStringMarkup(this.resolveNetString(args[4])); const rawText = this.formatRemoteArgs(args[1], args.slice(2)); @@ -682,9 +718,7 @@ export abstract class StreamEngine implements StreamingPlayback { const timeSec = this.getTimeSec(); const is3D = type === "Sim3DAudioEvent" || eventName === "Sim3DAudioEvent"; - const position = is3D - ? (data.position as Vec3 | undefined) - : undefined; + const position = is3D ? (data.position as Vec3 | undefined) : undefined; this.audioEvents.push({ profileId, position, timeSec }); if (this.audioEvents.length > 100) { this.audioEvents.splice(0, this.audioEvents.length - 100); @@ -863,8 +897,7 @@ export abstract class StreamEngine implements StreamingPlayback { entity.projectilePhysics = "linear"; } else if (ballisticProjectileClassNames.has(entity.className)) { entity.projectilePhysics = "ballistic"; - entity.gravityMod = - getNumberField(blockData, ["gravityMod"]) ?? 1.0; + entity.gravityMod = getNumberField(blockData, ["gravityMod"]) ?? 1.0; } else if (seekerProjectileClassNames.has(entity.className)) { entity.projectilePhysics = "seeker"; } @@ -955,14 +988,29 @@ export abstract class StreamEngine implements StreamingPlayback { entity.packShape = undefined; } - // Flag tracking + // Flag image (slot 3 = $FlagSlot, mountPoint 2 = Mount2) const flagImage = images.find((img) => img.index === 3); - if (flagImage) { - const hasFlag = !!flagImage.dataBlockId && flagImage.dataBlockId > 0; - entity.carryingFlag = hasFlag; + if (flagImage?.dataBlockId && flagImage.dataBlockId > 0) { + entity.carryingFlag = true; + const blockData = this.getDataBlockData(flagImage.dataBlockId); + const shape = resolveShapeName("ShapeBaseImageData", blockData); + if (shape) { + entity.flagShape = shape; + } if (entity.targetId != null && entity.targetId >= 0) { const prev = this.targetRenderFlags.get(entity.targetId) ?? 0; - const updated = hasFlag ? prev | 0x2 : prev & ~0x2; + const updated = prev | 0x2; + if (updated !== prev) { + this.targetRenderFlags.set(entity.targetId, updated); + entity.targetRenderFlags = updated; + } + } + } else if (flagImage && !flagImage.dataBlockId) { + entity.carryingFlag = false; + entity.flagShape = undefined; + if (entity.targetId != null && entity.targetId >= 0) { + const prev = this.targetRenderFlags.get(entity.targetId) ?? 0; + const updated = prev & ~0x2; if (updated !== prev) { this.targetRenderFlags.set(entity.targetId, updated); entity.targetRenderFlags = updated; @@ -1018,9 +1066,11 @@ export abstract class StreamEngine implements StreamingPlayback { ) ) { const converted = torqueQuatToThreeJS( - (data.transform as { - rotation: { x: number; y: number; z: number; w: number }; - }).rotation, + ( + data.transform as { + rotation: { x: number; y: number; z: number; w: number }; + } + ).rotation, ); if (converted) entity.rotation = converted; } else if ( @@ -1059,21 +1109,15 @@ export abstract class StreamEngine implements StreamingPlayback { if (typeof data.moveFlag0 === "boolean") entity.falling = data.moveFlag0; if (typeof data.moveFlag1 === "boolean") entity.jetting = data.moveFlag1; - // Item physics: when the server sends a position update with - // atRest=false and a velocity, start client-side physics simulation. + // Item velocity interpolation: the Tribes 2 client does NOT simulate + // physics (gravity/collision) for items. It interpolates position using + // server-sent velocity until the next server update or atRest=true. if (entity.type === "Item") { const atRest = data.atRest as boolean | undefined; if (atRest === false && isVec3Like(data.velocity)) { - const blockData = - entity.dataBlockId != null - ? this.getDataBlockData(entity.dataBlockId) - : undefined; entity.itemPhysics = { velocity: [data.velocity.x, data.velocity.y, data.velocity.z], atRest: false, - elasticity: getNumberField(blockData, ["elasticity"]) ?? 0.2, - friction: getNumberField(blockData, ["friction"]) ?? 0.6, - gravityMod: getNumberField(blockData, ["gravityMod"]) ?? 1.0, }; } else if (atRest === true) { entity.itemPhysics = undefined; @@ -1280,8 +1324,7 @@ export abstract class StreamEngine implements StreamingPlayback { audioVolume: (descBlock?.volume as number) ?? 1, audioIs3D: (descBlock?.is3D as boolean) ?? true, audioIsLooping: (descBlock?.isLooping as boolean) ?? false, - audioMinDistance: - (descBlock?.referenceDistance as number) ?? 20, + audioMinDistance: (descBlock?.referenceDistance as number) ?? 20, audioMaxDistance: (descBlock?.maxDistance as number) ?? 100, audioMinLoopGap: (descBlock?.minLoopGap as number) ?? 0, audioMaxLoopGap: (descBlock?.maxLoopGap as number) ?? 0, @@ -1433,6 +1476,8 @@ export abstract class StreamEngine implements StreamingPlayback { } /** Advance dropped item physics (gravity, terrain collision, friction). */ + /** Advance item positions using server-sent velocity (no gravity/collision). + * The real Tribes 2 client just interpolates; physics runs server-side. */ protected advanceItems(): void { const dt = TICK_DURATION_MS / 1000; for (const entity of this.entities.values()) { @@ -1440,35 +1485,9 @@ export abstract class StreamEngine implements StreamingPlayback { if (!phys || phys.atRest || !entity.position) continue; const v = phys.velocity; const p = entity.position; - - // Gravity: Tribes 2 uses -20 m/s² (Torque Z-up). - v[2] += -20 * phys.gravityMod * dt; - p[0] += v[0] * dt; p[1] += v[1] * dt; p[2] += v[2] * dt; - - // Terrain collision (flat normal approximation: [0, 0, 1]) - const groundZ = getTerrainHeightAt(p[0], p[1]); - if (groundZ != null && p[2] < groundZ) { - p[2] = groundZ; - const bd = Math.abs(v[2]); // normal impact speed - v[2] = bd * phys.elasticity; // reflect with restitution - // Friction: reduce horizontal speed proportional to impact - const friction = bd * phys.friction; - const hSpeed = Math.sqrt(v[0] * v[0] + v[1] * v[1]); - if (hSpeed > 0) { - const scale = Math.max(0, 1 - friction / hSpeed); - v[0] *= scale; - v[1] *= scale; - } - // At-rest check - const speed = Math.sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]); - if (speed < 0.15) { - v[0] = v[1] = v[2] = 0; - phys.atRest = true; - } - } } } @@ -1572,15 +1591,17 @@ export abstract class StreamEngine implements StreamingPlayback { } else { // Player control object. if (control.ghostIndex >= 0) { - this.controlPlayerGhostId = - this.resolveEntityIdForGhostIndex(control.ghostIndex); + this.controlPlayerGhostId = this.resolveEntityIdForGhostIndex( + control.ghostIndex, + ); } if (!this.firstPerson) { // Third-person: orbit the vehicle (if piloting) or the player. this.camera.mode = "third-person"; if (this.isPiloting && this.lastPilotGhostIndex != null) { - this.camera.orbitTargetId = - this.resolveEntityIdForGhostIndex(this.lastPilotGhostIndex); + this.camera.orbitTargetId = this.resolveEntityIdForGhostIndex( + this.lastPilotGhostIndex, + ); this.camera.orbitDistance = 15; if (this.lastVehicleOrbitDir) { this.camera.orbitDirection = this.lastVehicleOrbitDir; @@ -1601,8 +1622,9 @@ export abstract class StreamEngine implements StreamingPlayback { // Sync control object positions from controlObjectData. if (controlType === "player" && control.position) { if (this.isPiloting && this.lastPilotGhostIndex != null) { - const vehicleId = - this.resolveEntityIdForGhostIndex(this.lastPilotGhostIndex); + const vehicleId = this.resolveEntityIdForGhostIndex( + this.lastPilotGhostIndex, + ); const vehicleEntity = vehicleId ? this.entities.get(vehicleId) : undefined; @@ -1618,7 +1640,11 @@ export abstract class StreamEngine implements StreamingPlayback { control.position.y, control.position.z, ]; - this.lastVehiclePos = vehicleEntity.position.slice() as [number, number, number]; + this.lastVehiclePos = vehicleEntity.position.slice() as [ + number, + number, + number, + ]; this.lastVehiclePosTime = timeSec; // Extract velocity from linMomentum for interpolation between @@ -1629,7 +1655,8 @@ export abstract class StreamEngine implements StreamingPlayback { if (mom && isValidPosition(mom)) { // linMomentum = mass * velocity; look up mass from datablock. const dbId = vehicleEntity.dataBlockId; - const dbData = dbId != null ? this.getDataBlockData(dbId) : undefined; + const dbData = + dbId != null ? this.getDataBlockData(dbId) : undefined; const mass = (dbData?.mass as number) ?? 200; const invMass = mass > 0 ? 1 / mass : 1 / 200; this.lastVehicleVelocity = [ @@ -1768,6 +1795,7 @@ export abstract class StreamEngine implements StreamingPlayback { if (this.chatMessages.length > 200) { this.chatMessages.splice(0, this.chatMessages.length - 200); } + this._chatGen++; } protected handleServerMessage(args: string[]): void { @@ -1805,8 +1833,11 @@ export abstract class StreamEngine implements StreamingPlayback { this.onTeamScoresChanged(); } } else if (msgType === "MsgClientJoin" && args.length >= 4) { - const clientId = parseInt(this.resolveNetString(args[2]), 10); - const name = stripTaggedStringMarkup(this.resolveNetString(args[3])); + // Wire order: args[2]=clientName, args[3]=clientId, args[4]=targetId + const name = stripTaggedStringMarkup( + this.resolveNetString(args[2]), + ).trim(); + const clientId = parseInt(this.resolveNetString(args[3]), 10); if (!isNaN(clientId)) { const existing = this.playerRoster.get(clientId); this.playerRoster.set(clientId, { @@ -1815,6 +1846,11 @@ export abstract class StreamEngine implements StreamingPlayback { }); this.onRosterChanged(); } + // The first MsgClientJoin is the connected player's own join message. + if (!this.connectedPlayerName && name) { + this.connectedPlayerName = name; + this.onMissionInfoChange?.(); + } } else if (msgType === "MsgClientDrop" && args.length >= 3) { const clientId = parseInt(this.resolveNetString(args[2]), 10); if (!isNaN(clientId)) { @@ -1833,6 +1869,51 @@ export abstract class StreamEngine implements StreamingPlayback { } this.onRosterChanged(); } + } else if (msgType === "MsgMissionDropInfo" && args.length >= 5) { + // messageClient(%cl, 'MsgMissionDropInfo', ..., $MissionDisplayName, $MissionTypeDisplayName, $ServerName) + const missionDisplayName = stripTaggedStringMarkup( + this.resolveNetString(args[2]), + ); + const missionTypeDisplayName = stripTaggedStringMarkup( + this.resolveNetString(args[3]), + ); + const serverDisplayName = stripTaggedStringMarkup( + this.resolveNetString(args[4]), + ); + log.info( + "mission drop info: mission=%s gameType=%s server=%s", + missionDisplayName, + missionTypeDisplayName, + serverDisplayName, + ); + this.missionDisplayName = missionDisplayName || this.missionDisplayName; + this.missionTypeDisplayName = + missionTypeDisplayName || this.missionTypeDisplayName; + this.serverDisplayName = serverDisplayName || this.serverDisplayName; + this.onMissionInfoChange?.(); + } else if (msgType === "MsgLoadInfo" && args.length >= 5) { + // messageClient(%cl, 'MsgLoadInfo', "", $CurrentMission, $MissionDisplayName, $MissionTypeDisplayName) + const missionDisplayName = stripTaggedStringMarkup( + this.resolveNetString(args[3]), + ); + const missionTypeDisplayName = stripTaggedStringMarkup( + this.resolveNetString(args[4]), + ); + log.info( + "load info: mission=%s gameType=%s", + missionDisplayName, + missionTypeDisplayName, + ); + this.missionDisplayName = missionDisplayName || this.missionDisplayName; + this.missionTypeDisplayName = + missionTypeDisplayName || this.missionTypeDisplayName; + this.onMissionInfoChange?.(); + } else if (msgType === "MsgClientReady" && args.length >= 3) { + // messageClient(%cl, 'MsgClientReady', "", %game.class) + const gameClassName = this.resolveNetString(args[2]); + log.info("client ready: gameClass=%s", gameClassName); + this.gameClassName = gameClassName || this.gameClassName; + this.onMissionInfoChange?.(); } } @@ -1942,7 +2023,7 @@ export abstract class StreamEngine implements StreamingPlayback { let renderFlags = entity.targetId != null && entity.targetId >= 0 ? (this.targetRenderFlags.get(entity.targetId) ?? - entity.targetRenderFlags) + entity.targetRenderFlags) : entity.targetRenderFlags; if (entity.type === "Player" && !entity.carryingFlag) { renderFlags = renderFlags != null ? renderFlags & ~0x2 : renderFlags; @@ -1960,13 +2041,13 @@ export abstract class StreamEngine implements StreamingPlayback { dataBlock: entity.dataBlock, weaponShape: entity.weaponShape, packShape: entity.packShape, + flagShape: entity.flagShape, falling: entity.falling, jetting: entity.jetting, playerName: entity.playerName, targetRenderFlags: renderFlags, iffColor: - (entity.type === "Player" || - ((renderFlags ?? 0) & 0x2) !== 0) && + (entity.type === "Player" || ((renderFlags ?? 0) & 0x2) !== 0) && entity.sensorGroup != null ? this.resolveIffColor(entity.sensorGroup) : undefined, @@ -2033,8 +2114,7 @@ export abstract class StreamEngine implements StreamingPlayback { const teamScores = this.teamScores.map((ts) => ({ ...ts })); const teamCounts = new Map(); for (const { teamId } of this.playerRoster.values()) { - if (teamId > 0) - teamCounts.set(teamId, (teamCounts.get(teamId) ?? 0) + 1); + if (teamId > 0) teamCounts.set(teamId, (teamCounts.get(teamId) ?? 0) + 1); } for (const ts of teamScores) { ts.playerCount = teamCounts.get(ts.teamId) ?? 0; @@ -2048,7 +2128,11 @@ export abstract class StreamEngine implements StreamingPlayback { chatMessages: ChatMessage[]; audioEvents: PendingAudioEvent[]; } { - const chatMessages = this.chatMessages.slice(); + if (this._chatSnapshotGen !== this._chatGen) { + this._chatSnapshot = this.chatMessages.slice(); + this._chatSnapshotGen = this._chatGen; + } + const chatMessages = this._chatSnapshot; const audioEvents = this.audioEvents.filter( (e) => e.timeSec > timeSec - 0.5 && e.timeSec <= timeSec, ); diff --git a/src/stream/demoStreaming.ts b/src/stream/demoStreaming.ts index 6d663f49..4da8259b 100644 --- a/src/stream/demoStreaming.ts +++ b/src/stream/demoStreaming.ts @@ -33,35 +33,68 @@ import type { } from "./types"; import { StreamEngine, type MutableEntity } from "./StreamEngine"; -function extractMissionInfo(demoValues: string[]): { - missionName: string | null; - gameType: string | null; -} { - let missionName: string | null = null; - let gameType: string | null = null; +interface DemoMissionInfo { + /** Mission display name from readplayerinfo row 2 (e.g. "S5-WoodyMyrk"). */ + missionDisplayName: string | null; + /** Mission type display name from readplayerinfo row 3 (e.g. "Capture the Flag"). */ + missionType: string | null; + /** Game class name from the SCORE header (e.g. "CTFGame"). */ + gameClassName: string | null; + /** Server display name from readplayerinfo row 2. */ + serverDisplayName: string | null; + /** Mod name from readplayerinfo row 3 (e.g. "classic"). */ + mod: string | null; + /** Name of the player who recorded the demo (from readplayerinfo row 1). */ + recorderName: string | null; + /** Recording date string from readplayerinfo row 2 (e.g. "May-4-2025 10:37PM"). */ + recordingDate: string | null; +} + +function extractMissionInfo(demoValues: string[]): DemoMissionInfo { + let missionDisplayName: string | null = null; + let missionType: string | null = null; + let gameClassName: string | null = null; + let serverDisplayName: string | null = null; + let mod: string | null = null; + let recorderName: string | null = null; + let recordingDate: string | null = null; for (let i = 0; i < demoValues.length; i++) { + // SCORE header: "visible\tgameClassName\tobjCount" + const scoreFields = demoValues[i].split("\t"); + if (scoreFields.length >= 3 && scoreFields[1]?.endsWith("Game")) { + gameClassName = scoreFields[1]; + } + if (demoValues[i] !== "readplayerinfo") continue; const value = demoValues[i + 1]; if (!value) continue; - if (value.startsWith("2\t")) { + if (value.startsWith("1\t")) { + // Row 1: "1\ttime\trecorderName\tteam\tplayerId" const fields = value.split("\t"); - if (fields[4]) { - missionName = fields[4]; - } + if (fields[2]) recorderName = stripTaggedStringMarkup(fields[2]).trim(); + continue; + } + + if (value.startsWith("2\t")) { + // Row 2: "2\tserverName\taddress\tdate\tmissionDisplayName" + const fields = value.split("\t"); + if (fields[1]) serverDisplayName = fields[1]; + if (fields[3]) recordingDate = fields[3]; + if (fields[4]) missionDisplayName = fields[4]; continue; } if (value.startsWith("3\t")) { + // Row 3: "3\tmod\tmissionTypeDisplayName\t..." const fields = value.split("\t"); - if (fields[2]) { - gameType = fields[2]; - } + if (fields[1]) mod = fields[1]; + if (fields[2]) missionType = fields[2]; } } - return { missionName, gameType }; + return { missionDisplayName, missionType, gameClassName, serverDisplayName, mod, recorderName, recordingDate }; } interface ParsedDemoValues { @@ -370,9 +403,10 @@ class StreamingPlayback extends StreamEngine { return this.moveTicks * (TICK_DURATION_MS / 1000); } - protected getCameraYawPitch( - _data: Record | undefined, - ): { yaw: number; pitch: number } { + protected getCameraYawPitch(_data: Record | undefined): { + yaw: number; + pitch: number; + } { // Move-derived angles are valid when the control object is a Player // (including when piloting a vehicle — moves still drive the camera). const hasMoves = this.lastControlType === "player"; @@ -464,7 +498,8 @@ class StreamingPlayback extends StreamEngine { : false; this.lastPilotGhostIndex = this.isPiloting && - typeof this.initialBlock.controlObjectData?.controlObjectGhost === "number" + typeof this.initialBlock.controlObjectData?.controlObjectGhost === + "number" ? this.initialBlock.controlObjectData.controlObjectGhost : undefined; if (this.isPiloting) { @@ -666,10 +701,7 @@ class StreamingPlayback extends StreamEngine { } getSnapshot(): StreamSnapshot { - if ( - this._cachedSnapshot && - this._cachedSnapshotTick === this.moveTicks - ) { + if (this._cachedSnapshot && this._cachedSnapshotTick === this.moveTicks) { return this._cachedSnapshot; } const snapshot = this.buildSnapshot(); @@ -823,8 +855,7 @@ class StreamingPlayback extends StreamEngine { // Replicate V12 Player::updateMove(): apply delta then wrap/clamp. this.absoluteYaw += block.parsed.yaw ?? 0; const TWO_PI = Math.PI * 2; - this.absoluteYaw = - ((this.absoluteYaw % TWO_PI) + TWO_PI) % TWO_PI; + this.absoluteYaw = ((this.absoluteYaw % TWO_PI) + TWO_PI) % TWO_PI; this.absolutePitch = clamp( this.absolutePitch + (block.parsed.pitch ?? 0), -MAX_PITCH, @@ -840,8 +871,7 @@ class StreamingPlayback extends StreamEngine { const timeSec = this.getTimeSec(); const prev = this._snap; - const { chatMessages, audioEvents } = - this.buildTimeFilteredEvents(timeSec); + const { chatMessages, audioEvents } = this.buildTimeFilteredEvents(timeSec); const weaponsHud = prev && prev.weaponsHudGen === this._weaponsHudGen @@ -978,15 +1008,25 @@ export async function createDemoStreamingRecording( ): Promise { const parser = new DemoParser(new Uint8Array(data)); const { header, initialBlock } = await parser.load(); - const { missionName: infoMissionName, gameType } = extractMissionInfo( - initialBlock.demoValues, - ); + const info = extractMissionInfo(initialBlock.demoValues); + const playback = new StreamingPlayback(parser); + + // Seed StreamEngine's mission info fields from the initial block so they're + // available immediately (before any server messages arrive during playback). + playback.missionDisplayName = info.missionDisplayName; + playback.missionTypeDisplayName = info.missionType; + playback.gameClassName = info.gameClassName; + playback.serverDisplayName = info.serverDisplayName; + playback.connectedPlayerName = info.recorderName; return { source: "demo", duration: header.demoLengthMs / 1000, - missionName: infoMissionName ?? initialBlock.missionName ?? null, - gameType, - streamingPlayback: new StreamingPlayback(parser), + missionName: initialBlock.missionName ?? null, + gameType: info.missionType, + serverDisplayName: info.serverDisplayName, + recorderName: info.recorderName, + recordingDate: info.recordingDate, + streamingPlayback: playback, }; } diff --git a/src/stream/entityBridge.ts b/src/stream/entityBridge.ts index 2bf6fd7f..b5383bd7 100644 --- a/src/stream/entityBridge.ts +++ b/src/stream/entityBridge.ts @@ -28,8 +28,9 @@ function positionedBase(entity: StreamEntity, spawnTime?: number) { keyframes: [ { time: spawnTime ?? 0, - position: entity.position ?? [0, 0, 0] as [number, number, number], - rotation: entity.rotation ?? [0, 0, 0, 1] as [number, number, number, number], + position: entity.position ?? ([0, 0, 0] as [number, number, number]), + rotation: + entity.rotation ?? ([0, 0, 0, 1] as [number, number, number, number]), }, ], }; @@ -52,17 +53,33 @@ export function streamEntityToGameEntity( }; switch (entity.sceneData.className) { case "TerrainBlock": - return { ...base, renderType: "TerrainBlock", terrainData: entity.sceneData }; + return { + ...base, + renderType: "TerrainBlock", + terrainData: entity.sceneData, + }; case "InteriorInstance": - return { ...base, renderType: "InteriorInstance", interiorData: entity.sceneData }; + return { + ...base, + renderType: "InteriorInstance", + interiorData: entity.sceneData, + }; case "Sky": return { ...base, renderType: "Sky", skyData: entity.sceneData }; case "Sun": return { ...base, renderType: "Sun", sunData: entity.sceneData }; case "WaterBlock": - return { ...base, renderType: "WaterBlock", waterData: entity.sceneData }; + return { + ...base, + renderType: "WaterBlock", + waterData: entity.sceneData, + }; case "MissionArea": - return { ...base, renderType: "MissionArea", missionAreaData: entity.sceneData }; + return { + ...base, + renderType: "MissionArea", + missionAreaData: entity.sceneData, + }; case "TSStatic": // TSStatic is rendered as a shape — extract shapeName from scene data. return { @@ -102,6 +119,7 @@ export function streamEntityToGameEntity( dataBlock: entity.dataBlock, weaponShape: entity.weaponShape, packShape: entity.packShape, + flagShape: entity.flagShape, falling: entity.falling, jetting: entity.jetting, playerName: entity.playerName, diff --git a/src/stream/liveStreaming.ts b/src/stream/liveStreaming.ts index 74558402..587b5267 100644 --- a/src/stream/liveStreaming.ts +++ b/src/stream/liveStreaming.ts @@ -1,13 +1,13 @@ -import { - createLiveParser, - type PacketParser, -} from "t2-demo-parser"; +import { createLiveParser, type PacketParser } from "t2-demo-parser"; +import { createLogger } from "../logger"; import { resolveShapeName, stripTaggedStringMarkup } from "./streamHelpers"; import type { Vec3 } from "./streamHelpers"; import type { StreamSnapshot } from "./types"; import { StreamEngine } from "./StreamEngine"; import type { RelayClient } from "./relayClient"; +const log = createLogger("liveStreaming"); + // ── Player list entry ── export interface PlayerListEntry { @@ -34,6 +34,10 @@ export class LiveStreamAdapter extends StreamEngine { /** Called once when the first ghost entity is created. */ onReady?: () => void; + /** Called when the server starts a new mission (map cycle). */ + onMissionChange?: (missionName: string) => void; + /** Current mission name as reported by the server. */ + missionName: string | null = null; constructor(relay: RelayClient) { super(); @@ -71,9 +75,10 @@ export class LiveStreamAdapter extends StreamEngine { return this.currentTimeSec; } - protected getCameraYawPitch( - data: Record | undefined, - ): { yaw: number; pitch: number } { + protected getCameraYawPitch(data: Record | undefined): { + yaw: number; + pitch: number; + } { const absRot = this.getAbsoluteRotation(data); return absRot ?? { yaw: 0, pitch: 0 }; } @@ -103,6 +108,7 @@ export class LiveStreamAdapter extends StreamEngine { this._snapshotTick = -1; this.dataBlockClassNames.clear(); this.observerMode = "fly"; + this.missionName = null; } getSnapshot(): StreamSnapshot { @@ -112,10 +118,7 @@ export class LiveStreamAdapter extends StreamEngine { return this.buildSnapshot(); } - stepToTime( - targetTimeSec: number, - _maxMoveTicks?: number, - ): StreamSnapshot { + stepToTime(targetTimeSec: number, _maxMoveTicks?: number): StreamSnapshot { this.currentTimeSec = targetTimeSec; return this.getSnapshot(); } @@ -173,7 +176,7 @@ export class LiveStreamAdapter extends StreamEngine { const args = rawArgs .map((a) => this.resolveNetString(a)) .filter((a) => a !== ""); - console.log(`[live] auth event: ${funcName}`, args); + log.info("auth event: %s %o", funcName, args); this.relay.sendAuthEvent(funcName, args); return; } @@ -184,15 +187,47 @@ export class LiveStreamAdapter extends StreamEngine { const resolvedArgs = rawArgs.map((a) => this.resolveNetString(a)); if (funcName === "MissionStartPhase1") { const seq = resolvedArgs[0] ?? ""; - console.log(`[live] mission phase 1, seq=${seq}`); + const newMissionName = resolvedArgs[1] ?? null; + log.info( + "mission phase 1, seq=%s mission=%s resolvedArgs=%o", + seq, + newMissionName, + resolvedArgs, + ); + // Phase 1 signals a new mission load — clear all ghosts (the server + // called resetGhosting before sending this) and update mission name. + if (newMissionName && newMissionName !== this.missionName) { + this.missionName = newMissionName; + this.entities.clear(); + this.entityIdByGhostIndex.clear(); + this._ready = false; + this._snapshot = null; + this._snapshotTick = -1; + // Clear stale mission info — new values arrive via MsgClientReady + // and MsgMissionDropInfo after the mission finishes loading. + this.missionDisplayName = null; + this.missionTypeDisplayName = null; + this.gameClassName = null; + this.serverDisplayName = null; + this.onMissionChange?.(newMissionName); + } this.relay.sendCommand("MissionStartPhase1Done", [seq]); } else if (funcName === "MissionStartPhase2") { const seq = resolvedArgs[0] ?? ""; - console.log(`[live] mission phase 2 (datablocks), seq=${seq}`); + log.info("mission phase 2 (datablocks), seq=%s", seq); this.relay.sendCommand("MissionStartPhase2Done", [seq]); } else if (funcName === "MissionStartPhase3") { const seq = resolvedArgs[0] ?? ""; - console.log(`[live] mission phase 3 (ghosting), seq=${seq}`); + const currentMission = resolvedArgs[1] ?? null; + log.info( + "mission phase 3 (ghosting), seq=%s mission=%s", + seq, + currentMission, + ); + // Phase 3 sends $CurrentMission — update if different from phase 1. + if (currentMission) { + this.missionName = currentMission; + } // Send an empty favorites list then acknowledge phase 3. this.relay.sendCommand("setClientFav", [""]); this.relay.sendCommand("MissionStartPhase3Done", [seq]); @@ -207,20 +242,29 @@ export class LiveStreamAdapter extends StreamEngine { const field2 = parsedData.field2 as number; // field1 bit 0 = includeTextures (from $Host::CRCTextures) const includeTextures = (field1 & 1) !== 0; - console.log( - `[live] CRC challenge: seed=0x${(seed >>> 0).toString(16)} ` + - `f1=0x${(field1 >>> 0).toString(16)} f2=0x${(field2 >>> 0).toString(16)} ` + - `includeTextures=${includeTextures}`, + log.info( + "CRC challenge: seed=0x%s f1=0x%s f2=0x%s includeTextures=%s", + (seed >>> 0).toString(16), + (field1 >>> 0).toString(16), + (field2 >>> 0).toString(16), + includeTextures, ); // Collect datablocks for relay-side CRC computation over game files. const dbMap = this.packetParser.getDataBlockDataMap(); - const datablocks: { objectId: number; className: string; shapeName: string }[] = []; + const datablocks: { + objectId: number; + className: string; + shapeName: string; + }[] = []; if (dbMap) { for (const [id, block] of dbMap) { const className = this.dataBlockClassNames.get(id); if (!className) continue; - const shapeName = resolveShapeName(className, block as Record); + const shapeName = resolveShapeName( + className, + block as Record, + ); datablocks.push({ objectId: id, className, @@ -228,7 +272,7 @@ export class LiveStreamAdapter extends StreamEngine { }); } } - console.log(`[live] CRC: sending ${datablocks.length} datablocks for computation`); + log.info("CRC: sending %d datablocks for computation", datablocks.length); this.relay.sendCRCCompute(seed, field2, datablocks, includeTextures); } @@ -242,12 +286,15 @@ export class LiveStreamAdapter extends StreamEngine { const message = parsedData.message as number; const sequence = parsedData.sequence as number; const ghostCount = parsedData.ghostCount as number; - console.log( - `[live] GhostingMessageEvent: message=${message} sequence=${sequence} ghostCount=${ghostCount}`, + log.info( + "GhostingMessageEvent: message=%d sequence=%d ghostCount=%d", + message, + sequence, + ghostCount, ); if (message === 0) { // GhostAlwaysDone → send type 1 acknowledgment - console.log(`[live] Sending ghost ack (type 1) for sequence ${sequence}`); + log.info("Sending ghost ack (type 1) for sequence %d", sequence); this.relay.sendGhostAck(sequence, ghostCount); } } @@ -263,12 +310,12 @@ export class LiveStreamAdapter extends StreamEngine { cycleObserveNext(): void { if (this.observerMode === "fly") { // Jump trigger enters observerFollow from observerFly - console.log("[live] observer: fly → follow (jump trigger)"); + log.info("observer: fly → follow (jump trigger)"); this.sendTrigger(2); this.observerMode = "follow"; } else { // Fire trigger cycles to next player in observerFollow - console.log("[live] observer: cycle next (fire trigger)"); + log.info("observer: cycle next (fire trigger)"); this.sendTrigger(0); } } @@ -277,24 +324,34 @@ export class LiveStreamAdapter extends StreamEngine { toggleObserverMode(): void { if (this.observerMode === "fly") { // Jump trigger enters observerFollow from observerFly - console.log("[live] observer: fly → follow (jump trigger)"); + log.info("observer: fly → follow (jump trigger)"); this.sendTrigger(2); this.observerMode = "follow"; } else { // Jump trigger returns to observerFly from observerFollow - console.log("[live] observer: follow → fly (jump trigger)"); + log.info("observer: follow → fly (jump trigger)"); this.sendTrigger(2); this.observerMode = "fly"; } } private sendTrigger(index: number): void { - const trigger: [boolean, boolean, boolean, boolean, boolean, boolean] = - [false, false, false, false, false, false]; + const trigger: [boolean, boolean, boolean, boolean, boolean, boolean] = [ + false, + false, + false, + false, + false, + false, + ]; trigger[index] = true; this.relay.sendMove({ - x: 0, y: 0, z: 0, - yaw: 0, pitch: 0, roll: 0, + x: 0, + y: 0, + z: 0, + yaw: 0, + pitch: 0, + roll: 0, trigger, freeLook: false, }); @@ -318,12 +375,17 @@ export class LiveStreamAdapter extends StreamEngine { const noDispatchBefore = this.packetParser.protocolNoDispatch; const parsed = this.packetParser.parsePacket(data); const wasRejected = this.packetParser.protocolRejected > rejectedBefore; - const wasNoDispatch = this.packetParser.protocolNoDispatch > noDispatchBefore; + const wasNoDispatch = + this.packetParser.protocolNoDispatch > noDispatchBefore; if (wasRejected || wasNoDispatch) { - console.warn( - `[live] packet #${this.tickCount} ${wasRejected ? "REJECTED" : "no-dispatch"}: ${data.length} bytes` + - ` (total rejected=${this.packetParser.protocolRejected}, noDispatch=${this.packetParser.protocolNoDispatch})`, + log.warn( + "packet #%d %s: %d bytes (total rejected=%d, noDispatch=%d)", + this.tickCount, + wasRejected ? "REJECTED" : "no-dispatch", + data.length, + this.packetParser.protocolRejected, + this.packetParser.protocolNoDispatch, ); } @@ -332,14 +394,18 @@ export class LiveStreamAdapter extends StreamEngine { const shouldLog = isEarlyPacket || isMilestonePacket; if (shouldLog) { - console.log( - `[live] packet #${this.tickCount}: ${parsed.events.length} events, ${parsed.ghosts.length} ghosts, ${data.length} bytes` + - (parsed.gameState.controlObjectGhostIndex !== undefined + log.debug( + "packet #%d: %d events, %d ghosts, %d bytes%s%s", + this.tickCount, + parsed.events.length, + parsed.ghosts.length, + data.length, + parsed.gameState.controlObjectGhostIndex !== undefined ? `, control=${parsed.gameState.controlObjectGhostIndex}` - : "") + - (parsed.gameState.cameraFov !== undefined + : "", + parsed.gameState.cameraFov !== undefined ? `, fov=${parsed.gameState.cameraFov}` - : ""), + : "", ); } @@ -356,17 +422,20 @@ export class LiveStreamAdapter extends StreamEngine { // Always log RemoteCommandEvents (chat, server messages, HUD). if (type === "RemoteCommandEvent") { - const funcName = this.resolveNetString(event.parsedData.funcName as string ?? ""); - console.log(`[live] remote: ${funcName}`); + const funcName = this.resolveNetString( + (event.parsedData.funcName as string) ?? "", + ); + log.debug("remote: %s", funcName); } // Log other events in early packets if (isEarlyPacket) { if (type !== "NetStringEvent" && type !== "RemoteCommandEvent") { - console.log( - `[live] event: ${type}`, + log.debug( + "event: %s%s", + type, type === "SimDataBlockEvent" - ? { id: event.parsedData.objectId, className: event.parsedData.dataBlockClassName } - : undefined, + ? ` id=${event.parsedData.objectId} class=${event.parsedData.dataBlockClassName}` + : "", ); } } @@ -374,16 +443,22 @@ export class LiveStreamAdapter extends StreamEngine { // Track SimDataBlockEvent class names for CRC computation. if (type === "SimDataBlockEvent") { const dbId = event.parsedData.objectId as number | undefined; - const dbClassName = event.parsedData.dataBlockClassName as string | undefined; + const dbClassName = event.parsedData.dataBlockClassName as + | string + | undefined; if (dbId != null && dbClassName) { this.dataBlockClassNames.set(dbId, dbClassName); } if (shouldLog) { - const dbData = event.parsedData.dataBlockData as Record | undefined; + const dbData = event.parsedData.dataBlockData as + | Record + | undefined; const shapeName = resolveShapeName(dbClassName ?? "", dbData); - console.log( - `[live] datablock: id=${dbId} class=${dbClassName ?? "?"}` + - (shapeName ? ` shape=${shapeName}` : ""), + log.debug( + "datablock: id=%d class=%s%s", + dbId, + dbClassName ?? "?", + shapeName ? ` shape=${shapeName}` : "", ); } } @@ -396,7 +471,11 @@ export class LiveStreamAdapter extends StreamEngine { const id = event.parsedData.id as number; const value = event.parsedData.value as string; if (id != null && typeof value === "string") { - console.log(`[live] netString #${id} = "${value.length > 60 ? value.slice(0, 60) + "…" : value}"`); + log.trace( + 'netString #%d = "%s"', + id, + value.length > 60 ? value.slice(0, 60) + "…" : value, + ); } } @@ -408,7 +487,12 @@ export class LiveStreamAdapter extends StreamEngine { const resolved = this.netStrings.get(nameTag); if (resolved) { const name = stripTaggedStringMarkup(resolved); - console.log(`[live] target #${targetId}: "${name}" team=${event.parsedData.sensorGroup ?? "?"}`); + log.info( + 'target #%d: "%s" team=%s', + targetId, + name, + event.parsedData.sensorGroup ?? "?", + ); } } } @@ -417,17 +501,21 @@ export class LiveStreamAdapter extends StreamEngine { if (type === "SetSensorGroupEvent") { const sg = event.parsedData.sensorGroup as number | undefined; if (sg != null) { - console.log(`[live] sensor group changed: → ${sg}`); + log.info("sensor group changed: → %d", sg); } } // Log sensor group colors if (type === "SensorGroupColorEvent") { const sg = event.parsedData.sensorGroup as number; - const colors = event.parsedData.colors as Array | undefined; + const colors = event.parsedData.colors as + | Array + | undefined; if (colors) { - console.log( - `[live] sensor group colors: group=${sg}, ${colors.length} entries`, + log.debug( + "sensor group colors: group=%d, %d entries", + sg, + colors.length, ); } } @@ -438,12 +526,23 @@ export class LiveStreamAdapter extends StreamEngine { for (const ghost of parsed.ghosts) { if (ghost.type === "create") { const pos = ghost.parsedData?.position as Vec3 | undefined; - const hasPos = pos && typeof pos.x === "number" && typeof pos.y === "number" && typeof pos.z === "number"; - const className = this.resolveGhostClassName(ghost.index, ghost.classId); - console.log( - `[live] ghost create: #${ghost.index} ${className ?? "?"}` + - (hasPos ? ` at (${pos.x.toFixed(1)}, ${pos.y.toFixed(1)}, ${pos.z.toFixed(1)})` : "") + - ` (${this.entities.size + 1} entities total)`, + const hasPos = + pos && + typeof pos.x === "number" && + typeof pos.y === "number" && + typeof pos.z === "number"; + const className = this.resolveGhostClassName( + ghost.index, + ghost.classId, + ); + log.debug( + "ghost create: #%d %s%s (%d entities total)", + ghost.index, + className ?? "?", + hasPos + ? ` at (${pos.x.toFixed(1)}, ${pos.y.toFixed(1)}, ${pos.z.toFixed(1)})` + : "", + this.entities.size + 1, ); if (!this._ready) { this._ready = true; @@ -451,11 +550,15 @@ export class LiveStreamAdapter extends StreamEngine { } } else if (ghost.type === "delete") { const prevEntityId = this.entityIdByGhostIndex.get(ghost.index); - const prevEntity = prevEntityId ? this.entities.get(prevEntityId) : undefined; + const prevEntity = prevEntityId + ? this.entities.get(prevEntityId) + : undefined; if (this.tickCount < 50 || this.tickCount % 200 === 0) { - console.log( - `[live] ghost delete: #${ghost.index} ${prevEntity?.className ?? "?"}` + - ` (${this.entities.size - 1} entities remaining)`, + log.debug( + "ghost delete: #%d %s (%d entities remaining)", + ghost.index, + prevEntity?.className ?? "?", + this.entities.size - 1, ); } } @@ -469,10 +572,13 @@ export class LiveStreamAdapter extends StreamEngine { // Periodic status at milestones if (isMilestonePacket && this.tickCount > 1) { const dbMap = this.packetParser.getDataBlockDataMap(); - console.log( - `[live] status @ tick ${this.tickCount}: ${this.entities.size} entities, ` + - `${dbMap?.size ?? 0} datablocks, ` + - `rejected=${this.packetParser.protocolRejected}, noDispatch=${this.packetParser.protocolNoDispatch}`, + log.info( + "status @ tick %d: %d entities, %d datablocks, rejected=%d, noDispatch=%d", + this.tickCount, + this.entities.size, + dbMap?.size ?? 0, + this.packetParser.protocolRejected, + this.packetParser.protocolNoDispatch, ); } @@ -489,9 +595,7 @@ export class LiveStreamAdapter extends StreamEngine { const summary = [...types.entries()] .map(([t, c]) => `${t}=${c}`) .join(" "); - console.log( - `[live] entity count: ${entityCount} (${summary})`, - ); + log.info("entity count: %d (%s)", entityCount, summary); } const prevMode = this.camera?.mode; @@ -499,18 +603,25 @@ export class LiveStreamAdapter extends StreamEngine { // Log camera mode transitions (always, not just early packets). if (this.camera && this.camera.mode !== prevMode) { - console.log( - `[live] camera mode: ${prevMode ?? "none"} → ${this.camera.mode}` + - (this.camera.mode === "third-person" + log.info( + "camera mode: %s → %s%s", + prevMode ?? "none", + this.camera.mode, + this.camera.mode === "third-person" ? ` orbit=${this.camera.orbitTargetId ?? "?"} dist=${this.camera.orbitDistance ?? "?"}` - : ""), + : "", ); } // Log camera position for early packets if (this.tickCount <= 5 && this.camera) { const [cx, cy, cz] = this.camera.position; - console.log( - `[live] camera: mode=${this.camera.mode} pos=(${cx.toFixed(1)}, ${cy.toFixed(1)}, ${cz.toFixed(1)}) fov=${this.camera.fov}`, + log.debug( + "camera: mode=%s pos=(%s, %s, %s) fov=%s", + this.camera.mode, + cx.toFixed(1), + cy.toFixed(1), + cz.toFixed(1), + this.camera.fov, ); } } catch (e) { @@ -521,7 +632,7 @@ export class LiveStreamAdapter extends StreamEngine { controlGhost: this.latestControl.ghostIndex, connectSynced: this.connectSynced, }; - console.error("Failed to process live packet:", e, errorContext); + log.error("Failed to process live packet: %o %o", e, errorContext); } } diff --git a/src/stream/missionEntityBridge.ts b/src/stream/missionEntityBridge.ts index b4081828..a6db4bc6 100644 --- a/src/stream/missionEntityBridge.ts +++ b/src/stream/missionEntityBridge.ts @@ -92,24 +92,48 @@ export function buildGameEntityFromMission( switch (className) { // Scene infrastructure case "TerrainBlock": - return { ...base, renderType: "TerrainBlock", terrainData: terrainFromMis(object) }; + return { + ...base, + renderType: "TerrainBlock", + terrainData: terrainFromMis(object), + }; case "InteriorInstance": - return { ...base, renderType: "InteriorInstance", interiorData: interiorFromMis(object) }; + return { + ...base, + renderType: "InteriorInstance", + interiorData: interiorFromMis(object), + }; case "Sky": return { ...base, renderType: "Sky", skyData: skyFromMis(object) }; case "Sun": return { ...base, renderType: "Sun", sunData: sunFromMis(object) }; case "WaterBlock": - return { ...base, renderType: "WaterBlock", waterData: waterBlockFromMis(object) }; + return { + ...base, + renderType: "WaterBlock", + waterData: waterBlockFromMis(object), + }; case "MissionArea": - return { ...base, renderType: "MissionArea", missionAreaData: missionAreaFromMis(object) }; + return { + ...base, + renderType: "MissionArea", + missionAreaData: missionAreaFromMis(object), + }; // Shapes case "StaticShape": case "Item": case "Turret": case "TSStatic": - return buildShapeEntity(posBase, object, datablock, runtime, className, teamId, datablockName); + return buildShapeEntity( + posBase, + object, + datablock, + runtime, + className, + teamId, + datablockName, + ); // Force field case "ForceFieldBare": @@ -166,14 +190,18 @@ function buildShapeEntity( teamId: number | undefined, datablockName: string, ): ShapeEntity { - const shapeName = className === "TSStatic" - ? getProperty(object, "shapeName") - : getProperty(datablock, "shapeFile"); + const shapeName = + className === "TSStatic" + ? getProperty(object, "shapeName") + : getProperty(datablock, "shapeFile"); const shapeType = - className === "Turret" ? "Turret" - : className === "Item" ? "Item" - : className === "TSStatic" ? "TSStatic" - : "StaticShape"; + className === "Turret" + ? "Turret" + : className === "Item" + ? "Item" + : className === "TSStatic" + ? "TSStatic" + : "StaticShape"; const entity: ShapeEntity = { ...posBase, @@ -256,13 +284,26 @@ function buildForceFieldEntity( }; } +/** Check if an entity's missionTypesList includes the given mission type. */ +function matchesMissionType( + missionTypesList: string | undefined, + missionType: string | undefined, +): boolean { + if (!missionType || !missionTypesList) return true; + const types = missionTypesList.toLowerCase().split(/\s+/).filter(Boolean); + return types.length === 0 || types.includes(missionType.toLowerCase()); +} + /** * Walk a TorqueObject tree and extract all GameEntities. * Respects team assignment from SimGroup hierarchy. + * When missionType is provided, entities whose missionTypesList doesn't + * include that type are excluded. */ export function walkMissionTree( root: TorqueObject, runtime: TorqueRuntime, + missionType?: string, teamId?: number, ): GameEntity[] { const entities: GameEntity[] = []; @@ -282,14 +323,16 @@ export function walkMissionTree( // Try to build entity for this object const entity = buildGameEntityFromMission(root, runtime, currentTeam); - if (entity) { + if (entity && matchesMissionType(entity.missionTypesList, missionType)) { entities.push(entity); } // Recurse into children if (root._children) { for (const child of root._children) { - entities.push(...walkMissionTree(child, runtime, currentTeam)); + entities.push( + ...walkMissionTree(child, runtime, missionType, currentTeam), + ); } } diff --git a/src/stream/playbackUtils.ts b/src/stream/playbackUtils.ts index 59d6b0e8..7fc41521 100644 --- a/src/stream/playbackUtils.ts +++ b/src/stream/playbackUtils.ts @@ -65,7 +65,8 @@ export function torqueHorizontalFovToThreeVerticalFov( torqueFovDeg: number, aspect: number, ): number { - const safeAspect = Number.isFinite(aspect) && aspect > 0.000001 ? aspect : 4 / 3; + const safeAspect = + Number.isFinite(aspect) && aspect > 0.000001 ? aspect : 4 / 3; const clampedFov = Math.max(0.01, Math.min(179.99, torqueFovDeg)); const hRad = (clampedFov * Math.PI) / 180; const vRad = 2 * Math.atan(Math.tan(hRad / 2) / safeAspect); @@ -281,7 +282,11 @@ export function replaceWithShapeMaterial( // that loads the atlas and sets up per-frame animation. if (flagNames.has("IflMaterial")) { const result = createMaterialFromFlags( - mat, null, flagNames, isOrganic, vis, + mat, + null, + flagNames, + isOrganic, + vis, ); if (Array.isArray(result)) { const material = result[1]; @@ -312,7 +317,11 @@ export function replaceWithShapeMaterial( } const result = createMaterialFromFlags( - mat, texture, flagNames, isOrganic, vis, + mat, + texture, + flagNames, + isOrganic, + vis, ); if (Array.isArray(result)) { return { material: result[1], backMaterial: result[0] }; diff --git a/src/stream/relayClient.ts b/src/stream/relayClient.ts index 41f8e363..c2ee0645 100644 --- a/src/stream/relayClient.ts +++ b/src/stream/relayClient.ts @@ -1,3 +1,4 @@ +import { createLogger } from "../logger"; import type { ClientMessage, ClientMove, @@ -6,9 +7,16 @@ import type { ConnectionStatus, } from "../../relay/types"; +const log = createLogger("relayClient"); + export type RelayEventHandler = { onOpen?: () => void; - onStatus?: (status: ConnectionStatus, message?: string, connectSequence?: number, mapName?: string) => void; + onStatus?: ( + status: ConnectionStatus, + message?: string, + connectSequence?: number, + mapName?: string, + ) => void; onServerList?: (servers: ServerInfo[]) => void; onGamePacket?: (data: Uint8Array) => void; /** Relay↔T2 server RTT. */ @@ -45,7 +53,7 @@ export class RelayClient { this.ws.binaryType = "arraybuffer"; this.ws.onopen = () => { - console.log("[relay] WebSocket connected to", this.url); + log.info("WebSocket connected to %s", this.url); this._connected = true; this.startWsPing(); this.handlers.onOpen?.(); @@ -61,20 +69,20 @@ export class RelayClient { const message: ServerMessage = JSON.parse(event.data as string); this.handleMessage(message); } catch (e) { - console.error("Failed to parse relay message:", e); + log.error("Failed to parse relay message: %o", e); } } }; this.ws.onclose = () => { - console.log("[relay] WebSocket disconnected"); + log.info("WebSocket disconnected"); this._connected = false; this.stopWsPing(); this.handlers.onClose?.(); }; this.ws.onerror = () => { - console.error("[relay] WebSocket error"); + log.error("WebSocket error"); this.handlers.onError?.("WebSocket connection error"); }; } @@ -85,7 +93,12 @@ export class RelayClient { this.handlers.onServerList?.(message.servers); break; case "status": - this.handlers.onStatus?.(message.status, message.message, message.connectSequence, message.mapName); + this.handlers.onStatus?.( + message.status, + message.message, + message.connectSequence, + message.mapName, + ); break; case "ping": this.handlers.onPing?.(message.ms); @@ -117,7 +130,7 @@ export class RelayClient { /** Join a specific game server. */ joinServer(address: string, warriorName?: string): void { - console.log("[relay] Joining server:", address); + log.info("Joining server: %s", address); this.send({ type: "joinServer", address, warriorName }); } @@ -148,7 +161,13 @@ export class RelayClient { datablocks: { objectId: number; className: string; shapeName: string }[], includeTextures: boolean, ): void { - this.send({ type: "sendCRCCompute", seed, field2, includeTextures, datablocks }); + this.send({ + type: "sendCRCCompute", + seed, + field2, + includeTextures, + datablocks, + }); } /** Send a GhostAlwaysDone acknowledgment through the relay. */ @@ -191,7 +210,7 @@ export class RelayClient { if (this.ws?.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify(message)); } else { - console.warn("[relay] send dropped (ws not open):", message.type); + log.warn("send dropped (ws not open): %s", message.type); } } } diff --git a/src/stream/streamHelpers.ts b/src/stream/streamHelpers.ts index e5906574..4c8ecaa7 100644 --- a/src/stream/streamHelpers.ts +++ b/src/stream/streamHelpers.ts @@ -36,10 +36,22 @@ export function yawPitchToQuaternion( const cz = Math.cos(yaw); _rotMat.set( - -sz, cz * sx, -cz * cx, 0, - 0, cx, sx, 0, - cz, sz * sx, -sz * cx, 0, - 0, 0, 0, 1, + -sz, + cz * sx, + -cz * cx, + 0, + 0, + cx, + sx, + 0, + cz, + sz * sx, + -sz * cx, + 0, + 0, + 0, + 0, + 1, ); _rotQuat.setFromRotationMatrix(_rotMat); @@ -371,8 +383,16 @@ export function stripTaggedStringMarkup(s: string): string { * producing byte values that skip \t (0x9), \n (0xa), and \r (0xd). */ const BYTE_TO_COLOR_INDEX: Record = { - 0x2: 0, 0x3: 1, 0x4: 2, 0x5: 3, 0x6: 4, - 0x7: 5, 0x8: 6, 0xb: 7, 0xc: 8, 0xe: 9, + 0x2: 0, + 0x3: 1, + 0x4: 2, + 0x5: 3, + 0x6: 4, + 0x7: 5, + 0x8: 6, + 0xb: 7, + 0xc: 8, + 0xe: 9, }; const BYTE_COLOR_RESET = 0x0f; @@ -441,9 +461,10 @@ export function parseColorSegments(raw: string): ChatSegment[] { } /** Extract an embedded `~w` sound tag from a message string. */ -export function extractWavTag( - text: string, -): { text: string; wavPath: string | null } { +export function extractWavTag(text: string): { + text: string; + wavPath: string | null; +} { const idx = text.indexOf("~w"); if (idx === -1) return { text, wavPath: null }; return { diff --git a/src/stream/types.ts b/src/stream/types.ts index ab2645ec..4c4626d8 100644 --- a/src/stream/types.ts +++ b/src/stream/types.ts @@ -132,6 +132,8 @@ export interface StreamEntity { weaponImageStates?: WeaponImageDataBlockState[]; /** DTS shape name for the mounted pack (slot 2, Mount1 bone). */ packShape?: string; + /** DTS shape name for the carried flag (slot 3, Mount2 bone). */ + flagShape?: string; /** True when the player has no ground contact and is falling. */ falling?: boolean; /** True when the player is using jetpack thrust. */ @@ -282,6 +284,20 @@ export interface StreamingPlayback { * Returns the raw sequence strings like `"heavy_male_root.dsq root"`. */ getShapeConstructorSequences(shapeName: string): string[] | undefined; + + // ── Mission info (populated from server messages) ── + /** Mission display name (e.g. "Riverdance"), from MsgMissionDropInfo. */ + missionDisplayName: string | null; + /** Game type display name (e.g. "Capture the Flag"), from MsgMissionDropInfo. */ + missionTypeDisplayName: string | null; + /** Game class name (e.g. "CTFGame"), from MsgClientReady. */ + gameClassName: string | null; + /** Server name, from MsgMissionDropInfo. */ + serverDisplayName: string | null; + /** Server-assigned name of the connected/recording player, from MsgClientJoin. */ + connectedPlayerName: string | null; + /** Called when any mission info field changes. */ + onMissionInfoChange?: () => void; } export interface StreamRecording { @@ -292,6 +308,12 @@ export interface StreamRecording { missionName: string | null; /** Game type display name (e.g. "Capture the Flag"). */ gameType: string | null; + /** Server display name. */ + serverDisplayName: string | null; + /** Name of the player who recorded the demo. */ + recorderName: string | null; + /** Recording date string (e.g. "May-4-2025 10:37PM"). */ + recordingDate: string | null; /** Streaming parser session for tick-driven playback. */ streamingPlayback: StreamingPlayback; } diff --git a/src/stream/weaponStateMachine.ts b/src/stream/weaponStateMachine.ts index af3db735..ca7491d8 100644 --- a/src/stream/weaponStateMachine.ts +++ b/src/stream/weaponStateMachine.ts @@ -1,7 +1,4 @@ -import type { - WeaponImageDataBlockState, - WeaponImageState, -} from "./types"; +import type { WeaponImageDataBlockState, WeaponImageState } from "./types"; /** Transition index sentinel: -1 means "no transition defined". */ const NO_TRANSITION = -1; @@ -10,10 +7,10 @@ const NO_TRANSITION = -1; const MAX_TRANSITIONS_PER_TICK = 32; /** Torque SpinState enum values from ShapeBaseImageData (shapeBase.h). */ -const SPIN_STOP = 1; // NoSpin -const SPIN_UP = 2; // SpinUp -const SPIN_DOWN = 3; // SpinDown -const SPIN_FULL = 4; // FullSpin +const SPIN_STOP = 1; // NoSpin +const SPIN_UP = 2; // SpinUp +const SPIN_DOWN = 3; // SpinDown +const SPIN_FULL = 4; // FullSpin export interface WeaponAnimState { /** Name of the current animation sequence to play (lowercase), or null. */ @@ -53,10 +50,7 @@ export class WeaponImageStateMachine { private lastFireCount = -1; private spinTimeScale = 0; - constructor( - states: WeaponImageDataBlockState[], - seqIndexToName: string[], - ) { + constructor(states: WeaponImageDataBlockState[], seqIndexToName: string[]) { this.states = states; this.seqIndexToName = seqIndexToName; if (states.length > 0) { @@ -70,9 +64,8 @@ export class WeaponImageStateMachine { reset(): void { this.currentStateIndex = 0; - this.delayTime = this.states.length > 0 - ? (this.states[0].timeoutValue ?? 0) - : 0; + this.delayTime = + this.states.length > 0 ? (this.states[0].timeoutValue ?? 0) : 0; this.lastFireCount = -1; } @@ -190,17 +183,15 @@ export class WeaponImageStateMachine { this.spinTimeScale = 0; break; case SPIN_UP: - this.spinTimeScale = timeout > 0 - ? Math.max(0, 1 - this.delayTime / timeout) - : 1; + this.spinTimeScale = + timeout > 0 ? Math.max(0, 1 - this.delayTime / timeout) : 1; break; case SPIN_FULL: this.spinTimeScale = 1; break; case SPIN_DOWN: - this.spinTimeScale = timeout > 0 - ? Math.max(0, this.delayTime / timeout) - : 0; + this.spinTimeScale = + timeout > 0 ? Math.max(0, this.delayTime / timeout) : 0; break; // SPIN_IGNORE (0): leave spinTimeScale unchanged. } @@ -312,9 +303,7 @@ export class WeaponImageStateMachine { } /** Resolve a state's sequence index to a clip name via the GLB metadata. */ - private resolveSequenceName( - state: WeaponImageDataBlockState, - ): string | null { + private resolveSequenceName(state: WeaponImageDataBlockState): string | null { if (state.sequence == null || state.sequence < 0) return null; const name = this.seqIndexToName[state.sequence]; return name ?? null; diff --git a/src/torqueScript/engineMethods.ts b/src/torqueScript/engineMethods.ts index 4e5701d0..67d3738f 100644 --- a/src/torqueScript/engineMethods.ts +++ b/src/torqueScript/engineMethods.ts @@ -1,5 +1,8 @@ +import { createLogger } from "../logger"; import type { TorqueRuntime } from "./types"; +const log = createLogger("engineMethods"); + /** * Register C++ engine method stubs that TorqueScript code expects to exist. * These are methods that would normally be implemented in the Torque C++ engine @@ -116,9 +119,9 @@ export function registerEngineStubs(runtime: TorqueRuntime): void { try { runtime.$.call(this_, String(methodName), ...args); } catch (err) { - console.error( - `schedule: error calling ${methodName} on ${this_._id}:`, - err, + log.error( + "schedule: error calling %s on %s: %o", + methodName, this_._id, err, ); } }, ms); diff --git a/src/torqueScript/reactivity.ts b/src/torqueScript/reactivity.ts index 1c290d7f..ffc2df20 100644 --- a/src/torqueScript/reactivity.ts +++ b/src/torqueScript/reactivity.ts @@ -43,7 +43,10 @@ function buildFieldIndex(rules: readonly ReactiveFieldRule[]): ClassRuleIndex { addRuleValues(anyClassValues, rule.fields); continue; } - addRuleValues(getOrCreateSet(valuesByClass, normalizedClass), rule.fields); + addRuleValues( + getOrCreateSet(valuesByClass, normalizedClass), + rule.fields, + ); } } diff --git a/src/torqueScript/runtime.spec.ts b/src/torqueScript/runtime.spec.ts index 5d8c5555..b06d82a1 100644 --- a/src/torqueScript/runtime.spec.ts +++ b/src/torqueScript/runtime.spec.ts @@ -2729,15 +2729,19 @@ describe("TorqueScript Runtime", () => { runtime.$.deleteObject("LifecycleTest"); await Promise.resolve(); - const mutationEvents = events.filter((event) => event.type !== "batch.flushed"); - expect(mutationEvents.some((event) => event.type === "object.created")).toBe( - true, - ); - expect(mutationEvents.some((event) => event.type === "object.deleted")).toBe( - true, + const mutationEvents = events.filter( + (event) => event.type !== "batch.flushed", ); + expect( + mutationEvents.some((event) => event.type === "object.created"), + ).toBe(true); + expect( + mutationEvents.some((event) => event.type === "object.deleted"), + ).toBe(true); - const batchEvents = events.filter((event) => event.type === "batch.flushed"); + const batchEvents = events.filter( + (event) => event.type === "batch.flushed", + ); expect(batchEvents.length).toBeGreaterThan(0); expect( batchEvents.some((batch) => @@ -2768,7 +2772,9 @@ describe("TorqueScript Runtime", () => { runtime.$.setProp(obj, "customvalue", "after"); await Promise.resolve(); - const fieldEvents = events.filter((event) => event.type === "field.changed"); + const fieldEvents = events.filter( + (event) => event.type === "field.changed", + ); expect(fieldEvents.length).toBe(1); expect(fieldEvents[0].field).toBe("position"); expect(fieldEvents[0].value).toBe("10 20 30"); @@ -2793,7 +2799,9 @@ describe("TorqueScript Runtime", () => { runtime.$.call(obj, "doNothing"); await Promise.resolve(); - const methodEvents = events.filter((event) => event.type === "method.called"); + const methodEvents = events.filter( + (event) => event.type === "method.called", + ); expect(methodEvents.length).toBe(1); expect(methodEvents[0].className).toBe("sceneobject"); expect(methodEvents[0].methodName).toBe("settransform"); @@ -2811,7 +2819,9 @@ describe("TorqueScript Runtime", () => { runtime.$g.set("customState", 123); await Promise.resolve(); - const globalEvents = events.filter((event) => event.type === "global.changed"); + const globalEvents = events.filter( + (event) => event.type === "global.changed", + ); expect(globalEvents.length).toBe(1); expect(globalEvents[0].name).toBe("missionrunning"); expect(globalEvents[0].value).toBe(true); @@ -2830,7 +2840,9 @@ describe("TorqueScript Runtime", () => { runtime.$g.set("customState", 456); await Promise.resolve(); - const globalEvents = events.filter((event) => event.type === "global.changed"); + const globalEvents = events.filter( + (event) => event.type === "global.changed", + ); expect(globalEvents.length).toBe(1); expect(globalEvents[0].name).toBe("customstate"); expect(globalEvents[0].value).toBe(456); diff --git a/src/torqueScript/runtime.ts b/src/torqueScript/runtime.ts index c410ba41..5e272973 100644 --- a/src/torqueScript/runtime.ts +++ b/src/torqueScript/runtime.ts @@ -1,7 +1,10 @@ import picomatch from "picomatch"; +import { createLogger } from "../logger"; import { generate } from "./codegen"; import { parse, type Program } from "./index"; import { createBuiltins as defaultCreateBuiltins } from "./builtins"; + +const log = createLogger("runtime"); import { createReactiveFieldMatcher, createReactiveGlobalMatcher, @@ -76,8 +79,10 @@ export function createRuntime( const reactiveGlobalNames = options.reactiveGlobalNames ?? DEFAULT_REACTIVE_GLOBAL_NAMES; const matchesReactiveField = createReactiveFieldMatcher(reactiveFieldRules); - const matchesReactiveMethod = createReactiveMethodMatcher(reactiveMethodRules); - const matchesReactiveGlobal = createReactiveGlobalMatcher(reactiveGlobalNames); + const matchesReactiveMethod = + createReactiveMethodMatcher(reactiveMethodRules); + const matchesReactiveGlobal = + createReactiveGlobalMatcher(reactiveGlobalNames); const methods = new CaseInsensitiveMap>(); const functions = new CaseInsensitiveMap(); const packages = new CaseInsensitiveMap(); @@ -251,9 +256,7 @@ export function createRuntime( return; } - if ( - !matchesReactiveField(getObjectClassChain(obj), normalizedField) - ) { + if (!matchesReactiveField(getObjectClassChain(obj), normalizedField)) { return; } @@ -1221,7 +1224,7 @@ export function createRuntime( } // Match TorqueScript behavior: warn and return empty string - console.warn( + log.warn( `Unknown function: ${name}(${args .map((a) => JSON.stringify(a)) .join(", ")})`, @@ -1299,8 +1302,8 @@ export function createRuntime( const loader = options.loadScript; if (!loader) { if (scriptsToLoad.length > 0) { - console.warn( - `Script has exec() calls but no loadScript provided:`, + log.warn( + "Script has exec() calls but no loadScript provided: %o", scriptsToLoad, ); } @@ -1321,7 +1324,7 @@ export function createRuntime( // Skip if script matches ignore patterns if (isIgnoredScript && isIgnoredScript(normalized)) { - console.warn(`Ignoring script: ${ref}`); + log.warn("Ignoring script: %s", ref); state.failedScripts.add(normalized); return; } @@ -1346,7 +1349,7 @@ export function createRuntime( // Pass original path to loader - it handles its own normalization const source = await loader(ref); if (source == null) { - console.warn(`Script not found: ${ref}`); + log.warn("Script not found: %s", ref); state.failedScripts.add(normalized); options.progress?.completeItem(); return; @@ -1356,7 +1359,7 @@ export function createRuntime( try { depAst = parse(source, { filename: ref }); } catch (err) { - console.warn(`Failed to parse script: ${ref}`, err); + log.warn("Failed to parse script: %s %o", ref, err); state.failedScripts.add(normalized); options.progress?.completeItem(); return; diff --git a/src/torqueScript/scriptLoader.browser.ts b/src/torqueScript/scriptLoader.browser.ts index a9025b06..24e44360 100644 --- a/src/torqueScript/scriptLoader.browser.ts +++ b/src/torqueScript/scriptLoader.browser.ts @@ -1,6 +1,9 @@ import type { ScriptLoader } from "./types"; +import { createLogger } from "../logger"; import { getUrlForPath } from "../loaders"; +const log = createLogger("scriptLoader"); + /** * Creates a script loader for browser environments that fetches scripts * using the manifest-based URL resolution. @@ -11,20 +14,19 @@ export function createScriptLoader(): ScriptLoader { try { url = getUrlForPath(path); } catch (err) { - console.warn(`Script not in manifest: ${path} (${err})`); + log.warn("Script not in manifest: %s (%s)", path, err); return null; } try { const response = await fetch(url); if (!response.ok) { - console.error(`Script fetch failed: ${path} (${response.status})`); + log.error("Script fetch failed: %s (%d)", path, response.status); return null; } return await response.text(); } catch (err) { - console.error(`Script fetch error: ${path}`); - console.error(err); + log.error("Script fetch error: %s %o", path, err); return null; } }; diff --git a/src/torqueScript/shapeConstructor.ts b/src/torqueScript/shapeConstructor.ts index 168fd72b..0d900433 100644 --- a/src/torqueScript/shapeConstructor.ts +++ b/src/torqueScript/shapeConstructor.ts @@ -7,7 +7,6 @@ import type { TorqueRuntime } from "./types"; */ export type SequenceAliasMap = Map>; - /** * Build sequence alias maps from TSShapeConstructor datablocks already * registered in the runtime. Each datablock has `baseshape` and @@ -19,7 +18,9 @@ export type SequenceAliasMap = Map>; * extension from the DSQ filename, matching the Blender addon's * `dsq_name_from_filename` behavior. */ -export function buildSequenceAliasMap(runtime: TorqueRuntime): SequenceAliasMap { +export function buildSequenceAliasMap( + runtime: TorqueRuntime, +): SequenceAliasMap { const result: SequenceAliasMap = new Map(); for (const obj of runtime.state.datablocks.values()) { @@ -44,7 +45,10 @@ export function buildSequenceAliasMap(runtime: TorqueRuntime): SequenceAliasMap if (spaceIdx === -1) continue; const dsqFile = value.slice(0, spaceIdx).toLowerCase(); - const alias = value.slice(spaceIdx + 1).trim().toLowerCase(); + const alias = value + .slice(spaceIdx + 1) + .trim() + .toLowerCase(); if (!alias) continue; // Strip prefix and .dsq to get the GLB clip name. @@ -65,7 +69,6 @@ export function buildSequenceAliasMap(runtime: TorqueRuntime): SequenceAliasMap return result; } - /** * Build a case-insensitive action map from GLB clips, augmented with * TSShapeConstructor aliases. Both the original clip name and the alias