diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts index 78a3258e..0364a880 100644 --- a/packages/core/src/events/bus.ts +++ b/packages/core/src/events/bus.ts @@ -146,6 +146,13 @@ type DoorAnimationEvents = { } } +type WindowAnimationEvents = { + 'window:animation-completed': { + windowId: WindowNode['id'] + field: 'operationState' + } +} + type PresetEvents = { 'preset:generate-thumbnail': { presetId: string; nodeId: string } 'preset:thumbnail-updated': { presetId: string; thumbnailUrl: string } @@ -189,6 +196,7 @@ type EditorEvents = GridEvents & ToolEvents & GuideEvents & DoorAnimationEvents & + WindowAnimationEvents & PresetEvents & ThumbnailEvents & SnapshotEvents & diff --git a/packages/editor/src/components/editor/first-person-controls.tsx b/packages/editor/src/components/editor/first-person-controls.tsx index f3597c20..cbc7ad14 100644 --- a/packages/editor/src/components/editor/first-person-controls.tsx +++ b/packages/editor/src/components/editor/first-person-controls.tsx @@ -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['nodes'], @@ -73,7 +85,7 @@ export const FirstPersonControls = () => { const controllerRef = useRef(null) const yawRef = useRef(0) const pitchRef = useRef(0) - const interactableDoorIdRef = useRef(null) + const interactableTargetRef = useRef(null) const worldRef = useRef(null) const [world, setWorld] = useState(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(() => { 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 }) => {
+ +
Click to look around diff --git a/packages/editor/src/lib/window-interaction.ts b/packages/editor/src/lib/window-interaction.ts index 4935e6eb..64c4ce99 100644 --- a/packages/editor/src/lib/window-interaction.ts +++ b/packages/editor/src/lib/window-interaction.ts @@ -11,7 +11,7 @@ type WindowOpenAnimationOptions = { persist?: boolean } -function isOperableWindowType(windowType: string | undefined) { +export function isOperableWindowType(windowType: string | undefined) { return ( windowType === 'sliding' || windowType === 'casement' || diff --git a/packages/viewer/src/systems/window/window-animation-system.tsx b/packages/viewer/src/systems/window/window-animation-system.tsx index 1af29df6..845b45a1 100644 --- a/packages/viewer/src/systems/window/window-animation-system.tsx +++ b/packages/viewer/src/systems/window/window-animation-system.tsx @@ -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 { AWNING_WINDOW_SASH_NAME, @@ -101,6 +108,10 @@ export const WindowAnimationSystem = () => { } else { interactive.setWindowOpenState(typedWindowId, { [animation.field]: animation.to }) } + emitter.emit('window:animation-completed', { + windowId: typedWindowId as WindowNode['id'], + field: animation.field, + }) } }, 2)