Fix first-person door toggles and colliders
This commit is contained in:
@@ -7,6 +7,11 @@ import { KeyboardControls } from '@react-three/drei'
|
||||
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 {
|
||||
animateDoorOpenState,
|
||||
DOOR_SWING_OPEN_ANGLE,
|
||||
isOperationDoorType,
|
||||
} from '../../lib/door-interaction'
|
||||
import useEditor from '../../store/use-editor'
|
||||
import {
|
||||
buildFirstPersonColliderWorldFromRegistry,
|
||||
@@ -22,7 +27,6 @@ const CAMERA_EYE_OFFSET = 0.45
|
||||
const LOOK_SENSITIVITY = 0.002
|
||||
const CONTROLLER_CENTER_FROM_EYE = 0.85
|
||||
const DOOR_INTERACTION_DISTANCE = 2.5
|
||||
const DOOR_SWING_OPEN_ANGLE = Math.PI / 2
|
||||
const DOOR_LEAF_INTERACTION_DEPTH = 0.08
|
||||
const keyboardMap = [
|
||||
{ name: 'forward', keys: ['ArrowUp', 'KeyW'] },
|
||||
@@ -43,6 +47,12 @@ const doorLeafLocalHit = new Vector3()
|
||||
const doorLeafLocalRay = new Ray()
|
||||
const doorLeafMatrix = new Matrix4()
|
||||
const doorLeafWorldHit = new Vector3()
|
||||
const doorOpeningBox = new Box3()
|
||||
const doorOpeningInverseMatrix = new Matrix4()
|
||||
const doorOpeningLocalHit = new Vector3()
|
||||
const doorOpeningLocalRay = new Ray()
|
||||
const doorOpeningMatrix = new Matrix4()
|
||||
const doorOpeningWorldHit = new Vector3()
|
||||
const spawnWorldPosition = new Vector3()
|
||||
const spawnWorldEuler = new Euler(0, 0, 0, 'YXZ')
|
||||
|
||||
@@ -56,17 +66,6 @@ const resolvePlacedSpawnNode = (
|
||||
return [...candidates].sort((a, b) => a.id.localeCompare(b.id))[0] ?? null
|
||||
}
|
||||
|
||||
function updateDoorOpenState(
|
||||
doorId: AnyNodeId,
|
||||
data: { operationState?: number; swingAngle?: number },
|
||||
) {
|
||||
const scene = useScene.getState()
|
||||
const node = scene.nodes[doorId]
|
||||
scene.updateNode(doorId, data)
|
||||
scene.dirtyNodes.add(doorId)
|
||||
if (node?.parentId) scene.dirtyNodes.add(node.parentId as AnyNodeId)
|
||||
}
|
||||
|
||||
export const FirstPersonControls = () => {
|
||||
const { camera, gl } = useThree()
|
||||
const selectedLevelId = useViewer((state) => state.selection.levelId)
|
||||
@@ -124,6 +123,39 @@ export const FirstPersonControls = () => {
|
||||
if (leafW <= 0 || leafH <= 0) continue
|
||||
|
||||
const leafCenterY = -node.frameThickness / 2
|
||||
|
||||
if (isOperationDoorType(node.doorType)) {
|
||||
doorOpeningMatrix
|
||||
.copy(object.matrixWorld)
|
||||
.multiply(new Matrix4().makeTranslation(0, leafCenterY, 0))
|
||||
doorOpeningInverseMatrix.copy(doorOpeningMatrix).invert()
|
||||
doorOpeningBox.min.set(-leafW / 2, -leafH / 2, -DOOR_LEAF_INTERACTION_DEPTH / 2)
|
||||
doorOpeningBox.max.set(leafW / 2, leafH / 2, DOOR_LEAF_INTERACTION_DEPTH / 2)
|
||||
doorOpeningLocalRay
|
||||
.copy(doorInteractionRaycaster.ray)
|
||||
.applyMatrix4(doorOpeningInverseMatrix)
|
||||
|
||||
const localOpeningHit = doorOpeningLocalRay.intersectBox(
|
||||
doorOpeningBox,
|
||||
doorOpeningLocalHit,
|
||||
)
|
||||
if (!localOpeningHit) continue
|
||||
|
||||
doorOpeningWorldHit.copy(localOpeningHit).applyMatrix4(doorOpeningMatrix)
|
||||
const openingHitDistance = doorOpeningWorldHit.distanceTo(
|
||||
doorInteractionRaycaster.ray.origin,
|
||||
)
|
||||
|
||||
if (
|
||||
openingHitDistance <= DOOR_INTERACTION_DISTANCE &&
|
||||
openingHitDistance < closestDistance
|
||||
) {
|
||||
closestDoorId = doorId as AnyNodeId
|
||||
closestDistance = openingHitDistance
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const hingeX = node.hingesSide === 'right' ? leafW / 2 : -leafW / 2
|
||||
const swingDirectionSign = node.swingDirection === 'inward' ? 1 : -1
|
||||
const hingeDirectionSign = node.hingesSide === 'right' ? 1 : -1
|
||||
@@ -162,27 +194,25 @@ export const FirstPersonControls = () => {
|
||||
const node = useScene.getState().nodes[doorId]
|
||||
if (node?.type !== 'door' || node.openingKind === 'opening') return
|
||||
|
||||
if (
|
||||
node.doorType === 'folding' ||
|
||||
node.doorType === 'pocket' ||
|
||||
node.doorType === 'barn' ||
|
||||
node.doorType === 'sliding' ||
|
||||
node.doorType === 'garage-sectional' ||
|
||||
node.doorType === 'garage-rollup' ||
|
||||
node.doorType === 'garage-tiltup'
|
||||
) {
|
||||
if (isOperationDoorType(node.doorType)) {
|
||||
const currentOpenAmount = node.operationState ?? 0
|
||||
updateDoorOpenState(doorId, {
|
||||
operationState: currentOpenAmount >= 0.5 ? 0 : 1,
|
||||
})
|
||||
animateDoorOpenState(
|
||||
doorId,
|
||||
'operationState',
|
||||
currentOpenAmount,
|
||||
currentOpenAmount >= 0.5 ? 0 : 1,
|
||||
rebuildColliderWorld,
|
||||
)
|
||||
} else {
|
||||
const currentSwingAngle = node.swingAngle ?? 0
|
||||
updateDoorOpenState(doorId, {
|
||||
swingAngle: currentSwingAngle >= DOOR_SWING_OPEN_ANGLE / 2 ? 0 : DOOR_SWING_OPEN_ANGLE,
|
||||
})
|
||||
animateDoorOpenState(
|
||||
doorId,
|
||||
'swingAngle',
|
||||
currentSwingAngle,
|
||||
currentSwingAngle >= DOOR_SWING_OPEN_ANGLE / 2 ? 0 : DOOR_SWING_OPEN_ANGLE,
|
||||
rebuildColliderWorld,
|
||||
)
|
||||
}
|
||||
|
||||
requestAnimationFrame(rebuildColliderWorld)
|
||||
}, [rebuildColliderWorld, resolveInteractableDoorId])
|
||||
|
||||
const placedSpawn = useMemo<FirstPersonSpawn | null>(() => {
|
||||
|
||||
@@ -23,6 +23,7 @@ const UP = new THREE.Vector3(0, 1, 0)
|
||||
const SPAWN_EYE_HEIGHT = 1.65
|
||||
const RAYCAST_CLEARANCE = 25
|
||||
const DOOR_LEAF_COLLIDER_DEPTH = 0.06
|
||||
const OPERATION_DOOR_COLLIDER_OPEN_THRESHOLD = 0.85
|
||||
|
||||
export const FIRST_PERSON_SPAWN_EYE_HEIGHT = SPAWN_EYE_HEIGHT
|
||||
|
||||
@@ -99,19 +100,56 @@ function createDoorLeafColliderGeometry(root: THREE.Object3D, node: DoorNode) {
|
||||
const hasLeafContent = node.segments.some((segment) => segment.type !== 'empty')
|
||||
if (!hasLeafContent) return null
|
||||
|
||||
const isOperationDoor =
|
||||
node.doorType === 'folding' ||
|
||||
node.doorType === 'pocket' ||
|
||||
node.doorType === 'barn' ||
|
||||
node.doorType === 'sliding' ||
|
||||
node.doorType === 'garage-sectional' ||
|
||||
node.doorType === 'garage-rollup' ||
|
||||
node.doorType === 'garage-tiltup'
|
||||
const leafW = node.width - 2 * node.frameThickness
|
||||
const leafH = node.height - node.frameThickness
|
||||
if (leafW <= 0 || leafH <= 0) return null
|
||||
|
||||
const leafCenterY = -node.frameThickness / 2
|
||||
|
||||
root.updateWorldMatrix(true, false)
|
||||
|
||||
if (node.doorType === 'garage-sectional' || node.doorType === 'garage-rollup') {
|
||||
const openAmount =
|
||||
node.doorType === 'garage-sectional'
|
||||
? Math.min(1, (node.operationState ?? 0) / 0.88)
|
||||
: Math.max(0, Math.min(1, node.operationState ?? 0))
|
||||
const visibleHeight = leafH * (1 - openAmount)
|
||||
if (visibleHeight <= 0.12) return null
|
||||
|
||||
const sourceGeometry = new THREE.BoxGeometry(
|
||||
leafW,
|
||||
visibleHeight,
|
||||
DOOR_LEAF_COLLIDER_DEPTH,
|
||||
).toNonIndexed()
|
||||
const geometry = new THREE.BufferGeometry()
|
||||
geometry.setAttribute('position', sourceGeometry.getAttribute('position').clone())
|
||||
geometry.setAttribute('normal', sourceGeometry.getAttribute('normal').clone())
|
||||
sourceGeometry.dispose()
|
||||
const visibleCenterY = leafCenterY - leafH / 2 + visibleHeight / 2
|
||||
geometry.applyMatrix4(
|
||||
root.matrixWorld.clone().multiply(new THREE.Matrix4().makeTranslation(0, visibleCenterY, 0)),
|
||||
)
|
||||
return geometry
|
||||
}
|
||||
|
||||
if (isOperationDoor && (node.operationState ?? 0) >= OPERATION_DOOR_COLLIDER_OPEN_THRESHOLD) {
|
||||
return null
|
||||
}
|
||||
|
||||
const hingeX = node.hingesSide === 'right' ? leafW / 2 : -leafW / 2
|
||||
const swingDirectionSign = node.swingDirection === 'inward' ? 1 : -1
|
||||
const hingeDirectionSign = node.hingesSide === 'right' ? 1 : -1
|
||||
const clampedSwingAngle = Math.max(0, Math.min(Math.PI / 2, node.swingAngle ?? 0))
|
||||
const leafSwingRotation = clampedSwingAngle * swingDirectionSign * hingeDirectionSign
|
||||
|
||||
root.updateWorldMatrix(true, false)
|
||||
|
||||
const sourceGeometry = new THREE.BoxGeometry(
|
||||
leafW,
|
||||
leafH,
|
||||
|
||||
@@ -98,6 +98,18 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
|
||||
edgeMaterial.color.setHex(valid ? 0x22_c5_5e : 0xef_44_44)
|
||||
}
|
||||
|
||||
const getPlacementOrientation = (event: WallEvent) => {
|
||||
const faceSide = getSideFromNormal(event.normal)
|
||||
const side = movingDoorNode.side ?? faceSide
|
||||
const rotationOffset = side !== faceSide ? Math.PI : 0
|
||||
return {
|
||||
side,
|
||||
itemRotation: calculateItemRotation(event.normal) + rotationOffset,
|
||||
cursorRotation:
|
||||
calculateCursorRotation(event.normal, event.node.start, event.node.end) + rotationOffset,
|
||||
}
|
||||
}
|
||||
|
||||
const onWallEnter = (event: WallEvent) => {
|
||||
if (!isValidWallSideFace(event.normal)) return
|
||||
if (isCurvedWall(event.node)) {
|
||||
@@ -106,9 +118,7 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
|
||||
}
|
||||
if (event.node.parentId !== getLevelId()) return
|
||||
|
||||
const side = getSideFromNormal(event.normal)
|
||||
const itemRotation = calculateItemRotation(event.normal)
|
||||
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
|
||||
const { side, itemRotation, cursorRotation } = getPlacementOrientation(event)
|
||||
|
||||
const localX = snapToHalf(event.localPosition[0])
|
||||
const { clampedX, clampedY } = clampToWall(
|
||||
@@ -167,9 +177,7 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
|
||||
}
|
||||
if (event.node.parentId !== getLevelId()) return
|
||||
|
||||
const side = getSideFromNormal(event.normal)
|
||||
const itemRotation = calculateItemRotation(event.normal)
|
||||
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
|
||||
const { side, itemRotation, cursorRotation } = getPlacementOrientation(event)
|
||||
|
||||
const localX = snapToHalf(event.localPosition[0])
|
||||
const { clampedX, clampedY } = clampToWall(
|
||||
@@ -234,8 +242,7 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
|
||||
if (isCurvedWall(event.node)) return
|
||||
if (event.node.parentId !== getLevelId()) return
|
||||
|
||||
const side = getSideFromNormal(event.normal)
|
||||
const itemRotation = calculateItemRotation(event.normal)
|
||||
const { side, itemRotation } = getPlacementOrientation(event)
|
||||
|
||||
const localX = snapToHalf(event.localPosition[0])
|
||||
const { clampedX, clampedY } = clampToWall(
|
||||
|
||||
@@ -1,55 +1,16 @@
|
||||
import { type AnyNodeId, emitter, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect } from 'react'
|
||||
import {
|
||||
animateDoorOpenState,
|
||||
DOOR_SWING_OPEN_ANGLE,
|
||||
isOperationDoorType,
|
||||
updateDoorOpenState,
|
||||
} from '../lib/door-interaction'
|
||||
import { runRedo, runUndo } from '../lib/history'
|
||||
import { sfxEmitter } from '../lib/sfx-bus'
|
||||
import useEditor from '../store/use-editor'
|
||||
|
||||
const DOOR_SWING_OPEN_ANGLE = Math.PI / 2
|
||||
const DOOR_TOGGLE_ANIMATION_MS = 520
|
||||
const activeDoorAnimations = new Map<AnyNodeId, number>()
|
||||
|
||||
function updateDoorOpenState(
|
||||
doorId: AnyNodeId,
|
||||
data: { operationState?: number; swingAngle?: number },
|
||||
) {
|
||||
const scene = useScene.getState()
|
||||
const node = scene.nodes[doorId]
|
||||
scene.updateNode(doorId, data)
|
||||
scene.dirtyNodes.add(doorId)
|
||||
if (node?.parentId) scene.dirtyNodes.add(node.parentId as AnyNodeId)
|
||||
}
|
||||
|
||||
function animateDoorOpenState(
|
||||
doorId: AnyNodeId,
|
||||
field: 'operationState' | 'swingAngle',
|
||||
from: number,
|
||||
to: number,
|
||||
) {
|
||||
const existingFrame = activeDoorAnimations.get(doorId)
|
||||
if (existingFrame !== undefined) {
|
||||
window.cancelAnimationFrame(existingFrame)
|
||||
}
|
||||
|
||||
const startedAt = performance.now()
|
||||
const ease = (value: number) => value * value * (3 - 2 * value)
|
||||
|
||||
const tick = (now: number) => {
|
||||
const progress = Math.min(1, (now - startedAt) / DOOR_TOGGLE_ANIMATION_MS)
|
||||
const value = from + (to - from) * ease(progress)
|
||||
updateDoorOpenState(doorId, { [field]: value })
|
||||
|
||||
if (progress < 1) {
|
||||
activeDoorAnimations.set(doorId, window.requestAnimationFrame(tick))
|
||||
} else {
|
||||
activeDoorAnimations.delete(doorId)
|
||||
updateDoorOpenState(doorId, { [field]: to })
|
||||
}
|
||||
}
|
||||
|
||||
activeDoorAnimations.set(doorId, window.requestAnimationFrame(tick))
|
||||
}
|
||||
|
||||
// Tools call this in their onCancel handler when they have an active mid-action to cancel,
|
||||
// so that the global Escape handler knows not to also switch to select mode.
|
||||
let _toolCancelConsumed = false
|
||||
@@ -197,15 +158,7 @@ export const useKeyboard = ({
|
||||
if (node?.type === 'door') {
|
||||
e.preventDefault()
|
||||
if (node.openingKind !== 'opening') {
|
||||
if (
|
||||
node.doorType === 'folding' ||
|
||||
node.doorType === 'pocket' ||
|
||||
node.doorType === 'barn' ||
|
||||
node.doorType === 'sliding' ||
|
||||
node.doorType === 'garage-sectional' ||
|
||||
node.doorType === 'garage-rollup' ||
|
||||
node.doorType === 'garage-tiltup'
|
||||
) {
|
||||
if (isOperationDoorType(node.doorType)) {
|
||||
const currentOpenAmount = node.operationState ?? 0
|
||||
animateDoorOpenState(
|
||||
node.id,
|
||||
@@ -249,15 +202,7 @@ export const useKeyboard = ({
|
||||
if (node.openingKind !== 'opening') {
|
||||
updateDoorOpenState(
|
||||
node.id,
|
||||
node.doorType === 'folding' ||
|
||||
node.doorType === 'pocket' ||
|
||||
node.doorType === 'barn' ||
|
||||
node.doorType === 'sliding' ||
|
||||
node.doorType === 'garage-sectional' ||
|
||||
node.doorType === 'garage-rollup' ||
|
||||
node.doorType === 'garage-tiltup'
|
||||
? { operationState: 0 }
|
||||
: { swingAngle: 0 },
|
||||
isOperationDoorType(node.doorType) ? { operationState: 0 } : { swingAngle: 0 },
|
||||
)
|
||||
sfxEmitter.emit('sfx:item-rotate')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { type AnyNodeId, useScene } from '@pascal-app/core'
|
||||
|
||||
export const DOOR_SWING_OPEN_ANGLE = Math.PI / 2
|
||||
|
||||
const DOOR_TOGGLE_ANIMATION_MS = 520
|
||||
const activeDoorAnimations = new Map<AnyNodeId, number>()
|
||||
|
||||
export function isOperationDoorType(doorType: string | undefined) {
|
||||
return (
|
||||
doorType === 'folding' ||
|
||||
doorType === 'pocket' ||
|
||||
doorType === 'barn' ||
|
||||
doorType === 'sliding' ||
|
||||
doorType === 'garage-sectional' ||
|
||||
doorType === 'garage-rollup' ||
|
||||
doorType === 'garage-tiltup'
|
||||
)
|
||||
}
|
||||
|
||||
export function updateDoorOpenState(
|
||||
doorId: AnyNodeId,
|
||||
data: { operationState?: number; swingAngle?: number },
|
||||
) {
|
||||
const scene = useScene.getState()
|
||||
const node = scene.nodes[doorId]
|
||||
scene.updateNode(doorId, data)
|
||||
scene.dirtyNodes.add(doorId)
|
||||
if (node?.parentId) scene.dirtyNodes.add(node.parentId as AnyNodeId)
|
||||
}
|
||||
|
||||
export function animateDoorOpenState(
|
||||
doorId: AnyNodeId,
|
||||
field: 'operationState' | 'swingAngle',
|
||||
from: number,
|
||||
to: number,
|
||||
onComplete?: () => void,
|
||||
) {
|
||||
const existingFrame = activeDoorAnimations.get(doorId)
|
||||
if (existingFrame !== undefined) {
|
||||
window.cancelAnimationFrame(existingFrame)
|
||||
}
|
||||
|
||||
const startedAt = performance.now()
|
||||
const ease = (value: number) => value * value * (3 - 2 * value)
|
||||
|
||||
const tick = (now: number) => {
|
||||
const progress = Math.min(1, (now - startedAt) / DOOR_TOGGLE_ANIMATION_MS)
|
||||
const value = from + (to - from) * ease(progress)
|
||||
updateDoorOpenState(doorId, { [field]: value })
|
||||
|
||||
if (progress < 1) {
|
||||
activeDoorAnimations.set(doorId, window.requestAnimationFrame(tick))
|
||||
} else {
|
||||
activeDoorAnimations.delete(doorId)
|
||||
updateDoorOpenState(doorId, { [field]: to })
|
||||
onComplete?.()
|
||||
}
|
||||
}
|
||||
|
||||
activeDoorAnimations.set(doorId, window.requestAnimationFrame(tick))
|
||||
}
|
||||
Reference in New Issue
Block a user