Feat/map-environment #6

Merged
math-pixel merged 116 commits from feat/map-environment into develop 2026-05-29 00:00:51 +00:00
4 changed files with 46 additions and 16 deletions
Showing only changes of commit 3881e38a6d - Show all commits
@@ -30,6 +30,10 @@ export function isRuntimeSingleMapNode(node: MapNode): boolean {
); );
} }
export function isRuntimeCollisionMapNode(node: MapNode): boolean {
return node.name === "terrain" || isRuntimeSingleMapNode(node);
}
export function isEditorVisibleMapNode(node: MapNode): boolean { export function isEditorVisibleMapNode(node: MapNode): boolean {
return !isRuntimeStructureMapNode(node.name) && node.type !== "Mesh"; return !isRuntimeStructureMapNode(node.name) && node.type !== "Mesh";
} }
+2 -1
View File
@@ -31,6 +31,7 @@ import { logger } from "@/utils/core/Logger";
import { loadMapSceneData } from "@/utils/map/loadMapSceneData"; import { loadMapSceneData } from "@/utils/map/loadMapSceneData";
import { import {
getTerrainMapNode, getTerrainMapNode,
isRuntimeCollisionMapNode,
isRuntimeSingleMapNode, isRuntimeSingleMapNode,
} from "@/utils/map/mapRuntimeClassification"; } from "@/utils/map/mapRuntimeClassification";
import { logModelLoadError } from "@/utils/three/modelLoadLogger"; import { logModelLoadError } from "@/utils/three/modelLoadLogger";
@@ -178,7 +179,7 @@ export function GameMap({
return { node, modelUrl: modelUrl ?? null }; return { node, modelUrl: modelUrl ?? null };
}); });
const loadedCollisionNodes = sceneData.mapNodes const loadedCollisionNodes = sceneData.mapNodes
.filter((node) => node.name === "terrain") .filter(isRuntimeCollisionMapNode)
.map((node) => { .map((node) => {
const modelUrl = sceneData.models.get(node.name); const modelUrl = sceneData.models.get(node.name);
return { node, modelUrl: modelUrl ?? null }; return { node, modelUrl: modelUrl ?? null };
+25 -8
View File
@@ -4,6 +4,7 @@ import {
Suspense, Suspense,
useCallback, useCallback,
useEffect, useEffect,
useMemo,
useRef, useRef,
useState, useState,
} from "react"; } from "react";
@@ -11,6 +12,11 @@ import * as THREE from "three";
import { useClonedObject } from "@/hooks/three/useClonedObject"; import { useClonedObject } from "@/hooks/three/useClonedObject";
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF"; import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
import { useOctreeGraphNode } from "@/hooks/three/useOctreeGraphNode"; import { useOctreeGraphNode } from "@/hooks/three/useOctreeGraphNode";
import {
getObjectBottomOffset,
normalizeMapScale,
useTerrainHeightSampler,
} from "@/hooks/three/useTerrainHeight";
import { WorldBoundsCollision } from "@/world/collision/WorldBoundsCollision"; import { WorldBoundsCollision } from "@/world/collision/WorldBoundsCollision";
import type { MapNode } from "@/types/editor/editor"; import type { MapNode } from "@/types/editor/editor";
import type { OctreeReadyHandler } from "@/types/three/three"; import type { OctreeReadyHandler } from "@/types/three/three";
@@ -27,6 +33,8 @@ interface ResolvedGameMapCollisionNode {
modelUrl: string; modelUrl: string;
} }
type TerrainHeightSampler = ReturnType<typeof useTerrainHeightSampler>;
interface GameMapCollisionProps { interface GameMapCollisionProps {
buildOctree?: boolean; buildOctree?: boolean;
mapReady: boolean; mapReady: boolean;
@@ -47,8 +55,6 @@ interface CollisionErrorBoundaryState {
hasError: boolean; hasError: boolean;
} }
const MAP_COLLISION_NODE_NAMES = new Set(["terrain"]);
class CollisionErrorBoundary extends Component< class CollisionErrorBoundary extends Component<
CollisionErrorBoundaryProps, CollisionErrorBoundaryProps,
CollisionErrorBoundaryState CollisionErrorBoundaryState
@@ -88,9 +94,7 @@ class CollisionErrorBoundary extends Component<
function isCollisionNode( function isCollisionNode(
mapNode: GameMapCollisionNode, mapNode: GameMapCollisionNode,
): mapNode is ResolvedGameMapCollisionNode { ): mapNode is ResolvedGameMapCollisionNode {
return ( return mapNode.modelUrl !== null;
mapNode.modelUrl !== null && MAP_COLLISION_NODE_NAMES.has(mapNode.node.name)
);
} }
export function GameMapCollision({ export function GameMapCollision({
@@ -105,6 +109,7 @@ export function GameMapCollision({
const settledCollisionNodesRef = useRef(new Set<number>()); const settledCollisionNodesRef = useRef(new Set<number>());
const loadedNotifiedRef = useRef(false); const loadedNotifiedRef = useRef(false);
const [settledCollisionNodeCount, setSettledCollisionNodeCount] = useState(0); const [settledCollisionNodeCount, setSettledCollisionNodeCount] = useState(0);
const terrainHeight = useTerrainHeightSampler();
const collisionNodes = nodes.filter(isCollisionNode); const collisionNodes = nodes.filter(isCollisionNode);
const collisionReady = const collisionReady =
mapReady && settledCollisionNodeCount >= collisionNodes.length; mapReady && settledCollisionNodeCount >= collisionNodes.length;
@@ -188,6 +193,7 @@ export function GameMapCollision({
node={mapNode.node} node={mapNode.node}
modelUrl={mapNode.modelUrl} modelUrl={mapNode.modelUrl}
onLoaded={() => handleCollisionNodeSettled(index)} onLoaded={() => handleCollisionNodeSettled(index)}
terrainHeight={terrainHeight}
/> />
</Suspense> </Suspense>
</CollisionErrorBoundary> </CollisionErrorBoundary>
@@ -201,19 +207,30 @@ function CollisionModelInstance({
node, node,
modelUrl, modelUrl,
onLoaded, onLoaded,
terrainHeight,
}: { }: {
node: MapNode; node: MapNode;
modelUrl: string; modelUrl: string;
onLoaded: () => void; onLoaded: () => void;
terrainHeight: TerrainHeightSampler;
}): React.JSX.Element { }): React.JSX.Element {
const { position, rotation, scale } = node; const { position, rotation, scale } = node;
const normalizedScale = normalizeMapScale(scale);
const { scene } = useLoggedGLTF(modelUrl, { const { scene } = useLoggedGLTF(modelUrl, {
scope: "GameMapCollision.ModelInstance", scope: "GameMapCollision.ModelInstance",
position, position,
rotation, rotation,
scale, scale: normalizedScale,
}); });
const sceneInstance = useClonedObject(scene); const sceneInstance = useClonedObject(scene);
const collisionPosition = useMemo(() => {
if (node.name === "terrain") return position;
const [x, y, z] = position;
const height = terrainHeight.getHeight(x, z);
const bottomOffset = getObjectBottomOffset(sceneInstance, normalizedScale);
return [x, height !== null ? height + bottomOffset : y, z] as const;
}, [node.name, normalizedScale, position, sceneInstance, terrainHeight]);
useEffect(() => { useEffect(() => {
onLoaded(); onLoaded();
@@ -222,9 +239,9 @@ function CollisionModelInstance({
return ( return (
<primitive <primitive
object={sceneInstance} object={sceneInstance}
position={position} position={collisionPosition}
rotation={rotation} rotation={rotation}
scale={scale} scale={normalizedScale}
/> />
); );
} }
+15 -7
View File
@@ -47,6 +47,9 @@ const DEFAULT_KEYS: Keys = {
jump: false, jump: false,
}; };
const PLAYER_COLLISION_ITERATIONS = 3;
const PLAYER_FLOOR_NORMAL_MIN = 0.5;
interface PlayerControllerProps { interface PlayerControllerProps {
octree: Octree | null; octree: Octree | null;
spawnPosition: Vector3Tuple; spawnPosition: Vector3Tuple;
@@ -300,16 +303,21 @@ export function PlayerController({
capsule.current.translate(_translateVec); capsule.current.translate(_translateVec);
if (octree) { if (octree) {
const result = octree.capsuleIntersect(capsule.current);
onFloor.current = false; onFloor.current = false;
if (result) { for (let index = 0; index < PLAYER_COLLISION_ITERATIONS; index++) {
onFloor.current = result.normal.y > 0; const result = octree.capsuleIntersect(capsule.current);
if (!result) break;
if (!onFloor.current) { const isFloorCollision = result.normal.y > PLAYER_FLOOR_NORMAL_MIN;
const vn = result.normal.dot(velocity.current); onFloor.current ||= isFloorCollision;
velocity.current.addScaledVector(result.normal, -vn); const normalVelocity = result.normal.dot(velocity.current);
} else {
if (!isFloorCollision && normalVelocity < 0) {
velocity.current.addScaledVector(result.normal, -normalVelocity);
}
if (isFloorCollision) {
velocity.current.y = Math.max(0, velocity.current.y); velocity.current.y = Math.max(0, velocity.current.y);
} }