Add first-person window interaction
This commit is contained in:
@@ -8,10 +8,16 @@ import { useFrame, useThree } from '@react-three/fiber'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Box3, Euler, Matrix4, Ray, Raycaster, Vector2, Vector3 } from 'three'
|
||||
import {
|
||||
closeDoorOpenState,
|
||||
DOOR_SWING_OPEN_ANGLE,
|
||||
isOperationDoorType,
|
||||
toggleDoorOpenState,
|
||||
} from '../../lib/door-interaction'
|
||||
import {
|
||||
closeWindowOpenState,
|
||||
isOperableWindowType,
|
||||
toggleWindowOpenState,
|
||||
} from '../../lib/window-interaction'
|
||||
import useEditor from '../../store/use-editor'
|
||||
import {
|
||||
buildFirstPersonColliderWorldFromRegistry,
|
||||
@@ -55,6 +61,12 @@ const doorOpeningMatrix = new Matrix4()
|
||||
const doorOpeningWorldHit = new Vector3()
|
||||
const spawnWorldPosition = new Vector3()
|
||||
const spawnWorldEuler = new Euler(0, 0, 0, 'YXZ')
|
||||
const windowInteractionRaycaster = new Raycaster()
|
||||
|
||||
type FirstPersonInteractableTarget = {
|
||||
id: AnyNodeId
|
||||
type: 'door' | 'window'
|
||||
}
|
||||
|
||||
const resolvePlacedSpawnNode = (
|
||||
nodes: ReturnType<typeof useScene.getState>['nodes'],
|
||||
@@ -73,7 +85,7 @@ export const FirstPersonControls = () => {
|
||||
const controllerRef = useRef<BVHEcctrlApi | null>(null)
|
||||
const yawRef = 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 [world, setWorld] = useState<FirstPersonColliderWorld | null>(null)
|
||||
const [controllerStart, setControllerStart] = useState<{
|
||||
@@ -189,15 +201,94 @@ export const FirstPersonControls = () => {
|
||||
return closestDoorId
|
||||
}, [camera])
|
||||
|
||||
const toggleInteractableDoor = useCallback(() => {
|
||||
const doorId = interactableDoorIdRef.current ?? resolveInteractableDoorId()
|
||||
if (!doorId) return
|
||||
const resolveInteractableWindowId = useCallback((): AnyNodeId | null => {
|
||||
const nodes = useScene.getState().nodes
|
||||
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]
|
||||
if (node?.type !== 'door' || node.openingKind === 'opening') return
|
||||
|
||||
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>(() => {
|
||||
if (!(placedSpawnNode && placedSpawnNode.type === 'spawn')) return null
|
||||
@@ -240,7 +331,11 @@ export const FirstPersonControls = () => {
|
||||
|
||||
useEffect(() => {
|
||||
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])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -308,7 +403,11 @@ export const FirstPersonControls = () => {
|
||||
} else if (event.code === 'KeyE' || event.code === 'KeyR') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
toggleInteractableDoor()
|
||||
toggleInteractableTarget()
|
||||
} else if (event.code === 'KeyT') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
closeInteractableTarget()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -316,7 +415,7 @@ export const FirstPersonControls = () => {
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown, true)
|
||||
}
|
||||
}, [gl, toggleInteractableDoor])
|
||||
}, [closeInteractableTarget, gl, toggleInteractableTarget])
|
||||
|
||||
useFrame((_, delta) => {
|
||||
if (!controllerRef.current?.group) return
|
||||
@@ -328,16 +427,20 @@ export const FirstPersonControls = () => {
|
||||
camera.quaternion.setFromEuler(cameraEuler)
|
||||
camera.updateMatrixWorld(true)
|
||||
|
||||
const nextInteractableDoorId = resolveInteractableDoorId()
|
||||
if (interactableDoorIdRef.current !== nextInteractableDoorId) {
|
||||
interactableDoorIdRef.current = nextInteractableDoorId
|
||||
useViewer.getState().setHoveredId(nextInteractableDoorId)
|
||||
const nextInteractableTarget = resolveInteractableTarget()
|
||||
const previousInteractableTarget = interactableTargetRef.current
|
||||
if (
|
||||
previousInteractableTarget?.id !== nextInteractableTarget?.id ||
|
||||
previousInteractableTarget?.type !== nextInteractableTarget?.type
|
||||
) {
|
||||
interactableTargetRef.current = nextInteractableTarget
|
||||
useViewer.getState().setHoveredId(nextInteractableTarget?.id ?? null)
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (useViewer.getState().hoveredId === interactableDoorIdRef.current) {
|
||||
if (useViewer.getState().hoveredId === interactableTargetRef.current?.id) {
|
||||
useViewer.getState().setHoveredId(null)
|
||||
}
|
||||
}
|
||||
@@ -452,6 +555,8 @@ export const FirstPersonOverlay = ({ onExit }: { onExit: () => void }) => {
|
||||
<div className="h-px w-full bg-border/30" />
|
||||
<InlineControlHint label="Jump" keyLabel="Space" />
|
||||
<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" />
|
||||
<span className="text-center text-muted-foreground/60 text-xs">
|
||||
Click to look around
|
||||
|
||||
@@ -11,7 +11,7 @@ type WindowOpenAnimationOptions = {
|
||||
persist?: boolean
|
||||
}
|
||||
|
||||
function isOperableWindowType(windowType: string | undefined) {
|
||||
export function isOperableWindowType(windowType: string | undefined) {
|
||||
return (
|
||||
windowType === 'sliding' ||
|
||||
windowType === 'casement' ||
|
||||
|
||||
Reference in New Issue
Block a user