Add first-person window interaction

This commit is contained in:
sudhir
2026-05-07 13:50:30 +05:30
parent 133d81e563
commit 8f6a8fbe8b
4 changed files with 139 additions and 15 deletions
+8
View File
@@ -146,6 +146,13 @@ type DoorAnimationEvents = {
} }
} }
type WindowAnimationEvents = {
'window:animation-completed': {
windowId: WindowNode['id']
field: 'operationState'
}
}
type PresetEvents = { type PresetEvents = {
'preset:generate-thumbnail': { presetId: string; nodeId: string } 'preset:generate-thumbnail': { presetId: string; nodeId: string }
'preset:thumbnail-updated': { presetId: string; thumbnailUrl: string } 'preset:thumbnail-updated': { presetId: string; thumbnailUrl: string }
@@ -189,6 +196,7 @@ type EditorEvents = GridEvents &
ToolEvents & ToolEvents &
GuideEvents & GuideEvents &
DoorAnimationEvents & DoorAnimationEvents &
WindowAnimationEvents &
PresetEvents & PresetEvents &
ThumbnailEvents & ThumbnailEvents &
SnapshotEvents & SnapshotEvents &
@@ -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
@@ -11,7 +11,7 @@ type WindowOpenAnimationOptions = {
persist?: boolean persist?: boolean
} }
function isOperableWindowType(windowType: string | undefined) { export function isOperableWindowType(windowType: string | undefined) {
return ( return (
windowType === 'sliding' || windowType === 'sliding' ||
windowType === 'casement' || windowType === 'casement' ||
@@ -1,4 +1,11 @@
import { type AnyNodeId, sceneRegistry, useInteractive, useScene } from '@pascal-app/core' import {
type AnyNodeId,
emitter,
sceneRegistry,
useInteractive,
useScene,
type WindowNode,
} from '@pascal-app/core'
import { useFrame } from '@react-three/fiber' import { useFrame } from '@react-three/fiber'
import { import {
AWNING_WINDOW_SASH_NAME, AWNING_WINDOW_SASH_NAME,
@@ -101,6 +108,10 @@ export const WindowAnimationSystem = () => {
} else { } else {
interactive.setWindowOpenState(typedWindowId, { [animation.field]: animation.to }) interactive.setWindowOpenState(typedWindowId, { [animation.field]: animation.to })
} }
emitter.emit('window:animation-completed', {
windowId: typedWindowId as WindowNode['id'],
field: animation.field,
})
} }
}, 2) }, 2)