move non-Next components out of app folder

This commit is contained in:
Brian Beck 2025-11-14 17:23:16 -08:00
parent fdd27b26d7
commit beade00727
14 changed files with 41 additions and 44 deletions

View file

@ -0,0 +1,87 @@
import { getResourceList } from "../manifest";
import { useSettings } from "./SettingsProvider";
const excludeMissions = new Set([
"SkiFree",
"SkiFree_Daily",
"SkiFree_Randomizer",
]);
const missions = getResourceList()
.map((resourcePath) => resourcePath.match(/^missions\/(.+)\.mis$/))
.filter(Boolean)
.map((match) => match[1])
.filter((name) => !excludeMissions.has(name));
export function InspectorControls({
missionName,
onChangeMission,
}: {
missionName: string;
onChangeMission: (name: string) => void;
}) {
const {
fogEnabled,
setFogEnabled,
speedMultiplier,
setSpeedMultiplier,
fov,
setFov,
} = useSettings();
return (
<div
id="controls"
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
<select
id="missionList"
value={missionName}
onChange={(event) => onChangeMission(event.target.value)}
>
{missions.map((missionName) => (
<option key={missionName}>{missionName}</option>
))}
</select>
<div className="CheckboxField">
<input
id="fogInput"
type="checkbox"
checked={fogEnabled}
onChange={(event) => {
setFogEnabled(event.target.checked);
}}
/>
<label htmlFor="fogInput">Fog?</label>
</div>
<div className="Field">
<label htmlFor="fovInput">FOV</label>
<input
id="speedInput"
type="range"
min={75}
max={120}
step={5}
value={fov}
onChange={(event) => setFov(parseInt(event.target.value))}
/>
<output htmlFor="speedInput">{fov}</output>
</div>
<div className="Field">
<label htmlFor="speedInput">Speed</label>
<input
id="speedInput"
type="range"
min={0.1}
max={5}
step={0.05}
value={speedMultiplier}
onChange={(event) =>
setSpeedMultiplier(parseFloat(event.target.value))
}
/>
</div>
</div>
);
}

View file

@ -0,0 +1,108 @@
import { memo, Suspense, useMemo } from "react";
import { Mesh } from "three";
import { useGLTF, useTexture } from "@react-three/drei";
import { BASE_URL, interiorTextureToUrl, interiorToUrl } from "../loaders";
import {
ConsoleObject,
getPosition,
getProperty,
getRotation,
getScale,
} from "../mission";
import { setupColor } from "../textureUtils";
const FALLBACK_URL = `${BASE_URL}/black.png`;
/**
* Load a .gltf file that was converted from a .dif, used for "interior" models.
*/
function useInterior(interiorFile: string) {
const url = interiorToUrl(interiorFile);
return useGLTF(url);
}
function InteriorTexture({ materialName }: { materialName: string }) {
let url = FALLBACK_URL;
try {
url = interiorTextureToUrl(materialName);
} catch (err) {
console.error(err);
}
const texture = useTexture(url, (texture) => setupColor(texture));
return <meshStandardMaterial map={texture} side={2} />;
}
function InteriorMesh({ node }: { node: Mesh }) {
return (
<mesh geometry={node.geometry} castShadow receiveShadow>
{node.material ? (
<Suspense
fallback={
// Allow the mesh to render while the texture is still loading;
// show a wireframe placeholder.
<meshStandardMaterial color="yellow" wireframe />
}
>
{Array.isArray(node.material) ? (
node.material.map((mat, index) => (
<InteriorTexture key={index} materialName={mat.name} />
))
) : (
<InteriorTexture materialName={node.material.name} />
)}
</Suspense>
) : null}
</mesh>
);
}
export const InteriorModel = memo(
({ interiorFile }: { interiorFile: string }) => {
const { nodes } = useInterior(interiorFile);
return (
<>
{Object.entries(nodes)
.filter(
([name, node]: [string, any]) =>
!node.material || !node.material.name.match(/\.\d+$/)
)
.map(([name, node]: [string, any]) => (
<InteriorMesh key={name} node={node} />
))}
</>
);
}
);
function InteriorPlaceholder() {
return (
<mesh>
<boxGeometry args={[10, 10, 10]} />
<meshStandardMaterial color="orange" wireframe />
</mesh>
);
}
export const InteriorInstance = memo(
({ object }: { object: ConsoleObject }) => {
const interiorFile = getProperty(object, "interiorFile").value;
const [z, y, x] = useMemo(() => getPosition(object), [object]);
const [scaleX, scaleY, scaleZ] = useMemo(() => getScale(object), [object]);
const q = useMemo(() => getRotation(object, true), [object]);
return (
<group
quaternion={q}
position={[x - 1024, y, z - 1024]}
scale={[-scaleX, scaleY, -scaleZ]}
>
<Suspense fallback={<InteriorPlaceholder />}>
<InteriorModel interiorFile={interiorFile} />
</Suspense>
</group>
);
}
);

View file

@ -0,0 +1,20 @@
import { useQuery } from "@tanstack/react-query";
import { loadMission } from "../loaders";
import { renderObject } from "./renderObject";
function useMission(name: string) {
return useQuery({
queryKey: ["mission", name],
queryFn: () => loadMission(name),
});
}
export function Mission({ name }: { name: string }) {
const { data: mission } = useMission(name);
if (!mission) {
return null;
}
return <>{mission.objects.map((object, i) => renderObject(object, i))}</>;
}

View file

@ -0,0 +1,10 @@
import { PerspectiveCamera } from "@react-three/drei";
import { useSettings } from "./SettingsProvider";
export function ObserverCamera() {
const { fov } = useSettings();
return (
<PerspectiveCamera makeDefault position={[-512, 256, -512]} fov={fov} />
);
}

View file

@ -0,0 +1,142 @@
import { useEffect, useRef } from "react";
import { Vector3 } from "three";
import { useFrame, useThree } from "@react-three/fiber";
import { KeyboardControls, useKeyboardControls } from "@react-three/drei";
import { PointerLockControls } from "three-stdlib";
import { useSettings } from "./SettingsProvider";
enum Controls {
forward = "forward",
backward = "backward",
left = "left",
right = "right",
up = "up",
down = "down",
}
const BASE_SPEED = 100; // units per second
const MIN_SPEED_ADJUSTMENT = 0.05;
const MAX_SPEED_ADJUSTMENT = 1;
function CameraMovement() {
const { speedMultiplier, setSpeedMultiplier } = useSettings();
const [subscribe, getKeys] = useKeyboardControls<Controls>();
const { camera, gl } = useThree();
const controlsRef = useRef<PointerLockControls | null>(null);
// Scratch vectors to avoid allocations each frame
const forwardVec = useRef(new Vector3());
const sideVec = useRef(new Vector3());
const moveVec = useRef(new Vector3());
// Setup pointer lock controls
useEffect(() => {
const controls = new PointerLockControls(camera, gl.domElement);
controlsRef.current = controls;
const handleClick = (e: MouseEvent) => {
// Only lock if clicking directly on the canvas (not on UI elements)
if (e.target === gl.domElement) {
controls.lock();
}
};
gl.domElement.addEventListener("click", handleClick);
return () => {
gl.domElement.removeEventListener("click", handleClick);
controls.dispose();
};
}, [camera, gl]);
// Handle mousewheel for speed adjustment
useEffect(() => {
const handleWheel = (e: WheelEvent) => {
e.preventDefault();
const direction = e.deltaY > 0 ? -1 : 1;
const delta =
// Helps normalize sensitivity; trackpad scrolling will have many small
// updates while mouse wheels have fewer updates but large deltas.
Math.max(
MIN_SPEED_ADJUSTMENT,
Math.min(MAX_SPEED_ADJUSTMENT, Math.abs(e.deltaY * 0.01))
) * direction;
setSpeedMultiplier((prev) => {
const newSpeed = Math.round((prev + delta) * 20) / 20;
return Math.max(0.1, Math.min(5, newSpeed));
});
};
const canvas = gl.domElement;
canvas.addEventListener("wheel", handleWheel, { passive: false });
return () => {
canvas.removeEventListener("wheel", handleWheel);
};
}, [gl]);
useFrame((_, delta) => {
const { forward, backward, left, right, up, down } = getKeys();
if (!forward && !backward && !left && !right && !up && !down) {
return;
}
const speed = BASE_SPEED * speedMultiplier;
// Forward/backward: take complete camera angle into account (including Y)
camera.getWorldDirection(forwardVec.current);
forwardVec.current.normalize();
// Left/right: move along XZ plane
sideVec.current.crossVectors(camera.up, forwardVec.current).normalize();
moveVec.current.set(0, 0, 0);
if (forward) {
moveVec.current.add(forwardVec.current);
}
if (backward) {
moveVec.current.sub(forwardVec.current);
}
if (left) {
moveVec.current.add(sideVec.current);
}
if (right) {
moveVec.current.sub(sideVec.current);
}
if (up) {
moveVec.current.y += 1;
}
if (down) {
moveVec.current.y -= 1;
}
if (moveVec.current.lengthSq() > 0) {
moveVec.current.normalize().multiplyScalar(speed * delta);
camera.position.add(moveVec.current);
}
});
return null;
}
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"] },
];
export function ObserverControls() {
return (
<KeyboardControls map={KEYBOARD_CONTROLS}>
<CameraMovement />
</KeyboardControls>
);
}

View file

@ -0,0 +1,70 @@
import React, { useContext, useEffect, useMemo, useState } from "react";
const SettingsContext = React.createContext(null);
type PersistedSettings = {
fogEnabled?: boolean;
speedMultiplier?: number;
fov?: number;
};
export function useSettings() {
return useContext(SettingsContext);
}
export function SettingsProvider({ children }: { children: React.ReactNode }) {
const [fogEnabled, setFogEnabled] = useState(true);
const [speedMultiplier, setSpeedMultiplier] = useState(1);
const [fov, setFov] = useState(90);
const value = useMemo(
() => ({
fogEnabled,
setFogEnabled,
speedMultiplier,
setSpeedMultiplier,
fov,
setFov,
}),
[fogEnabled, speedMultiplier, fov]
);
// Read persisted settings from localStoarge.
useEffect(() => {
let savedSettings: PersistedSettings = {};
try {
savedSettings = JSON.parse(localStorage.getItem("settings")) || {};
} catch (err) {
// Ignore.
}
if (savedSettings.fogEnabled != null) {
setFogEnabled(savedSettings.fogEnabled);
}
if (savedSettings.speedMultiplier != null) {
setSpeedMultiplier(savedSettings.speedMultiplier);
}
if (savedSettings.fov != null) {
setFov(savedSettings.fov);
}
}, []);
// Persist settings to localStoarge.
useEffect(() => {
const settingsToSave: PersistedSettings = {
fogEnabled,
speedMultiplier,
fov,
};
try {
localStorage.setItem("settings", JSON.stringify(settingsToSave));
} catch (err) {
// Probably forbidden by browser settings.
}
}, [fogEnabled, speedMultiplier, fov]);
return (
<SettingsContext.Provider value={value}>
{children}
</SettingsContext.Provider>
);
}

View file

@ -0,0 +1,6 @@
import { ConsoleObject } from "../mission";
import { renderObject } from "./renderObject";
export function SimGroup({ object }: { object: ConsoleObject }) {
return object.children.map((child, i) => renderObject(child, i));
}

193
src/components/Sky.tsx Normal file
View file

@ -0,0 +1,193 @@
import { Suspense, useMemo, useEffect, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import { useCubeTexture } from "@react-three/drei";
import { Color, ShaderMaterial, BackSide } from "three";
import { ConsoleObject, getProperty } from "../mission";
import { useSettings } from "./SettingsProvider";
import { BASE_URL, getUrlForPath, loadDetailMapList } from "../loaders";
const FALLBACK_URL = `${BASE_URL}/black.png`;
/**
* Load a .dml file, used to list the textures for different faces of a skybox.
*/
function useDetailMapList(name: string) {
return useQuery({
queryKey: ["detailMapList", name],
queryFn: () => loadDetailMapList(name),
});
}
export function SkyBox({
materialList,
fogColor,
fogDistance,
}: {
materialList: string;
fogColor?: Color;
fogDistance?: number;
}) {
const { data: detailMapList } = useDetailMapList(materialList);
const skyBoxFiles = useMemo(
() =>
detailMapList
? [
getUrlForPath(detailMapList[1], FALLBACK_URL), // +x
getUrlForPath(detailMapList[3], FALLBACK_URL), // -x
getUrlForPath(detailMapList[4], FALLBACK_URL), // +y
getUrlForPath(detailMapList[5], FALLBACK_URL), // -y
getUrlForPath(detailMapList[0], FALLBACK_URL), // +z
getUrlForPath(detailMapList[2], FALLBACK_URL), // -z
]
: [
FALLBACK_URL,
FALLBACK_URL,
FALLBACK_URL,
FALLBACK_URL,
FALLBACK_URL,
FALLBACK_URL,
],
[detailMapList]
);
const skyBox = useCubeTexture(skyBoxFiles, { path: "" });
// Create a shader material for the skybox with fog
const materialRef = useRef<ShaderMaterial>(null!);
const hasFog = !!fogColor && !!fogDistance;
const shaderMaterial = useMemo(() => {
if (!hasFog) {
return null;
}
return new ShaderMaterial({
uniforms: {
skybox: { value: skyBox },
fogColor: { value: fogColor },
},
vertexShader: `
varying vec3 vDirection;
void main() {
// Use position directly as direction (no world transform needed)
vDirection = position;
// Transform position but ignore translation
vec4 pos = projectionMatrix * mat4(mat3(modelViewMatrix)) * vec4(position, 1.0);
gl_Position = pos.xyww; // Set depth to far plane
}
`,
fragmentShader: `
uniform samplerCube skybox;
uniform vec3 fogColor;
varying vec3 vDirection;
// Convert linear to sRGB
vec3 linearToSRGB(vec3 color) {
return pow(color, vec3(1.0 / 2.2));
}
void main() {
vec3 direction = normalize(vDirection);
direction.x = -direction.x;
vec4 skyColor = textureCube(skybox, direction);
// Calculate fog factor based on vertical direction
// direction.y: -1 = straight down, 0 = horizon, 1 = straight up
// 100% fog from bottom to horizon, then fade from horizon (0) to 0.4
float fogFactor = smoothstep(0.0, 0.4, direction.y);
// Mix in sRGB space to match Three.js fog rendering
vec3 finalColor = mix(fogColor, skyColor.rgb, fogFactor);
gl_FragColor = vec4(finalColor, 1.0);
}
`,
side: BackSide,
depthWrite: false,
});
}, [skyBox, fogColor, hasFog]);
// Update uniforms when fog parameters change
useEffect(() => {
if (materialRef.current && hasFog && shaderMaterial) {
materialRef.current.uniforms.skybox.value = skyBox;
materialRef.current.uniforms.fogColor.value = fogColor!;
}
}, [skyBox, fogColor, hasFog, shaderMaterial]);
// If fog is disabled, just use the skybox as background
if (!hasFog) {
return <primitive attach="background" object={skyBox} />;
}
return (
<mesh scale={5000}>
<sphereGeometry args={[1, 60, 40]} />
<primitive ref={materialRef} object={shaderMaterial} attach="material" />
</mesh>
);
}
export function Sky({ object }: { object: ConsoleObject }) {
const { fogEnabled } = useSettings();
// Skybox textures.
const materialList = getProperty(object, "materialList")?.value;
// Fog parameters.
// TODO: There can be multiple fog volumes/layers. Render simple fog for now.
const fogDistance = useMemo(() => {
const distanceString = getProperty(object, "fogDistance")?.value;
if (distanceString) {
return parseFloat(distanceString);
}
}, [object]);
const fogColor = useMemo(() => {
const colorString = getProperty(object, "fogColor")?.value;
if (colorString) {
// `colorString` might specify an alpha value, but three.js doesn't
// support opacity on fog or scene backgrounds, so ignore it.
const [r, g, b] = colorString.split(" ").map((s) => parseFloat(s));
return [
new Color().setRGB(r, g, b),
new Color().setRGB(r, g, b).convertSRGBToLinear(),
];
}
}, [object]);
const backgroundColor = fogColor ? (
<color attach="background" args={[fogColor[0]]} />
) : null;
return (
<>
{materialList ? (
// If there's a skybox, its textures will need to load. Render just the
// fog color as the background in the meantime.
<Suspense fallback={backgroundColor}>
<SkyBox
materialList={materialList}
fogColor={fogEnabled ? fogColor[1] : undefined}
fogDistance={fogEnabled ? fogDistance : undefined}
/>
</Suspense>
) : (
// If there's no skybox, just render the fog color as the background.
backgroundColor
)}
{fogEnabled && fogDistance && fogColor ? (
<fog
attach="fog"
color={fogColor[1]}
near={100}
far={Math.max(400, fogDistance * 2)}
/>
) : null}
</>
);
}

48
src/components/Sun.tsx Normal file
View file

@ -0,0 +1,48 @@
import { useMemo } from "react";
import { Color } from "three";
import { ConsoleObject, getProperty } from "../mission";
export function Sun({ object }: { object: ConsoleObject }) {
const direction = useMemo(() => {
const directionStr = getProperty(object, "direction")?.value ?? "0 0 -1";
const [x, y, z] = directionStr.split(" ").map((s) => parseFloat(s));
// Scale the direction vector to position the light far from the scene
const scale = 5000;
return [x * scale, y * scale, z * scale] as [number, number, number];
}, [object]);
const color = useMemo(() => {
const colorStr = getProperty(object, "color")?.value ?? "1 1 1 1";
const [r, g, b] = colorStr.split(" ").map((s) => parseFloat(s));
return [r, g, b] as [number, number, number];
}, [object]);
const ambient = useMemo(() => {
const ambientStr = getProperty(object, "ambient")?.value ?? "0.5 0.5 0.5 1";
const [r, g, b] = ambientStr.split(" ").map((s) => parseFloat(s));
return [r, g, b] as [number, number, number];
}, [object]);
return (
<>
{/* Directional light for the sun */}
{/* <directionalLight
position={[500, 500, 500]}
target-position={direction}
color={color}
intensity={2}
castShadow
shadow-mapSize={[2048, 2048]}
shadow-camera-left={-2000}
shadow-camera-right={2000}
shadow-camera-top={2000}
shadow-camera-bottom={-2000}
shadow-camera-near={0.5}
shadow-camera-far={5000}
shadow-bias={-0.001}
/> */}
{/* Ambient light component */}
<hemisphereLight args={[new Color(...color), new Color(...ambient), 2]} />
</>
);
}

View file

@ -0,0 +1,240 @@
import { Suspense, useCallback, useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import {
DataTexture,
RedFormat,
FloatType,
NoColorSpace,
NearestFilter,
ClampToEdgeWrapping,
UnsignedByteType,
PlaneGeometry,
} from "three";
import { useTexture } from "@react-three/drei";
import { uint16ToFloat32 } from "../arrayUtils";
import { loadTerrain, terrainTextureToUrl } from "../loaders";
import {
ConsoleObject,
getPosition,
getProperty,
getRotation,
getScale,
} from "../mission";
import {
setupColor,
setupMask,
updateTerrainTextureShader,
} from "../textureUtils";
/**
* Load a .ter file, used for terrain heightmap and texture info.
*/
function useTerrain(terrainFile: string) {
return useQuery({
queryKey: ["terrain", terrainFile],
queryFn: () => loadTerrain(terrainFile),
});
}
function BlendedTerrainTextures({
displacementMap,
visibilityMask,
textureNames,
alphaMaps,
}: {
displacementMap: DataTexture;
visibilityMask: DataTexture;
textureNames: string[];
alphaMaps: Uint8Array[];
}) {
const baseTextures = useTexture(
textureNames.map((name) => terrainTextureToUrl(name)),
(textures) => {
textures.forEach((tex) => setupColor(tex));
}
);
const alphaTextures = useMemo(
() => alphaMaps.map((data) => setupMask(data)),
[alphaMaps]
);
const tiling = useMemo(
() => ({
0: 32,
1: 32,
2: 32,
3: 32,
4: 32,
5: 32,
}),
[]
);
const onBeforeCompile = useCallback(
(shader) => {
updateTerrainTextureShader({
shader,
baseTextures,
alphaTextures,
visibilityMask,
tiling,
});
},
[baseTextures, alphaTextures, visibilityMask, tiling]
);
return (
<meshStandardMaterial
// For testing tiling values; forces recompile.
key={JSON.stringify(tiling)}
displacementMap={displacementMap}
map={displacementMap}
displacementScale={2048}
depthWrite
onBeforeCompile={onBeforeCompile}
/>
);
}
function TerrainMaterial({
heightMap,
textureNames,
alphaMaps,
emptySquares,
}: {
heightMap: Uint16Array;
emptySquares: number[];
textureNames: string[];
alphaMaps: Uint8Array[];
}) {
const displacementMap = useMemo(() => {
const f32HeightMap = uint16ToFloat32(heightMap);
const displacementMap = new DataTexture(
f32HeightMap,
256,
256,
RedFormat,
FloatType
);
displacementMap.colorSpace = NoColorSpace;
displacementMap.generateMipmaps = false;
displacementMap.needsUpdate = true;
return displacementMap;
}, [heightMap]);
const visibilityMask: DataTexture | null = useMemo(() => {
if (!emptySquares.length) {
return null;
}
const terrainSize = 256;
// Create a mask texture (1 = visible, 0 = invisible)
const maskData = new Uint8Array(terrainSize * terrainSize);
maskData.fill(255); // Start with everything visible
for (const squareId of emptySquares) {
// The squareId encodes position and count:
// Bits 0-7: X position (starting position)
// Bits 8-15: Y position
// Bits 16+: Count (number of consecutive horizontal squares)
const x = squareId & 0xff;
const y = (squareId >> 8) & 0xff;
const count = squareId >> 16;
for (let i = 0; i < count; i++) {
const px = x + i;
const py = y;
const index = py * terrainSize + px;
if (index >= 0 && index < maskData.length) {
maskData[index] = 0;
}
}
}
const visibilityMask = new DataTexture(
maskData,
terrainSize,
terrainSize,
RedFormat,
UnsignedByteType
);
visibilityMask.colorSpace = NoColorSpace;
visibilityMask.wrapS = visibilityMask.wrapT = ClampToEdgeWrapping;
visibilityMask.magFilter = NearestFilter;
visibilityMask.minFilter = NearestFilter;
visibilityMask.needsUpdate = true;
return visibilityMask;
}, [emptySquares]);
return (
<Suspense
fallback={
// Render a wireframe while the terrain textures load.
<meshStandardMaterial
color="rgb(0, 109, 56)"
displacementMap={displacementMap}
displacementScale={2048}
wireframe
/>
}
>
<BlendedTerrainTextures
displacementMap={displacementMap}
visibilityMask={visibilityMask}
textureNames={textureNames}
alphaMaps={alphaMaps}
/>
</Suspense>
);
}
export function TerrainBlock({ object }: { object: ConsoleObject }) {
const terrainFile: string = getProperty(object, "terrainFile").value;
const emptySquares: number[] = useMemo(() => {
const emptySquaresString: string | undefined = getProperty(
object,
"emptySquares"
)?.value;
return emptySquaresString
? emptySquaresString.split(" ").map((s) => parseInt(s, 10))
: [];
}, [object]);
const position = useMemo(() => getPosition(object), [object]);
const scale = useMemo(() => getScale(object), [object]);
const q = useMemo(() => getRotation(object), [object]);
const planeGeometry = useMemo(() => {
const geometry = new PlaneGeometry(2048, 2048, 256, 256);
geometry.rotateX(-Math.PI / 2);
geometry.rotateY(-Math.PI / 2);
return geometry;
}, []);
const { data: terrain } = useTerrain(terrainFile);
return (
<mesh
quaternion={q}
position={position}
scale={scale}
geometry={planeGeometry}
receiveShadow
castShadow
>
{terrain ? (
<TerrainMaterial
heightMap={terrain.heightMap}
emptySquares={emptySquares}
textureNames={terrain.textureNames}
alphaMaps={terrain.alphaMaps}
/>
) : null}
</mesh>
);
}

View file

@ -0,0 +1,43 @@
import { Suspense, useMemo } from "react";
import { useTexture } from "@react-three/drei";
import { textureToUrl } from "../loaders";
import {
ConsoleObject,
getPosition,
getProperty,
getRotation,
getScale,
} from "../mission";
import { setupColor } from "../textureUtils";
export function WaterMaterial({ surfaceTexture }: { surfaceTexture: string }) {
const url = textureToUrl(surfaceTexture);
const texture = useTexture(url, (texture) => setupColor(texture, [8, 8]));
return <meshStandardMaterial map={texture} transparent opacity={0.8} />;
}
export function WaterBlock({ object }: { object: ConsoleObject }) {
const [z, y, x] = useMemo(() => getPosition(object), [object]);
const [scaleZ, scaleY, scaleX] = useMemo(() => getScale(object), [object]);
const q = useMemo(() => getRotation(object, true), [object]);
const surfaceTexture =
getProperty(object, "surfaceTexture")?.value ?? "liquidTiles/BlueWater";
return (
<mesh
position={[x - 1024 + scaleX / 2, y + scaleY / 2, z - 1024 + scaleZ / 2]}
quaternion={q}
>
<boxGeometry args={[scaleZ, scaleY, scaleX]} />
<Suspense
fallback={
<meshStandardMaterial color="blue" transparent opacity={0.3} />
}
>
<WaterMaterial surfaceTexture={surfaceTexture} />
</Suspense>
</mesh>
);
}

View file

@ -0,0 +1,21 @@
import { ConsoleObject } from "../mission";
import { TerrainBlock } from "./TerrainBlock";
import { WaterBlock } from "./WaterBlock";
import { SimGroup } from "./SimGroup";
import { InteriorInstance } from "./InteriorInstance";
import { Sky } from "./Sky";
import { Sun } from "./Sun";
const componentMap = {
SimGroup,
TerrainBlock,
WaterBlock,
InteriorInstance,
Sky,
Sun,
};
export function renderObject(object: ConsoleObject, key: string | number) {
const Component = componentMap[object.className];
return Component ? <Component key={key} object={object} /> : null;
}