Merge branch 'develop' into fix/repair-game
This commit is contained in:
@@ -15,24 +15,11 @@ import { SkyModel } from "@/components/three/world/SkyModel";
|
||||
import { CloudSystem } from "@/world/clouds/CloudSystem";
|
||||
import { FogSystem } from "@/world/fog/FogSystem";
|
||||
import { GrassSystem } from "@/world/grass/GrassSystem";
|
||||
import { SceneShadowWarmup } from "@/world/SceneShadowWarmup";
|
||||
import { VegetationSystem } from "@/world/vegetation/VegetationSystem";
|
||||
import { WaterSystem } from "@/world/water/WaterSystem";
|
||||
import { WorldPlane } from "@/world/WorldPlane";
|
||||
|
||||
interface ShadowWarmupConfig {
|
||||
active: boolean;
|
||||
onReady: () => void;
|
||||
onStarted: () => void;
|
||||
}
|
||||
|
||||
interface EnvironmentProps {
|
||||
shadowWarmup?: ShadowWarmupConfig;
|
||||
}
|
||||
|
||||
export function Environment({
|
||||
shadowWarmup,
|
||||
}: EnvironmentProps): React.JSX.Element {
|
||||
export function Environment(): React.JSX.Element {
|
||||
const sceneMode = useSceneMode();
|
||||
const groups = useMapPerformanceStore((state) => state.groups);
|
||||
const models = useMapPerformanceStore((state) => state.models);
|
||||
@@ -47,13 +34,6 @@ export function Environment({
|
||||
return (
|
||||
<>
|
||||
<FogSystem />
|
||||
{shadowWarmup ? (
|
||||
<SceneShadowWarmup
|
||||
active={shadowWarmup.active}
|
||||
onReady={shadowWarmup.onReady}
|
||||
onStarted={shadowWarmup.onStarted}
|
||||
/>
|
||||
) : null}
|
||||
{showSky ? (
|
||||
<SkyModel
|
||||
fallbackColor={GAME_SCENE_FALLBACK_BACKGROUND_COLOR}
|
||||
|
||||
@@ -27,6 +27,7 @@ import { useMapLodModelPath } from "@/hooks/world/useMapLodModelPath";
|
||||
import { GameMapCollision } from "@/world/GameMapCollision";
|
||||
import { GeneratedMapNodeInstance } from "@/world/map-generated/GeneratedMapNodeInstance";
|
||||
import { isGeneratedMapModelName } from "@/data/world/generatedMapModelConfig";
|
||||
import { hasMapOctreeCollisionBox } from "@/data/world/octreeCollisionConfig";
|
||||
import { getMapSingleModelScaleMultiplier } from "@/data/world/mapInstancingConfig";
|
||||
import { MapInstancingSystem } from "@/world/map-instancing/MapInstancingSystem";
|
||||
import type { SceneLoadingChangeHandler } from "@/types/world/sceneLoading";
|
||||
@@ -115,6 +116,9 @@ export function GameMap({
|
||||
const [collisionMapNodes, setCollisionMapNodes] = useState<LoadedMapNode[]>(
|
||||
[],
|
||||
);
|
||||
const [proxyCollisionMapNodes, setProxyCollisionMapNodes] = useState<
|
||||
MapNode[]
|
||||
>([]);
|
||||
const [terrainNode, setTerrainNode] = useState<MapNode | null>(null);
|
||||
const [mapLoaded, setMapLoaded] = useState(false);
|
||||
const [settledMapNodeCount, setSettledMapNodeCount] = useState(0);
|
||||
@@ -134,6 +138,7 @@ export function GameMap({
|
||||
(currentStep: string) => {
|
||||
setRenderMapNodes([]);
|
||||
setCollisionMapNodes([]);
|
||||
setProxyCollisionMapNodes([]);
|
||||
setTerrainNode(null);
|
||||
setMapLoaded(true);
|
||||
settledMapNodesRef.current.clear();
|
||||
@@ -191,6 +196,10 @@ export function GameMap({
|
||||
const modelUrl = sceneData.models.get(node.name);
|
||||
return { node, modelUrl: modelUrl ?? null };
|
||||
});
|
||||
const loadedProxyCollisionNodes = sceneData.mapNodes.filter(
|
||||
(node) =>
|
||||
node.type === "Object3D" && hasMapOctreeCollisionBox(node.name),
|
||||
);
|
||||
const loadedTerrainNode = getTerrainMapNode(sceneData.mapNodes);
|
||||
const repairMissionAnchors = getRepairMissionMapAnchors(
|
||||
sceneData.mapNodes,
|
||||
@@ -211,6 +220,7 @@ export function GameMap({
|
||||
|
||||
setRenderMapNodes(loadedMapNodes);
|
||||
setCollisionMapNodes(loadedCollisionNodes);
|
||||
setProxyCollisionMapNodes(loadedProxyCollisionNodes);
|
||||
setTerrainNode(loadedTerrainNode);
|
||||
setRepairMissionAnchors(repairMissionAnchors);
|
||||
setMapLoaded(true);
|
||||
@@ -285,6 +295,7 @@ export function GameMap({
|
||||
buildOctree={buildOctree}
|
||||
mapReady={mapReady}
|
||||
nodes={collisionMapNodes}
|
||||
proxyNodes={proxyCollisionMapNodes}
|
||||
onLoaded={onLoaded}
|
||||
onLoadingStateChange={onLoadingStateChange}
|
||||
onOctreeReady={onOctreeReady}
|
||||
|
||||
+196
-10
@@ -17,9 +17,24 @@ import {
|
||||
normalizeMapScale,
|
||||
useTerrainHeightSampler,
|
||||
} from "@/hooks/three/useTerrainHeight";
|
||||
import {
|
||||
CHARACTER_CONFIGS,
|
||||
CHARACTER_IDS,
|
||||
type CharacterId,
|
||||
} from "@/data/world/characters/characterConfig";
|
||||
import {
|
||||
CHARACTER_OCTREE_COLLISION_BOX,
|
||||
LA_FABRIK_INTERIOR_COLLISION_BOXES,
|
||||
MAP_OCTREE_COLLISION_BOXES,
|
||||
hasMapOctreeCollisionBox,
|
||||
type OctreeCollisionBox,
|
||||
} from "@/data/world/octreeCollisionConfig";
|
||||
import { getMapModelScaleMultiplier } from "@/data/world/mapInstancingConfig";
|
||||
import { useCharacterDebugStore } from "@/managers/stores/useCharacterDebugStore";
|
||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||
import { WorldBoundsCollision } from "@/world/collision/WorldBoundsCollision";
|
||||
import type { MapNode } from "@/types/map/mapScene";
|
||||
import type { OctreeReadyHandler } from "@/types/three/three";
|
||||
import type { OctreeReadyHandler, Vector3Tuple } from "@/types/three/three";
|
||||
import type { SceneLoadingChangeHandler } from "@/types/world/sceneLoading";
|
||||
import { logModelLoadError } from "@/utils/three/modelLoadLogger";
|
||||
|
||||
@@ -39,6 +54,7 @@ interface GameMapCollisionProps {
|
||||
buildOctree?: boolean;
|
||||
mapReady: boolean;
|
||||
nodes: readonly GameMapCollisionNode[];
|
||||
proxyNodes: readonly MapNode[];
|
||||
onLoaded?: (() => void) | undefined;
|
||||
onLoadingStateChange?: SceneLoadingChangeHandler | undefined;
|
||||
onOctreeReady: OctreeReadyHandler;
|
||||
@@ -101,6 +117,7 @@ export function GameMapCollision({
|
||||
buildOctree = true,
|
||||
mapReady,
|
||||
nodes,
|
||||
proxyNodes,
|
||||
onLoaded,
|
||||
onLoadingStateChange,
|
||||
onOctreeReady,
|
||||
@@ -109,10 +126,28 @@ export function GameMapCollision({
|
||||
const settledCollisionNodesRef = useRef(new Set<number>());
|
||||
const loadedNotifiedRef = useRef(false);
|
||||
const [settledCollisionNodeCount, setSettledCollisionNodeCount] = useState(0);
|
||||
const mainState = useGameStore((state) => state.mainState);
|
||||
const terrainHeight = useTerrainHeightSampler();
|
||||
const collisionNodes = nodes.filter(isCollisionNode);
|
||||
const includeCharacterCollisions = mainState !== "ebike";
|
||||
const characterCollisionCount = includeCharacterCollisions
|
||||
? CHARACTER_IDS.length
|
||||
: 0;
|
||||
const collisionSourceCount =
|
||||
collisionNodes.length + proxyNodes.length + characterCollisionCount;
|
||||
const collisionReady =
|
||||
mapReady && settledCollisionNodeCount >= collisionNodes.length;
|
||||
const characterCollisionSignature = useCharacterDebugStore((state) =>
|
||||
includeCharacterCollisions
|
||||
? CHARACTER_IDS.map((id) => {
|
||||
const character = state.characters[id];
|
||||
return [...character.position, ...character.rotation].join(",");
|
||||
}).join("|")
|
||||
: "characters-hidden",
|
||||
);
|
||||
const collisionRebuildKey = collisionReady
|
||||
? `${collisionNodes.length}:${collisionSourceCount}:${characterCollisionSignature}`
|
||||
: "pending";
|
||||
|
||||
const notifyLoaded = useCallback(() => {
|
||||
if (loadedNotifiedRef.current) return;
|
||||
@@ -144,14 +179,14 @@ export function GameMapCollision({
|
||||
useOctreeGraphNode(
|
||||
groupRef,
|
||||
handleOctreeReady,
|
||||
collisionReady ? collisionNodes.length : 0,
|
||||
buildOctree && collisionReady && collisionNodes.length > 0,
|
||||
collisionRebuildKey,
|
||||
buildOctree && collisionReady && collisionSourceCount > 0,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mapReady) return;
|
||||
|
||||
if (collisionNodes.length === 0) {
|
||||
if (collisionSourceCount === 0) {
|
||||
notifyLoaded();
|
||||
return;
|
||||
}
|
||||
@@ -171,6 +206,7 @@ export function GameMapCollision({
|
||||
}, [
|
||||
buildOctree,
|
||||
collisionNodes.length,
|
||||
collisionSourceCount,
|
||||
collisionReady,
|
||||
mapReady,
|
||||
notifyLoaded,
|
||||
@@ -180,6 +216,18 @@ export function GameMapCollision({
|
||||
return (
|
||||
<group ref={groupRef} visible={false}>
|
||||
{mapReady ? <WorldBoundsCollision /> : null}
|
||||
{mapReady
|
||||
? proxyNodes.map((node, index) => (
|
||||
<MapCollisionBoxProxy
|
||||
key={`proxy-collision-${index}`}
|
||||
node={node}
|
||||
terrainHeight={terrainHeight}
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
{mapReady && includeCharacterCollisions ? (
|
||||
<CharacterCollisionProxies terrainHeight={terrainHeight} />
|
||||
) : null}
|
||||
{mapReady
|
||||
? collisionNodes.map((mapNode, index) => (
|
||||
<CollisionErrorBoundary
|
||||
@@ -223,6 +271,24 @@ function CollisionModelInstance({
|
||||
scale: normalizedScale,
|
||||
});
|
||||
const sceneInstance = useClonedObject(scene);
|
||||
useEffect(() => {
|
||||
if (node.name !== "lafabrik") return;
|
||||
|
||||
const isDoorSlab = (name: string): boolean =>
|
||||
name === "porte" || /^porte[._]\d+$/i.test(name);
|
||||
const isDoorFrameThickenChild = (child: THREE.Object3D): boolean =>
|
||||
child.parent?.name === "Thicken";
|
||||
|
||||
const doorMeshes: THREE.Object3D[] = [];
|
||||
sceneInstance.traverse((child) => {
|
||||
if (isDoorSlab(child.name) || isDoorFrameThickenChild(child)) {
|
||||
doorMeshes.push(child);
|
||||
}
|
||||
});
|
||||
for (const child of doorMeshes) {
|
||||
child.removeFromParent();
|
||||
}
|
||||
}, [node.name, sceneInstance]);
|
||||
const collisionPosition = useMemo(() => {
|
||||
if (node.name === "terrain") return position;
|
||||
|
||||
@@ -237,11 +303,131 @@ function CollisionModelInstance({
|
||||
}, [onLoaded]);
|
||||
|
||||
return (
|
||||
<primitive
|
||||
object={sceneInstance}
|
||||
position={collisionPosition}
|
||||
rotation={rotation}
|
||||
scale={normalizedScale}
|
||||
/>
|
||||
<>
|
||||
<primitive
|
||||
object={sceneInstance}
|
||||
position={collisionPosition}
|
||||
rotation={rotation}
|
||||
scale={normalizedScale}
|
||||
/>
|
||||
{node.name === "lafabrik" ? (
|
||||
<group
|
||||
name="lafabrik-interior-collision-proxies"
|
||||
position={collisionPosition}
|
||||
rotation={rotation}
|
||||
scale={normalizedScale}
|
||||
>
|
||||
{LA_FABRIK_INTERIOR_COLLISION_BOXES.map((box, index) => (
|
||||
<CollisionBox key={`lafabrik-interior-${index}`} box={box} />
|
||||
))}
|
||||
</group>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CollisionBox({ box }: { box: OctreeCollisionBox }): React.JSX.Element {
|
||||
return (
|
||||
<group position={box.center}>
|
||||
<mesh>
|
||||
<boxGeometry args={box.size} />
|
||||
<meshBasicMaterial />
|
||||
</mesh>
|
||||
<mesh rotation={[0, Math.PI, 0]}>
|
||||
<boxGeometry args={box.size} />
|
||||
<meshBasicMaterial />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function createScaledMapNodeScale(node: MapNode): Vector3Tuple {
|
||||
const baseScale = normalizeMapScale(node.scale);
|
||||
const scaleMultiplier = getMapModelScaleMultiplier(node.name);
|
||||
|
||||
return [
|
||||
baseScale[0] * scaleMultiplier,
|
||||
baseScale[1] * scaleMultiplier,
|
||||
baseScale[2] * scaleMultiplier,
|
||||
];
|
||||
}
|
||||
|
||||
function MapCollisionBoxProxy({
|
||||
node,
|
||||
terrainHeight,
|
||||
}: {
|
||||
node: MapNode;
|
||||
terrainHeight: TerrainHeightSampler;
|
||||
}): React.JSX.Element | null {
|
||||
const collisionBox = hasMapOctreeCollisionBox(node.name)
|
||||
? MAP_OCTREE_COLLISION_BOXES[node.name]
|
||||
: null;
|
||||
const normalizedScale = useMemo(() => createScaledMapNodeScale(node), [node]);
|
||||
const position = useMemo(() => {
|
||||
const [x, y, z] = node.position;
|
||||
if (!collisionBox) return [x, y, z] satisfies Vector3Tuple;
|
||||
|
||||
const height = terrainHeight.getHeight(x, z);
|
||||
const bottomOffset = -collisionBox.bottomY * normalizedScale[1];
|
||||
|
||||
return [x, (height ?? y) + bottomOffset, z] satisfies Vector3Tuple;
|
||||
}, [collisionBox, node.position, normalizedScale, terrainHeight]);
|
||||
|
||||
if (!collisionBox) return null;
|
||||
|
||||
return (
|
||||
<group
|
||||
name={`${node.name}-octree-collision-proxy`}
|
||||
position={position}
|
||||
rotation={node.rotation}
|
||||
scale={normalizedScale}
|
||||
>
|
||||
<CollisionBox box={collisionBox} />
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function CharacterCollisionProxies({
|
||||
terrainHeight,
|
||||
}: {
|
||||
terrainHeight: TerrainHeightSampler;
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<>
|
||||
{CHARACTER_IDS.map((id) => (
|
||||
<CharacterCollisionProxy
|
||||
key={`character-collision-${id}`}
|
||||
id={id}
|
||||
terrainHeight={terrainHeight}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CharacterCollisionProxy({
|
||||
id,
|
||||
terrainHeight,
|
||||
}: {
|
||||
id: CharacterId;
|
||||
terrainHeight: TerrainHeightSampler;
|
||||
}): React.JSX.Element {
|
||||
const config = CHARACTER_CONFIGS[id];
|
||||
const state = useCharacterDebugStore((store) => store.characters[id]);
|
||||
const position = useMemo(() => {
|
||||
const [x, y, z] = state.position;
|
||||
const height = terrainHeight.getHeight(x, z);
|
||||
|
||||
return [x, height ?? y, z] satisfies Vector3Tuple;
|
||||
}, [state.position, terrainHeight]);
|
||||
|
||||
return (
|
||||
<group
|
||||
name={`${config.id}-octree-collision-proxy`}
|
||||
position={position}
|
||||
rotation={state.rotation}
|
||||
>
|
||||
<CollisionBox box={CHARACTER_OCTREE_COLLISION_BOX} />
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,7 +16,13 @@ import { useRepairMissionAnchorStore } from "@/managers/stores/useRepairMissionA
|
||||
import type { RepairMissionTriggerConfig } from "@/types/gameplay/repairMission";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
import { getRepairMissionPosition } from "@/utils/gameplay/repairMissionPosition";
|
||||
import { EBIKE_WORLD_POSITION } from "@/data/ebike/ebikeConfig";
|
||||
import {
|
||||
EBIKE_WORLD_POSITION,
|
||||
EBIKE_WORLD_ROTATION_Y,
|
||||
EBIKE_WORLD_SCALE,
|
||||
} from "@/data/ebike/ebikeConfig";
|
||||
|
||||
const EBIKE_CONFIG_KEY = `${EBIKE_WORLD_POSITION.join(",")}:${EBIKE_WORLD_ROTATION_Y}:${EBIKE_WORLD_SCALE}`;
|
||||
|
||||
interface StageAnchorProps {
|
||||
color: string;
|
||||
@@ -92,7 +98,7 @@ export function GameStageContent(): React.JSX.Element {
|
||||
{mainState === "pylon" ? <RepairGamePreloader mission="farm" /> : null}
|
||||
|
||||
{mainState === "intro" ? <StageAnchor {...INTRO_STAGE_ANCHOR} /> : null}
|
||||
<Ebike position={EBIKE_WORLD_POSITION} />
|
||||
<Ebike key={EBIKE_CONFIG_KEY} position={EBIKE_WORLD_POSITION} />
|
||||
<PylonDownedPylon />
|
||||
{isDebugEnabled() ? (
|
||||
<>
|
||||
|
||||
+72
-31
@@ -1,10 +1,17 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useFrame, useThree } from "@react-three/fiber";
|
||||
import type { AmbientLight, DirectionalLight, Object3D } from "three";
|
||||
import {
|
||||
PCFShadowMap,
|
||||
type AmbientLight,
|
||||
type DirectionalLight,
|
||||
type Object3D,
|
||||
type WebGLRenderer,
|
||||
} from "three";
|
||||
import {
|
||||
AMBIENT_INTENSITY_MAX,
|
||||
AMBIENT_INTENSITY_MIN,
|
||||
AMBIENT_INTENSITY_STEP,
|
||||
SHADOW_CONFIG,
|
||||
SUN_INTENSITY_MAX,
|
||||
SUN_INTENSITY_MIN,
|
||||
SUN_INTENSITY_STEP,
|
||||
@@ -18,16 +25,51 @@ import {
|
||||
SUN_Z_MIN,
|
||||
SUN_Z_STEP,
|
||||
} from "@/data/world/lightingConfig";
|
||||
import { LA_FABRIK_INTERIOR_LIGHT_POSITION } from "@/data/world/laFabrikConfig";
|
||||
import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
|
||||
import { useShadowMapWarmup } from "@/hooks/three/useShadowMapWarmup";
|
||||
import { LIGHTING_STATE } from "@/world/lightingState";
|
||||
|
||||
const SHADOW_MAP_SIZE = 2048;
|
||||
const SHADOW_CAMERA_SIZE = 95;
|
||||
const SHADOW_CAMERA_NEAR = 0.5;
|
||||
const SHADOW_CAMERA_FAR = 300;
|
||||
function configureRendererShadows(gl: WebGLRenderer): void {
|
||||
gl.shadowMap.enabled = true;
|
||||
gl.shadowMap.type = PCFShadowMap;
|
||||
gl.shadowMap.autoUpdate = true;
|
||||
}
|
||||
|
||||
function configureSunShadow(sun: DirectionalLight, sunTarget: Object3D): void {
|
||||
sun.target = sunTarget;
|
||||
sun.shadow.autoUpdate = true;
|
||||
sun.shadow.bias = SHADOW_CONFIG.bias;
|
||||
sun.shadow.normalBias = SHADOW_CONFIG.normalBias;
|
||||
sun.shadow.mapSize.width = SHADOW_CONFIG.mapSize;
|
||||
sun.shadow.mapSize.height = SHADOW_CONFIG.mapSize;
|
||||
sun.shadow.camera.left = -SHADOW_CONFIG.cameraSize;
|
||||
sun.shadow.camera.right = SHADOW_CONFIG.cameraSize;
|
||||
sun.shadow.camera.top = SHADOW_CONFIG.cameraSize;
|
||||
sun.shadow.camera.bottom = -SHADOW_CONFIG.cameraSize;
|
||||
sun.shadow.camera.near = SHADOW_CONFIG.cameraNear;
|
||||
sun.shadow.camera.far = SHADOW_CONFIG.cameraFar;
|
||||
sun.shadow.camera.updateProjectionMatrix();
|
||||
}
|
||||
|
||||
function placeSunRelativeToCamera(
|
||||
sun: DirectionalLight,
|
||||
sunTarget: Object3D,
|
||||
cameraPosition: { x: number; z: number },
|
||||
): void {
|
||||
sunTarget.position.set(cameraPosition.x, 0, cameraPosition.z);
|
||||
sun.position.set(
|
||||
cameraPosition.x + LIGHTING_STATE.sunX,
|
||||
LIGHTING_STATE.sunY,
|
||||
cameraPosition.z + LIGHTING_STATE.sunZ,
|
||||
);
|
||||
}
|
||||
|
||||
export function Lighting(): React.JSX.Element {
|
||||
const camera = useThree((state) => state.camera);
|
||||
const gl = useThree((state) => state.gl);
|
||||
const scene = useThree((state) => state.scene);
|
||||
const invalidate = useThree((state) => state.invalidate);
|
||||
const ambient = useRef<AmbientLight>(null);
|
||||
const sun = useRef<DirectionalLight>(null);
|
||||
const sunTarget = useRef<Object3D>(null);
|
||||
@@ -35,19 +77,16 @@ export function Lighting(): React.JSX.Element {
|
||||
useEffect(() => {
|
||||
if (!sun.current || !sunTarget.current) return;
|
||||
|
||||
sun.current.target = sunTarget.current;
|
||||
sun.current.shadow.autoUpdate = true;
|
||||
sun.current.shadow.needsUpdate = true;
|
||||
sun.current.shadow.mapSize.width = SHADOW_MAP_SIZE;
|
||||
sun.current.shadow.mapSize.height = SHADOW_MAP_SIZE;
|
||||
sun.current.shadow.camera.left = -SHADOW_CAMERA_SIZE;
|
||||
sun.current.shadow.camera.right = SHADOW_CAMERA_SIZE;
|
||||
sun.current.shadow.camera.top = SHADOW_CAMERA_SIZE;
|
||||
sun.current.shadow.camera.bottom = -SHADOW_CAMERA_SIZE;
|
||||
sun.current.shadow.camera.near = SHADOW_CAMERA_NEAR;
|
||||
sun.current.shadow.camera.far = SHADOW_CAMERA_FAR;
|
||||
sun.current.shadow.camera.updateProjectionMatrix();
|
||||
}, []);
|
||||
configureRendererShadows(gl);
|
||||
configureSunShadow(sun.current, sunTarget.current);
|
||||
// Prime the sun + target onto the camera before the first shadow pass so
|
||||
// the initial shadow frustum already covers the visible scene; without
|
||||
// this, the first frame is rendered with the default (origin-centered)
|
||||
// frustum and shadows can appear absent until the player moves.
|
||||
placeSunRelativeToCamera(sun.current, sunTarget.current, camera.position);
|
||||
}, [camera, gl]);
|
||||
|
||||
useShadowMapWarmup({ light: sun, scene, gl, invalidate });
|
||||
|
||||
useDebugFolder("Lighting", (folder) => {
|
||||
folder.addColor(LIGHTING_STATE, "ambientColor").name("Ambient Color");
|
||||
@@ -87,19 +126,14 @@ export function Lighting(): React.JSX.Element {
|
||||
ambient.current.intensity = LIGHTING_STATE.ambientIntensity;
|
||||
}
|
||||
|
||||
if (sun.current && sunTarget.current) {
|
||||
sunTarget.current.position.set(camera.position.x, 0, camera.position.z);
|
||||
sunTarget.current.updateMatrixWorld();
|
||||
sun.current.position.set(
|
||||
camera.position.x + LIGHTING_STATE.sunX,
|
||||
LIGHTING_STATE.sunY,
|
||||
camera.position.z + LIGHTING_STATE.sunZ,
|
||||
);
|
||||
sun.current.color.set(LIGHTING_STATE.sunColor);
|
||||
sun.current.intensity = LIGHTING_STATE.sunIntensity;
|
||||
sun.current.updateMatrixWorld();
|
||||
sun.current.shadow.needsUpdate = true;
|
||||
}
|
||||
if (!sun.current || !sunTarget.current) return;
|
||||
|
||||
placeSunRelativeToCamera(sun.current, sunTarget.current, camera.position);
|
||||
sunTarget.current.updateMatrixWorld();
|
||||
sun.current.color.set(LIGHTING_STATE.sunColor);
|
||||
sun.current.intensity = LIGHTING_STATE.sunIntensity;
|
||||
sun.current.updateMatrixWorld();
|
||||
sun.current.shadow.needsUpdate = true;
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -121,6 +155,13 @@ export function Lighting(): React.JSX.Element {
|
||||
castShadow
|
||||
/>
|
||||
<object3D ref={sunTarget} />
|
||||
<pointLight
|
||||
position={LA_FABRIK_INTERIOR_LIGHT_POSITION}
|
||||
color="#dbeafe"
|
||||
intensity={1.2}
|
||||
distance={14}
|
||||
decay={1.6}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useThree } from "@react-three/fiber";
|
||||
import * as THREE from "three";
|
||||
|
||||
interface SceneShadowWarmupProps {
|
||||
active: boolean;
|
||||
onReady: () => void;
|
||||
onStarted: () => void;
|
||||
}
|
||||
|
||||
function markShadowLightForUpdate(object: THREE.Object3D): void {
|
||||
if (
|
||||
!(
|
||||
object instanceof THREE.DirectionalLight ||
|
||||
object instanceof THREE.PointLight ||
|
||||
object instanceof THREE.SpotLight
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!object.castShadow) return;
|
||||
|
||||
object.updateMatrixWorld(true);
|
||||
object.shadow.camera.updateProjectionMatrix();
|
||||
object.shadow.needsUpdate = true;
|
||||
}
|
||||
|
||||
function forceSceneShadowPass(
|
||||
gl: THREE.WebGLRenderer,
|
||||
scene: THREE.Scene,
|
||||
): void {
|
||||
gl.shadowMap.enabled = true;
|
||||
gl.shadowMap.type = THREE.PCFShadowMap;
|
||||
gl.shadowMap.autoUpdate = true;
|
||||
gl.shadowMap.needsUpdate = true;
|
||||
|
||||
scene.updateMatrixWorld(true);
|
||||
scene.traverse((object) => {
|
||||
if (object instanceof THREE.Mesh) {
|
||||
object.updateMatrixWorld(true);
|
||||
}
|
||||
|
||||
markShadowLightForUpdate(object);
|
||||
});
|
||||
}
|
||||
|
||||
export function SceneShadowWarmup({
|
||||
active,
|
||||
onReady,
|
||||
onStarted,
|
||||
}: SceneShadowWarmupProps): null {
|
||||
const gl = useThree((state) => state.gl);
|
||||
const scene = useThree((state) => state.scene);
|
||||
const invalidate = useThree((state) => state.invalidate);
|
||||
const isRunningRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) {
|
||||
isRunningRef.current = false;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (isRunningRef.current) return undefined;
|
||||
|
||||
isRunningRef.current = true;
|
||||
onStarted();
|
||||
forceSceneShadowPass(gl, scene);
|
||||
invalidate();
|
||||
|
||||
let firstFrame = 0;
|
||||
let secondFrame = 0;
|
||||
|
||||
firstFrame = window.requestAnimationFrame(() => {
|
||||
forceSceneShadowPass(gl, scene);
|
||||
invalidate();
|
||||
|
||||
secondFrame = window.requestAnimationFrame(() => {
|
||||
forceSceneShadowPass(gl, scene);
|
||||
invalidate();
|
||||
onReady();
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(firstFrame);
|
||||
window.cancelAnimationFrame(secondFrame);
|
||||
};
|
||||
}, [active, gl, invalidate, onReady, onStarted, scene]);
|
||||
|
||||
return null;
|
||||
}
|
||||
+27
-14
@@ -5,17 +5,22 @@ import {
|
||||
PLAYER_SPAWN_POSITION_PHYSICS,
|
||||
} from "@/data/player/playerConfig";
|
||||
import { useRepairTransitionStore } from "@/managers/stores/useRepairTransitionStore";
|
||||
import { LA_FABRIK_INITIAL_LOOK_AT } from "@/data/world/laFabrikConfig";
|
||||
import { useCameraMode } from "@/hooks/debug/useCameraMode";
|
||||
import { useEnvironmentDebug } from "@/hooks/debug/useEnvironmentDebug";
|
||||
import { useMapPerformanceDebug } from "@/hooks/debug/useMapPerformanceDebug";
|
||||
import { useCharacterDebug } from "@/hooks/debug/useCharacterDebug";
|
||||
import { usePlayerPositionDebug } from "@/hooks/debug/usePlayerPositionDebug";
|
||||
import { useDebugVisualsDebug } from "@/hooks/debug/useDebugVisualsDebug";
|
||||
import { useSceneMode } from "@/hooks/debug/useSceneMode";
|
||||
import { useHandTrackingSnapshot } from "@/hooks/handTracking/useHandTrackingSnapshot";
|
||||
import { useWorldSceneLoading } from "@/hooks/world/useWorldSceneLoading";
|
||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||
import { useDebugVisualsStore } from "@/managers/stores/useDebugVisualsStore";
|
||||
import { DebugCameraControls } from "@/components/debug/scene/DebugCameraControls";
|
||||
import { DebugHelpers } from "@/components/debug/scene/DebugHelpers";
|
||||
import { DebugOctreeVisualization } from "@/components/debug/DebugOctreeVisualization";
|
||||
import { DebugPlayerModel } from "@/components/debug/DebugPlayerModel";
|
||||
import { HandTrackingGlove } from "@/components/three/handTracking/HandTrackingGlove";
|
||||
import { Environment } from "@/world/Environment";
|
||||
import { GameCinematics } from "@/world/GameCinematics";
|
||||
@@ -38,10 +43,15 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
|
||||
useMapPerformanceDebug();
|
||||
useCharacterDebug();
|
||||
usePlayerPositionDebug();
|
||||
useDebugVisualsDebug();
|
||||
|
||||
const cameraMode = useCameraMode();
|
||||
const sceneMode = useSceneMode();
|
||||
const mainState = useGameStore((state) => state.mainState);
|
||||
const showDebugPlayerModel = useDebugVisualsStore(
|
||||
(state) => state.showPlayerModel,
|
||||
);
|
||||
const showDebugOctree = useDebugVisualsStore((state) => state.showOctree);
|
||||
const { status, usageStatus } = useHandTrackingSnapshot();
|
||||
const {
|
||||
octree,
|
||||
@@ -50,9 +60,6 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
|
||||
handleGameStageLoaded,
|
||||
handleGameMapLoaded,
|
||||
handleOctreeReady,
|
||||
handleShadowWarmupReady,
|
||||
handleShadowWarmupStarted,
|
||||
shouldWarmUpShadows,
|
||||
} = useWorldSceneLoading({ sceneMode, onLoadingStateChange });
|
||||
// Capture the spawn position once on mount via a ref so it never changes
|
||||
// mid-session (spawnPosition is reactive in Player and would re-spawn the
|
||||
@@ -88,15 +95,15 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Environment
|
||||
shadowWarmup={{
|
||||
active: shouldWarmUpShadows,
|
||||
onReady: handleShadowWarmupReady,
|
||||
onStarted: handleShadowWarmupStarted,
|
||||
}}
|
||||
/>
|
||||
<Environment />
|
||||
<Lighting />
|
||||
<DebugHelpers />
|
||||
{showDebugOctree ? <DebugOctreeVisualization octree={octree} /> : null}
|
||||
{showDebugPlayerModel ? (
|
||||
<Suspense fallback={null}>
|
||||
<DebugPlayerModel />
|
||||
</Suspense>
|
||||
) : null}
|
||||
{showHandTrackingGloves ? (
|
||||
<Suspense fallback={null}>
|
||||
<HandTrackingGlove handedness="left" />
|
||||
@@ -115,16 +122,22 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
|
||||
{showGameStage ? (
|
||||
<Physics>
|
||||
<GameStageLoaded onLoaded={handleGameStageLoaded} />
|
||||
<GameStageContent />
|
||||
<Suspense fallback={null}>
|
||||
<GameStageContent />
|
||||
</Suspense>
|
||||
</Physics>
|
||||
) : null}
|
||||
{spawnPlayer ? (
|
||||
<>
|
||||
<Suspense fallback={null}>
|
||||
<GameMusic />
|
||||
{mainState === "outro" ? <GameCinematics /> : null}
|
||||
{mainState !== "intro" ? <GameDialogues /> : null}
|
||||
<Player octree={octree} spawnPosition={playerSpawnPosition} />
|
||||
</>
|
||||
<Player
|
||||
initialLookAt={LA_FABRIK_INITIAL_LOOK_AT}
|
||||
octree={octree}
|
||||
spawnPosition={playerSpawnPosition}
|
||||
/>
|
||||
</Suspense>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -8,6 +8,11 @@ import {
|
||||
GRASS_COLORS,
|
||||
GRASS_CONFIG,
|
||||
} from "@/data/world/grassConfig";
|
||||
import {
|
||||
LA_FABRIK_CENTER,
|
||||
LA_FABRIK_HALF_EXTENTS,
|
||||
LA_FABRIK_ROTATION_Y,
|
||||
} from "@/data/world/laFabrikConfig";
|
||||
import {
|
||||
grassFragmentShader,
|
||||
grassVertexShader,
|
||||
@@ -169,6 +174,17 @@ function createGrassMaterial(
|
||||
uMaxBladeHeight: { value: GRASS_CONFIG.maxBladeHeight },
|
||||
uRandomHeightAmount: { value: GRASS_CONFIG.randomHeightAmount },
|
||||
uSurfaceOffset: { value: GRASS_CONFIG.surfaceOffset },
|
||||
uLaFabrikCenter: {
|
||||
value: new THREE.Vector2(LA_FABRIK_CENTER[0], LA_FABRIK_CENTER[2]),
|
||||
},
|
||||
uLaFabrikHalfExtents: {
|
||||
value: new THREE.Vector2(
|
||||
LA_FABRIK_HALF_EXTENTS.x,
|
||||
LA_FABRIK_HALF_EXTENTS.z,
|
||||
),
|
||||
},
|
||||
uLaFabrikRotation: { value: LA_FABRIK_ROTATION_Y },
|
||||
uLaFabrikNoGrassFeather: { value: 1.4 },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -43,6 +43,10 @@ export const grassVertexShader = /* glsl */ `
|
||||
uniform float uMaxBladeHeight;
|
||||
uniform float uRandomHeightAmount;
|
||||
uniform float uSurfaceOffset;
|
||||
uniform vec2 uLaFabrikCenter;
|
||||
uniform vec2 uLaFabrikHalfExtents;
|
||||
uniform float uLaFabrikRotation;
|
||||
uniform float uLaFabrikNoGrassFeather;
|
||||
|
||||
float random(vec2 st) {
|
||||
return fract(sin(dot(st.xy, vec2(12.9898, 78.233))) * 43758.5453123);
|
||||
@@ -132,6 +136,18 @@ export const grassVertexShader = /* glsl */ `
|
||||
smoothstep(uBoundingBoxMax.z, uBoundingBoxMax.z - 2.0, worldPos.z);
|
||||
heightModifier *= edgeFade * mix(0.45, 1.0, clumpMask);
|
||||
|
||||
vec2 laFabrikDelta = worldPos.xz - uLaFabrikCenter;
|
||||
float laFabrikCos = cos(-uLaFabrikRotation);
|
||||
float laFabrikSin = sin(-uLaFabrikRotation);
|
||||
vec2 laFabrikLocal = vec2(
|
||||
laFabrikDelta.x * laFabrikCos - laFabrikDelta.y * laFabrikSin,
|
||||
laFabrikDelta.x * laFabrikSin + laFabrikDelta.y * laFabrikCos
|
||||
);
|
||||
vec2 laFabrikDistance = abs(laFabrikLocal) - uLaFabrikHalfExtents;
|
||||
float laFabrikOutsideDistance = max(laFabrikDistance.x, laFabrikDistance.y);
|
||||
float laFabrikGrassMask = smoothstep(0.0, uLaFabrikNoGrassFeather, laFabrikOutsideDistance);
|
||||
heightModifier *= laFabrikGrassMask;
|
||||
|
||||
float sideFactor = (color.r == 0.1) ? 1.0 : (color.b == 0.1) ? -1.0 : 0.0;
|
||||
float tipFactor = color.g;
|
||||
float width = smoothstep(0.02, uMaxBladeHeight * 0.85, heightModifier) * uBladeWidth * bladeVisibility;
|
||||
|
||||
@@ -7,10 +7,12 @@ import { PlayerController } from "@/world/player/PlayerController";
|
||||
|
||||
interface PlayerProps {
|
||||
octree: Octree | null;
|
||||
initialLookAt?: Vector3Tuple | undefined;
|
||||
spawnPosition: Vector3Tuple;
|
||||
}
|
||||
|
||||
export function Player({
|
||||
initialLookAt,
|
||||
spawnPosition,
|
||||
octree,
|
||||
}: PlayerProps): React.JSX.Element {
|
||||
@@ -18,12 +20,17 @@ export function Player({
|
||||
|
||||
useLayoutEffect(() => {
|
||||
camera.position.set(...spawnPosition);
|
||||
}, [camera, spawnPosition]);
|
||||
if (initialLookAt) camera.lookAt(...initialLookAt);
|
||||
}, [camera, initialLookAt, spawnPosition]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PlayerCamera />
|
||||
<PlayerController octree={octree} spawnPosition={spawnPosition} />
|
||||
<PlayerController
|
||||
initialLookAt={initialLookAt}
|
||||
octree={octree}
|
||||
spawnPosition={spawnPosition}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@ const PLAYER_FLOOR_NORMAL_MIN = 0.15;
|
||||
const PLAYER_GROUND_SNAP_DISTANCE = 0.22;
|
||||
|
||||
interface PlayerControllerProps {
|
||||
initialLookAt?: Vector3Tuple | undefined;
|
||||
octree: Octree | null;
|
||||
spawnPosition: Vector3Tuple;
|
||||
}
|
||||
@@ -89,6 +90,7 @@ const _collisionCorrection = new THREE.Vector3();
|
||||
function resetPlayerCapsule(
|
||||
capsule: Capsule,
|
||||
spawnPosition: Vector3Tuple,
|
||||
initialLookAt: Vector3Tuple | undefined,
|
||||
camera: THREE.Camera,
|
||||
velocity: THREE.Vector3,
|
||||
): void {
|
||||
@@ -100,6 +102,7 @@ function resetPlayerCapsule(
|
||||
capsule.end.set(...spawnPosition);
|
||||
velocity.set(0, 0, 0);
|
||||
camera.position.copy(capsule.end);
|
||||
if (initialLookAt) camera.lookAt(...initialLookAt);
|
||||
}
|
||||
|
||||
function createSpawnCapsule(spawnPosition: Vector3Tuple): Capsule {
|
||||
@@ -145,6 +148,7 @@ function getCapsuleFootY(capsule: Capsule): number {
|
||||
}
|
||||
|
||||
export function PlayerController({
|
||||
initialLookAt,
|
||||
octree,
|
||||
spawnPosition,
|
||||
}: PlayerControllerProps): null {
|
||||
@@ -234,6 +238,7 @@ export function PlayerController({
|
||||
resetPlayerCapsule(
|
||||
capsule.current,
|
||||
spawnPosition,
|
||||
initialLookAt,
|
||||
camera,
|
||||
velocity.current,
|
||||
);
|
||||
@@ -241,7 +246,7 @@ export function PlayerController({
|
||||
onFloor.current = false;
|
||||
wantsJump.current = false;
|
||||
initializedRef.current = true;
|
||||
}, [camera, spawnPosition]);
|
||||
}, [camera, initialLookAt, spawnPosition]);
|
||||
|
||||
useEffect(() => {
|
||||
movementLockedRef.current = movementLocked;
|
||||
@@ -339,6 +344,7 @@ export function PlayerController({
|
||||
resetPlayerCapsule(
|
||||
capsule.current,
|
||||
spawnPosition,
|
||||
initialLookAt,
|
||||
camera,
|
||||
velocity.current,
|
||||
);
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
VEGETATION_TYPES,
|
||||
type VegetationType,
|
||||
} from "@/data/world/vegetationConfig";
|
||||
import { isInsideLaFabrikFootprint } from "@/data/world/laFabrikConfig";
|
||||
import { createWorldInstanceChunks } from "@/utils/world/chunkInstances";
|
||||
|
||||
interface VegetationSystemProps {
|
||||
@@ -60,6 +61,15 @@ function createVegetationChunks(
|
||||
});
|
||||
}
|
||||
|
||||
function removeLaFabrikVegetation(
|
||||
instances: VegetationInstance[],
|
||||
): VegetationInstance[] {
|
||||
return instances.filter((instance) => {
|
||||
const [x, , z] = instance.position;
|
||||
return !isInsideLaFabrikFootprint(x, z, 1.2);
|
||||
});
|
||||
}
|
||||
|
||||
export function VegetationSystem({
|
||||
onlyMapName = null,
|
||||
streaming = true,
|
||||
@@ -90,7 +100,10 @@ export function VegetationSystem({
|
||||
const entry = data.get(config.mapName);
|
||||
if (!entry || entry.instances.length === 0) return [];
|
||||
|
||||
return createVegetationChunks(type, entry.instances);
|
||||
const instances = removeLaFabrikVegetation(entry.instances);
|
||||
if (instances.length === 0) return [];
|
||||
|
||||
return createVegetationChunks(type, instances);
|
||||
});
|
||||
}, [data, groups, models, onlyMapName]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user