Merge branch 'develop' into fix/repair-game

This commit is contained in:
math-pixel
2026-06-02 09:26:40 +02:00
75 changed files with 1783 additions and 424 deletions
+1 -21
View File
@@ -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}
+11
View File
@@ -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
View File
@@ -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>
);
}
+8 -2
View File
@@ -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
View File
@@ -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}
/>
</>
);
}
-92
View File
@@ -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
View File
@@ -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}
</>
) : (
+16
View File
@@ -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 },
},
});
}
+16
View File
@@ -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;
+9 -2
View File
@@ -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}
/>
</>
);
}
+7 -1
View File
@@ -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,
);
+14 -1
View File
@@ -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]);