diff --git a/app/page.tsx b/app/page.tsx index 00e1b62d..345c9d70 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -10,6 +10,7 @@ import { InspectorControls } from "@/src/components/InspectorControls"; import { SettingsProvider } from "@/src/components/SettingsProvider"; import { ObserverCamera } from "@/src/components/ObserverCamera"; import { AudioProvider } from "@/src/components/AudioContext"; +import { DebugElements } from "@/src/components/DebugElements"; // 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. @@ -35,11 +36,12 @@ function MapInspector() {
- + + diff --git a/app/style.css b/app/style.css index d5d0e6f4..6165fbfb 100644 --- a/app/style.css +++ b/app/style.css @@ -45,3 +45,15 @@ main { #speedInput { max-width: 80px; } + +.StaticShapeLabel { + background: rgba(0, 0, 0, 0.5); + color: #fff; + font-size: 11px; + white-space: nowrap; +} + +.StatsPanel { + left: auto !important; + right: 0; +} diff --git a/docs/base/@vl2/shapes.vl2/shapes/borg5.glb b/docs/base/@vl2/shapes.vl2/shapes/borg5.glb new file mode 100644 index 00000000..39de358b Binary files /dev/null and b/docs/base/@vl2/shapes.vl2/shapes/borg5.glb differ diff --git a/docs/base/@vl2/shapes.vl2/shapes/porg2.glb b/docs/base/@vl2/shapes.vl2/shapes/porg2.glb new file mode 100644 index 00000000..808ad84f Binary files /dev/null and b/docs/base/@vl2/shapes.vl2/shapes/porg2.glb differ diff --git a/docs/base/@vl2/shapes.vl2/shapes/turret_muzzlepoint.glb b/docs/base/@vl2/shapes.vl2/shapes/turret_muzzlepoint.glb new file mode 100644 index 00000000..c5de9933 Binary files /dev/null and b/docs/base/@vl2/shapes.vl2/shapes/turret_muzzlepoint.glb differ diff --git a/docs/base/@vl2/shapes.vl2/shapes/xorg3.glb b/docs/base/@vl2/shapes.vl2/shapes/xorg3.glb new file mode 100644 index 00000000..223b6d08 Binary files /dev/null and b/docs/base/@vl2/shapes.vl2/shapes/xorg3.glb differ diff --git a/docs/base/@vl2/shapes.vl2/shapes/xorg5.glb b/docs/base/@vl2/shapes.vl2/shapes/xorg5.glb new file mode 100644 index 00000000..834220cb Binary files /dev/null and b/docs/base/@vl2/shapes.vl2/shapes/xorg5.glb differ diff --git a/src/components/AudioEmitter.tsx b/src/components/AudioEmitter.tsx index 1fea892c..d4205d1c 100644 --- a/src/components/AudioEmitter.tsx +++ b/src/components/AudioEmitter.tsx @@ -5,6 +5,7 @@ import { ConsoleObject, getPosition, getProperty } from "../mission"; import { audioToUrl } from "../loaders"; import { useAudio } from "./AudioContext"; import { useSettings } from "./SettingsProvider"; +import { FloatingLabel } from "./FloatingLabel"; // Global audio buffer cache const audioBufferCache = new Map(); @@ -204,12 +205,17 @@ export function AudioEmitter({ object }: { object: ConsoleObject }) { return debugMode ? ( - + + {fileName} + ) : null; } diff --git a/src/components/DebugElements.tsx b/src/components/DebugElements.tsx new file mode 100644 index 00000000..4a80dbab --- /dev/null +++ b/src/components/DebugElements.tsx @@ -0,0 +1,12 @@ +import { Stats } from "@react-three/drei"; +import { useSettings } from "./SettingsProvider"; + +export function DebugElements() { + const { debugMode } = useSettings(); + + return debugMode ? ( + <> + + + ) : null; +} diff --git a/src/components/FloatingLabel.tsx b/src/components/FloatingLabel.tsx new file mode 100644 index 00000000..23d304dd --- /dev/null +++ b/src/components/FloatingLabel.tsx @@ -0,0 +1,58 @@ +import { ReactNode, useEffect, useRef, useState } from "react"; +import { Object3D } from "three"; +import { useDistanceFromCamera } from "./useDistanceFromCamera"; +import { useFrame } from "@react-three/fiber"; +import { Html } from "@react-three/drei"; + +const DEFAULT_POSITION = [0, 0, 0] as [x: number, y: number, z: number]; + +export function FloatingLabel({ + children, + color = "white", + position = DEFAULT_POSITION, +}: { + children: ReactNode; + color?: string; + position?: [x: number, y: number, z: number]; +}) { + const groupRef = useRef(null); + const distanceRef = useDistanceFromCamera(groupRef); + const [isVisible, setIsVisible] = useState(false); + const labelRef = useRef(null); + + // Initialize opacity when label ref is attached + useEffect(() => { + if (labelRef.current && distanceRef.current != null) { + const opacity = Math.max(0, Math.min(1, 1 - distanceRef.current / 200)); + labelRef.current.style.opacity = opacity.toString(); + } + }, [isVisible]); + + useFrame(() => { + const distance = distanceRef.current; + const shouldBeVisible = distance != null && distance < 200; + + // Update visibility state only when crossing threshold + if (isVisible !== shouldBeVisible) { + setIsVisible(shouldBeVisible); + } + + // Update opacity directly on DOM element (no re-render) + if (labelRef.current && shouldBeVisible) { + const opacity = Math.max(0, Math.min(1, 1 - distance / 200)); + labelRef.current.style.opacity = opacity.toString(); + } + }); + + return ( + + {isVisible ? ( + +
+ {children} +
+ + ) : null} +
+ ); +} diff --git a/src/components/GenericShape.tsx b/src/components/GenericShape.tsx index 5e7044cd..107e00be 100644 --- a/src/components/GenericShape.tsx +++ b/src/components/GenericShape.tsx @@ -1,4 +1,4 @@ -import { Suspense } from "react"; +import { Suspense, useMemo } from "react"; import { useGLTF, useTexture } from "@react-three/drei"; import { BASE_URL, shapeTextureToUrl, shapeToUrl } from "../loaders"; import { filterGeometryByVertexGroups, getHullBoneIndices } from "../meshUtils"; @@ -8,6 +8,9 @@ import { } from "../shaderMaterials"; import { MeshStandardMaterial } from "three"; import { setupColor } from "../textureUtils"; +import { useSettings } from "./SettingsProvider"; +import { useShapeInfo } from "./ShapeInfoProvider"; +import { FloatingLabel } from "./FloatingLabel"; const FALLBACK_URL = `${BASE_URL}/black.png`; @@ -27,7 +30,7 @@ export function ShapeTexture({ shapeName?: string; }) { const url = shapeTextureToUrl(material.name, FALLBACK_URL); - const isOrganic = shapeName && /borg|xorg/i.test(shapeName); + const isOrganic = shapeName && /borg|xorg|porg/i.test(shapeName); const texture = useTexture(url, (texture) => { if (!isOrganic) { @@ -36,80 +39,23 @@ export function ShapeTexture({ return setupColor(texture); }); - // Only use alpha-as-roughness material for borg shapes - if (!isOrganic) { - const shaderMaterial = createAlphaAsRoughnessMaterial(); - shaderMaterial.map = texture; - return ; - } + const customMaterial = useMemo(() => { + // Only use alpha-as-roughness material for borg shapes + if (!isOrganic) { + const shaderMaterial = createAlphaAsRoughnessMaterial(); + shaderMaterial.map = texture; + return shaderMaterial; + } - // For non-borg shapes, use the original GLTF material with updated texture - const clonedMaterial = material.clone(); - clonedMaterial.map = texture; - clonedMaterial.transparent = true; - clonedMaterial.alphaTest = 0.9; - return ; -} + // For non-borg shapes, use the original GLTF material with updated texture + const clonedMaterial = material.clone(); + clonedMaterial.map = texture; + clonedMaterial.transparent = true; + clonedMaterial.alphaTest = 0.9; + return clonedMaterial; + }, [material, texture, isOrganic]); -export function ShapeModel({ shapeName }: { shapeName: string }) { - const { nodes } = useStaticShape(shapeName); - - let hullBoneIndices = new Set(); - const skeletonsFound = Object.values(nodes).filter( - (node: any) => node.skeleton - ); - - if (skeletonsFound.length > 0) { - const skeleton = (skeletonsFound[0] as any).skeleton; - hullBoneIndices = getHullBoneIndices(skeleton); - } - - return ( - <> - {Object.entries(nodes) - .filter( - ([name, node]: [string, any]) => - node.material && - node.material.name !== "Unassigned" && - !node.name.match(/^Hulk/i) - ) - .map(([name, node]: [string, any]) => { - const geometry = filterGeometryByVertexGroups( - node.geometry, - hullBoneIndices - ); - - return ( - - {node.material ? ( - - } - > - {Array.isArray(node.material) ? ( - node.material.map((mat, index) => ( - - )) - ) : ( - - )} - - ) : null} - - ); - })} - - ); + return ; } export function ShapePlaceholder({ color }: { color: string }) { @@ -120,3 +66,74 @@ export function ShapePlaceholder({ color }: { color: string }) { ); } + +export type StaticShapeType = "StaticShape" | "TSStatic" | "Item" | "Turret"; + +export function ShapeModel() { + const { shapeName } = useShapeInfo(); + const { debugMode } = useSettings(); + const { nodes } = useStaticShape(shapeName); + + const hullBoneIndices = useMemo(() => { + const skeletonsFound = Object.values(nodes).filter( + (node: any) => node.skeleton + ); + + if (skeletonsFound.length > 0) { + const skeleton = (skeletonsFound[0] as any).skeleton; + return getHullBoneIndices(skeleton); + } + return new Set(); + }, [nodes]); + + const processedNodes = useMemo(() => { + return Object.entries(nodes) + .filter( + ([name, node]: [string, any]) => + node.material && + node.material.name !== "Unassigned" && + !node.name.match(/^Hulk/i) + ) + .map(([name, node]: [string, any]) => { + const geometry = filterGeometryByVertexGroups( + node.geometry, + hullBoneIndices + ); + return { node, geometry }; + }); + }, [nodes, hullBoneIndices]); + + return ( + <> + {processedNodes.map(({ node, geometry }) => ( + + {node.material ? ( + + } + > + {Array.isArray(node.material) ? ( + node.material.map((mat, index) => ( + + )) + ) : ( + + )} + + ) : null} + + ))} + {debugMode ? {shapeName} : null} + + ); +} diff --git a/src/components/Item.tsx b/src/components/Item.tsx index c8c3e25e..1804790a 100644 --- a/src/components/Item.tsx +++ b/src/components/Item.tsx @@ -8,6 +8,7 @@ import { getScale, } from "../mission"; import { ShapeModel, ShapePlaceholder } from "./GenericShape"; +import { ShapeInfoProvider } from "./ShapeInfoProvider"; const dataBlockToShapeName = { AmmoPack: "pack_upgrade_ammo.dts", @@ -67,20 +68,22 @@ export function Item({ object }: { object: ConsoleObject }) { } return ( - - {shapeName ? ( - }> - }> - - - - ) : ( - - )} - + + + {shapeName ? ( + }> + }> + + + + ) : ( + + )} + + ); } diff --git a/src/components/SettingsProvider.tsx b/src/components/SettingsProvider.tsx index 2599e8ea..db563fa7 100644 --- a/src/components/SettingsProvider.tsx +++ b/src/components/SettingsProvider.tsx @@ -4,6 +4,7 @@ import { useContext, useEffect, useMemo, + useRef, useState, } from "react"; @@ -14,6 +15,7 @@ type PersistedSettings = { speedMultiplier?: number; fov?: number; audioEnabled?: boolean; + debugMode?: boolean; }; export function useSettings() { @@ -51,6 +53,12 @@ export function SettingsProvider({ children }: { children: ReactNode }) { } catch (err) { // Ignore. } + if (savedSettings.debugMode != null) { + setDebugMode(savedSettings.debugMode); + } + if (savedSettings.audioEnabled != null) { + setAudioEnabled(savedSettings.audioEnabled); + } if (savedSettings.fogEnabled != null) { setFogEnabled(savedSettings.fogEnabled); } @@ -62,19 +70,37 @@ export function SettingsProvider({ children }: { children: ReactNode }) { } }, []); - // Persist settings to localStoarge. + // Persist settings to localStorage with debouncing to avoid excessive writes + const saveTimerRef = useRef | null>(null); + useEffect(() => { - const settingsToSave: PersistedSettings = { - fogEnabled, - speedMultiplier, - fov, - }; - try { - localStorage.setItem("settings", JSON.stringify(settingsToSave)); - } catch (err) { - // Probably forbidden by browser settings. + // Clear any pending save + if (saveTimerRef.current) { + clearTimeout(saveTimerRef.current); } - }, [fogEnabled, speedMultiplier, fov]); + + // Debounce localStorage writes (wait 300ms after last change) + saveTimerRef.current = setTimeout(() => { + const settingsToSave: PersistedSettings = { + fogEnabled, + speedMultiplier, + fov, + audioEnabled, + debugMode, + }; + try { + localStorage.setItem("settings", JSON.stringify(settingsToSave)); + } catch (err) { + // Probably forbidden by browser settings. + } + }, 500); + + return () => { + if (saveTimerRef.current) { + clearTimeout(saveTimerRef.current); + } + }; + }, [fogEnabled, speedMultiplier, fov, audioEnabled, debugMode]); return ( diff --git a/src/components/ShapeInfoProvider.tsx b/src/components/ShapeInfoProvider.tsx new file mode 100644 index 00000000..a1410153 --- /dev/null +++ b/src/components/ShapeInfoProvider.tsx @@ -0,0 +1,27 @@ +import { createContext, ReactNode, useContext, useMemo } from "react"; + +export type StaticShapeType = "TSStatic" | "StaticShape" | "Item" | "Turret"; + +const ShapeInfoContext = createContext(null); + +export function useShapeInfo() { + return useContext(ShapeInfoContext); +} + +export function ShapeInfoProvider({ + children, + shapeName, + type, +}: { + children: ReactNode; + shapeName: string; + type: StaticShapeType; +}) { + const context = useMemo(() => ({ shapeName, type }), [shapeName, type]); + + return ( + + {children} + + ); +} diff --git a/src/components/StaticShape.tsx b/src/components/StaticShape.tsx index c4e5856a..2a2bc0ab 100644 --- a/src/components/StaticShape.tsx +++ b/src/components/StaticShape.tsx @@ -8,6 +8,7 @@ import { getScale, } from "../mission"; import { ShapeModel, ShapePlaceholder } from "./GenericShape"; +import { ShapeInfoProvider } from "./ShapeInfoProvider"; const dataBlockToShapeName = { Banner_Honor: "banner_honor.dts", @@ -57,20 +58,22 @@ export function StaticShape({ object }: { object: ConsoleObject }) { } return ( - - {shapeName ? ( - }> - }> - - - - ) : ( - - )} - + + + {shapeName ? ( + }> + }> + + + + ) : ( + + )} + + ); } diff --git a/src/components/TSStatic.tsx b/src/components/TSStatic.tsx index cee5108c..d2a64cb1 100644 --- a/src/components/TSStatic.tsx +++ b/src/components/TSStatic.tsx @@ -8,6 +8,7 @@ import { getScale, } from "../mission"; import { ShapeModel, ShapePlaceholder } from "./GenericShape"; +import { ShapeInfoProvider } from "./ShapeInfoProvider"; export function TSStatic({ object }: { object: ConsoleObject }) { const shapeName = getProperty(object, "shapeName").value; @@ -16,17 +17,23 @@ export function TSStatic({ object }: { object: ConsoleObject }) { const [scaleX, scaleY, scaleZ] = useMemo(() => getScale(object), [object]); const q = useMemo(() => getRotation(object, true), [object]); + if (!shapeName) { + console.error(" missing shapeName for object", object); + } + return ( - - }> - }> - - - - + + + }> + }> + + + + + ); } diff --git a/src/components/Turret.tsx b/src/components/Turret.tsx index 09efb7fb..1615eeea 100644 --- a/src/components/Turret.tsx +++ b/src/components/Turret.tsx @@ -8,6 +8,7 @@ import { getScale, } from "../mission"; import { ShapeModel, ShapePlaceholder } from "./GenericShape"; +import { ShapeInfoProvider } from "./ShapeInfoProvider"; const dataBlockToShapeName = { AABarrelLarge: "turret_aa_large.dts", @@ -17,6 +18,7 @@ const dataBlockToShapeName = { PlasmaBarrelLarge: "turret_fusion_large.dts", SentryTurret: "turret_sentry.dts", TurretBaseLarge: "turret_base_large.dts", + SentryTurretBarrel: "turret_muzzlepoint.dts", }; let _caseInsensitiveLookup: Record; @@ -46,33 +48,42 @@ export function Turret({ object }: { object: ConsoleObject }) { if (!shapeName) { console.error(` missing shape for dataBlock: ${dataBlock}`); } + if (!barrelShapeName) { + console.error( + ` missing shape for initialBarrel dataBlock: ${initialBarrel}` + ); + } return ( - - {shapeName ? ( - }> - }> - - - - ) : ( - - )} - - {barrelShapeName ? ( + + + {shapeName ? ( }> }> - + ) : ( )} + + + {barrelShapeName ? ( + }> + }> + + + + ) : ( + + )} + + - + ); } diff --git a/src/components/useDistanceFromCamera.ts b/src/components/useDistanceFromCamera.ts new file mode 100644 index 00000000..5936a7c7 --- /dev/null +++ b/src/components/useDistanceFromCamera.ts @@ -0,0 +1,22 @@ +import { useFrame, useThree } from "@react-three/fiber"; +import { RefObject, useRef } from "react"; +import { Object3D } from "three"; +import { useWorldPosition } from "./useWorldPosition"; + +export function useDistanceFromCamera( + ref: RefObject +): RefObject { + const { camera } = useThree(); + const distanceRef = useRef(null); + const worldPosRef = useWorldPosition(ref); + + useFrame(() => { + if (!worldPosRef.current) { + distanceRef.current = null; + } else { + distanceRef.current = camera.position.distanceTo(worldPosRef.current); + } + }); + + return distanceRef; +} diff --git a/src/components/useWorldPosition.ts b/src/components/useWorldPosition.ts new file mode 100644 index 00000000..2a34582a --- /dev/null +++ b/src/components/useWorldPosition.ts @@ -0,0 +1,18 @@ +import { useFrame } from "@react-three/fiber"; +import { useRef, RefObject } from "react"; +import { Object3D, Vector3 } from "three"; + +export function useWorldPosition( + ref: RefObject +): RefObject { + const worldPositionRef = useRef(null); + + useFrame(() => { + if (ref.current) { + worldPositionRef.current ??= new Vector3(); + ref.current.getWorldPosition(worldPositionRef.current); + } + }); + + return worldPositionRef; +} diff --git a/src/loaders.ts b/src/loaders.ts index c594430a..61afab51 100644 --- a/src/loaders.ts +++ b/src/loaders.ts @@ -51,6 +51,7 @@ export function textureFrameToUrl(fileName: string) { } export function shapeTextureToUrl(name: string, fallbackUrl?: string) { + name = name.replace(/^skins\\/, ""); name = name.replace(/\.\d+$/, ""); return getUrlForPath(`textures/skins/${name}.png`, fallbackUrl); } diff --git a/src/manifest.ts b/src/manifest.ts index d3e7a631..fc9d48d4 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -9,7 +9,18 @@ export function getSource(resourcePath: string) { } } +const _resourcePathCache = new Map(); + export function getActualResourcePath(resourcePath: string) { + if (_resourcePathCache.has(resourcePath)) { + return _resourcePathCache.get(resourcePath); + } + const actualResourcePath = getActualResourcePathUncached(resourcePath); + _resourcePathCache.set(resourcePath, actualResourcePath); + return actualResourcePath; +} + +export function getActualResourcePathUncached(resourcePath: string) { if (manifest[resourcePath]) { return resourcePath; } @@ -57,8 +68,10 @@ export function getActualResourcePath(resourcePath: string) { return resourcePath; } +const _cachedResourceList = Object.keys(manifest).sort(); + export function getResourceList() { - return Object.keys(manifest).sort(); + return _cachedResourceList; } export function getFilePath(resourcePath: string) {