mirror of
https://github.com/exogen/t2-mapper.git
synced 2026-02-20 07:03:48 +00:00
move non-Next components out of app folder
This commit is contained in:
parent
fdd27b26d7
commit
beade00727
14 changed files with 41 additions and 44 deletions
87
src/components/InspectorControls.tsx
Normal file
87
src/components/InspectorControls.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
108
src/components/InteriorInstance.tsx
Normal file
108
src/components/InteriorInstance.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
);
|
||||
20
src/components/Mission.tsx
Normal file
20
src/components/Mission.tsx
Normal 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))}</>;
|
||||
}
|
||||
10
src/components/ObserverCamera.tsx
Normal file
10
src/components/ObserverCamera.tsx
Normal 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} />
|
||||
);
|
||||
}
|
||||
142
src/components/ObserverControls.tsx
Normal file
142
src/components/ObserverControls.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
70
src/components/SettingsProvider.tsx
Normal file
70
src/components/SettingsProvider.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
6
src/components/SimGroup.tsx
Normal file
6
src/components/SimGroup.tsx
Normal 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
193
src/components/Sky.tsx
Normal 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
48
src/components/Sun.tsx
Normal 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]} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
240
src/components/TerrainBlock.tsx
Normal file
240
src/components/TerrainBlock.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
src/components/WaterBlock.tsx
Normal file
43
src/components/WaterBlock.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
src/components/renderObject.tsx
Normal file
21
src/components/renderObject.tsx
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue