|
|
@@ -8,10 +8,16 @@ import { useFrame, useThree } from '@react-three/fiber'
|
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
|
|
import { Box3, Euler, Matrix4, Ray, Raycaster, Vector2, Vector3 } from 'three'
|
|
|
|
import { Box3, Euler, Matrix4, Ray, Raycaster, Vector2, Vector3 } from 'three'
|
|
|
|
import {
|
|
|
|
import {
|
|
|
|
|
|
|
|
closeDoorOpenState,
|
|
|
|
DOOR_SWING_OPEN_ANGLE,
|
|
|
|
DOOR_SWING_OPEN_ANGLE,
|
|
|
|
isOperationDoorType,
|
|
|
|
isOperationDoorType,
|
|
|
|
toggleDoorOpenState,
|
|
|
|
toggleDoorOpenState,
|
|
|
|
} from '../../lib/door-interaction'
|
|
|
|
} from '../../lib/door-interaction'
|
|
|
|
|
|
|
|
import {
|
|
|
|
|
|
|
|
closeWindowOpenState,
|
|
|
|
|
|
|
|
isOperableWindowType,
|
|
|
|
|
|
|
|
toggleWindowOpenState,
|
|
|
|
|
|
|
|
} from '../../lib/window-interaction'
|
|
|
|
import useEditor from '../../store/use-editor'
|
|
|
|
import useEditor from '../../store/use-editor'
|
|
|
|
import {
|
|
|
|
import {
|
|
|
|
buildFirstPersonColliderWorldFromRegistry,
|
|
|
|
buildFirstPersonColliderWorldFromRegistry,
|
|
|
@@ -55,6 +61,12 @@ const doorOpeningMatrix = new Matrix4()
|
|
|
|
const doorOpeningWorldHit = new Vector3()
|
|
|
|
const doorOpeningWorldHit = new Vector3()
|
|
|
|
const spawnWorldPosition = new Vector3()
|
|
|
|
const spawnWorldPosition = new Vector3()
|
|
|
|
const spawnWorldEuler = new Euler(0, 0, 0, 'YXZ')
|
|
|
|
const spawnWorldEuler = new Euler(0, 0, 0, 'YXZ')
|
|
|
|
|
|
|
|
const windowInteractionRaycaster = new Raycaster()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
type FirstPersonInteractableTarget = {
|
|
|
|
|
|
|
|
id: AnyNodeId
|
|
|
|
|
|
|
|
type: 'door' | 'window'
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const resolvePlacedSpawnNode = (
|
|
|
|
const resolvePlacedSpawnNode = (
|
|
|
|
nodes: ReturnType<typeof useScene.getState>['nodes'],
|
|
|
|
nodes: ReturnType<typeof useScene.getState>['nodes'],
|
|
|
@@ -73,7 +85,7 @@ export const FirstPersonControls = () => {
|
|
|
|
const controllerRef = useRef<BVHEcctrlApi | null>(null)
|
|
|
|
const controllerRef = useRef<BVHEcctrlApi | null>(null)
|
|
|
|
const yawRef = useRef(0)
|
|
|
|
const yawRef = useRef(0)
|
|
|
|
const pitchRef = useRef(0)
|
|
|
|
const pitchRef = useRef(0)
|
|
|
|
const interactableDoorIdRef = useRef<AnyNodeId | null>(null)
|
|
|
|
const interactableTargetRef = useRef<FirstPersonInteractableTarget | null>(null)
|
|
|
|
const worldRef = useRef<FirstPersonColliderWorld | null>(null)
|
|
|
|
const worldRef = useRef<FirstPersonColliderWorld | null>(null)
|
|
|
|
const [world, setWorld] = useState<FirstPersonColliderWorld | null>(null)
|
|
|
|
const [world, setWorld] = useState<FirstPersonColliderWorld | null>(null)
|
|
|
|
const [controllerStart, setControllerStart] = useState<{
|
|
|
|
const [controllerStart, setControllerStart] = useState<{
|
|
|
@@ -189,15 +201,94 @@ export const FirstPersonControls = () => {
|
|
|
|
return closestDoorId
|
|
|
|
return closestDoorId
|
|
|
|
}, [camera])
|
|
|
|
}, [camera])
|
|
|
|
|
|
|
|
|
|
|
|
const toggleInteractableDoor = useCallback(() => {
|
|
|
|
const resolveInteractableWindowId = useCallback((): AnyNodeId | null => {
|
|
|
|
const doorId = interactableDoorIdRef.current ?? resolveInteractableDoorId()
|
|
|
|
const nodes = useScene.getState().nodes
|
|
|
|
if (!doorId) return
|
|
|
|
camera.updateMatrixWorld(true)
|
|
|
|
|
|
|
|
windowInteractionRaycaster.setFromCamera(centerScreenPoint, camera)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
let closestWindowId: AnyNodeId | null = null
|
|
|
|
|
|
|
|
let closestDistance = DOOR_INTERACTION_DISTANCE
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
for (const windowId of sceneRegistry.byType.window) {
|
|
|
|
|
|
|
|
const node = nodes[windowId as AnyNodeId]
|
|
|
|
|
|
|
|
if (node?.type !== 'window') continue
|
|
|
|
|
|
|
|
if (node.openingKind === 'opening') continue
|
|
|
|
|
|
|
|
if (!isOperableWindowType(node.windowType)) continue
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const object = sceneRegistry.nodes.get(windowId)
|
|
|
|
|
|
|
|
if (!object) continue
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const hit = windowInteractionRaycaster
|
|
|
|
|
|
|
|
.intersectObject(object, true)
|
|
|
|
|
|
|
|
.find((intersection) => intersection.distance <= DOOR_INTERACTION_DISTANCE)
|
|
|
|
|
|
|
|
if (!(hit && hit.distance < closestDistance)) continue
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
closestWindowId = windowId as AnyNodeId
|
|
|
|
|
|
|
|
closestDistance = hit.distance
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return closestWindowId
|
|
|
|
|
|
|
|
}, [camera])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const resolveInteractableTarget = useCallback((): FirstPersonInteractableTarget | null => {
|
|
|
|
|
|
|
|
const doorId = resolveInteractableDoorId()
|
|
|
|
|
|
|
|
if (doorId) return { id: doorId, type: 'door' }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const windowId = resolveInteractableWindowId()
|
|
|
|
|
|
|
|
if (windowId) return { id: windowId, type: 'window' }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return null
|
|
|
|
|
|
|
|
}, [resolveInteractableDoorId, resolveInteractableWindowId])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const toggleInteractableTarget = useCallback(() => {
|
|
|
|
|
|
|
|
const target = interactableTargetRef.current ?? resolveInteractableTarget()
|
|
|
|
|
|
|
|
if (!target) return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (target.type === 'window') {
|
|
|
|
|
|
|
|
const node = useScene.getState().nodes[target.id]
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
|
|
|
node?.type !== 'window' ||
|
|
|
|
|
|
|
|
node.openingKind === 'opening' ||
|
|
|
|
|
|
|
|
!isOperableWindowType(node.windowType)
|
|
|
|
|
|
|
|
) {
|
|
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
toggleWindowOpenState(target.id, { persist: false })
|
|
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const doorId = target.id
|
|
|
|
|
|
|
|
|
|
|
|
const node = useScene.getState().nodes[doorId]
|
|
|
|
const node = useScene.getState().nodes[doorId]
|
|
|
|
if (node?.type !== 'door' || node.openingKind === 'opening') return
|
|
|
|
if (node?.type !== 'door' || node.openingKind === 'opening') return
|
|
|
|
|
|
|
|
|
|
|
|
toggleDoorOpenState(doorId, { persist: false })
|
|
|
|
toggleDoorOpenState(doorId, { persist: false })
|
|
|
|
}, [resolveInteractableDoorId])
|
|
|
|
}, [resolveInteractableTarget])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const closeInteractableTarget = useCallback(() => {
|
|
|
|
|
|
|
|
const target = interactableTargetRef.current ?? resolveInteractableTarget()
|
|
|
|
|
|
|
|
if (!target) return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (target.type === 'window') {
|
|
|
|
|
|
|
|
const node = useScene.getState().nodes[target.id]
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
|
|
|
node?.type !== 'window' ||
|
|
|
|
|
|
|
|
node.openingKind === 'opening' ||
|
|
|
|
|
|
|
|
!isOperableWindowType(node.windowType)
|
|
|
|
|
|
|
|
) {
|
|
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
closeWindowOpenState(target.id, { persist: false })
|
|
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const node = useScene.getState().nodes[target.id]
|
|
|
|
|
|
|
|
if (node?.type !== 'door' || node.openingKind === 'opening') return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
closeDoorOpenState(target.id, { persist: false })
|
|
|
|
|
|
|
|
}, [resolveInteractableTarget])
|
|
|
|
|
|
|
|
|
|
|
|
const placedSpawn = useMemo<FirstPersonSpawn | null>(() => {
|
|
|
|
const placedSpawn = useMemo<FirstPersonSpawn | null>(() => {
|
|
|
|
if (!(placedSpawnNode && placedSpawnNode.type === 'spawn')) return null
|
|
|
|
if (!(placedSpawnNode && placedSpawnNode.type === 'spawn')) return null
|
|
|
@@ -240,7 +331,11 @@ export const FirstPersonControls = () => {
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
useEffect(() => {
|
|
|
|
emitter.on('door:animation-completed', rebuildColliderWorld)
|
|
|
|
emitter.on('door:animation-completed', rebuildColliderWorld)
|
|
|
|
return () => emitter.off('door:animation-completed', rebuildColliderWorld)
|
|
|
|
emitter.on('window:animation-completed', rebuildColliderWorld)
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
|
|
emitter.off('door:animation-completed', rebuildColliderWorld)
|
|
|
|
|
|
|
|
emitter.off('window:animation-completed', rebuildColliderWorld)
|
|
|
|
|
|
|
|
}
|
|
|
|
}, [rebuildColliderWorld])
|
|
|
|
}, [rebuildColliderWorld])
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
useEffect(() => {
|
|
|
@@ -308,7 +403,11 @@ export const FirstPersonControls = () => {
|
|
|
|
} else if (event.code === 'KeyE' || event.code === 'KeyR') {
|
|
|
|
} else if (event.code === 'KeyE' || event.code === 'KeyR') {
|
|
|
|
event.preventDefault()
|
|
|
|
event.preventDefault()
|
|
|
|
event.stopPropagation()
|
|
|
|
event.stopPropagation()
|
|
|
|
toggleInteractableDoor()
|
|
|
|
toggleInteractableTarget()
|
|
|
|
|
|
|
|
} else if (event.code === 'KeyT') {
|
|
|
|
|
|
|
|
event.preventDefault()
|
|
|
|
|
|
|
|
event.stopPropagation()
|
|
|
|
|
|
|
|
closeInteractableTarget()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
@@ -316,7 +415,7 @@ export const FirstPersonControls = () => {
|
|
|
|
return () => {
|
|
|
|
return () => {
|
|
|
|
document.removeEventListener('keydown', handleKeyDown, true)
|
|
|
|
document.removeEventListener('keydown', handleKeyDown, true)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}, [gl, toggleInteractableDoor])
|
|
|
|
}, [closeInteractableTarget, gl, toggleInteractableTarget])
|
|
|
|
|
|
|
|
|
|
|
|
useFrame((_, delta) => {
|
|
|
|
useFrame((_, delta) => {
|
|
|
|
if (!controllerRef.current?.group) return
|
|
|
|
if (!controllerRef.current?.group) return
|
|
|
@@ -328,16 +427,20 @@ export const FirstPersonControls = () => {
|
|
|
|
camera.quaternion.setFromEuler(cameraEuler)
|
|
|
|
camera.quaternion.setFromEuler(cameraEuler)
|
|
|
|
camera.updateMatrixWorld(true)
|
|
|
|
camera.updateMatrixWorld(true)
|
|
|
|
|
|
|
|
|
|
|
|
const nextInteractableDoorId = resolveInteractableDoorId()
|
|
|
|
const nextInteractableTarget = resolveInteractableTarget()
|
|
|
|
if (interactableDoorIdRef.current !== nextInteractableDoorId) {
|
|
|
|
const previousInteractableTarget = interactableTargetRef.current
|
|
|
|
interactableDoorIdRef.current = nextInteractableDoorId
|
|
|
|
if (
|
|
|
|
useViewer.getState().setHoveredId(nextInteractableDoorId)
|
|
|
|
previousInteractableTarget?.id !== nextInteractableTarget?.id ||
|
|
|
|
|
|
|
|
previousInteractableTarget?.type !== nextInteractableTarget?.type
|
|
|
|
|
|
|
|
) {
|
|
|
|
|
|
|
|
interactableTargetRef.current = nextInteractableTarget
|
|
|
|
|
|
|
|
useViewer.getState().setHoveredId(nextInteractableTarget?.id ?? null)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
})
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
useEffect(() => {
|
|
|
|
return () => {
|
|
|
|
return () => {
|
|
|
|
if (useViewer.getState().hoveredId === interactableDoorIdRef.current) {
|
|
|
|
if (useViewer.getState().hoveredId === interactableTargetRef.current?.id) {
|
|
|
|
useViewer.getState().setHoveredId(null)
|
|
|
|
useViewer.getState().setHoveredId(null)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
@@ -452,6 +555,8 @@ export const FirstPersonOverlay = ({ onExit }: { onExit: () => void }) => {
|
|
|
|
<div className="h-px w-full bg-border/30" />
|
|
|
|
<div className="h-px w-full bg-border/30" />
|
|
|
|
<InlineControlHint label="Jump" keyLabel="Space" />
|
|
|
|
<InlineControlHint label="Jump" keyLabel="Space" />
|
|
|
|
<InlineControlHint label="Sprint" keyLabel="Shift" />
|
|
|
|
<InlineControlHint label="Sprint" keyLabel="Shift" />
|
|
|
|
|
|
|
|
<InlineControlHint label="Interact" keyLabel="E / R" />
|
|
|
|
|
|
|
|
<InlineControlHint label="Close" keyLabel="T" />
|
|
|
|
<div className="h-px w-full bg-border/30" />
|
|
|
|
<div className="h-px w-full bg-border/30" />
|
|
|
|
<span className="text-center text-muted-foreground/60 text-xs">
|
|
|
|
<span className="text-center text-muted-foreground/60 text-xs">
|
|
|
|
Click to look around
|
|
|
|
Click to look around
|
|
|
|