Move door animations into viewer system

This commit is contained in:
sudhir
2026-05-07 10:11:25 +05:30
parent 0de07d6130
commit cef3f24dad
8 changed files with 181 additions and 109 deletions
+8
View File
@@ -137,6 +137,13 @@ type GuideEvents = {
'guide:deleted': { guideId: GuideNode['id'] } 'guide:deleted': { guideId: GuideNode['id'] }
} }
type DoorAnimationEvents = {
'door:animation-completed': {
doorId: DoorNode['id']
field: 'operationState' | 'swingAngle'
}
}
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 }
@@ -178,6 +185,7 @@ type EditorEvents = GridEvents &
CameraControlEvents & CameraControlEvents &
ToolEvents & ToolEvents &
GuideEvents & GuideEvents &
DoorAnimationEvents &
PresetEvents & PresetEvents &
ThumbnailEvents & ThumbnailEvents &
SnapshotEvents & SnapshotEvents &
+1
View File
@@ -68,6 +68,7 @@ export {
} from './store/history-control' } from './store/history-control'
export { export {
type ControlValue, type ControlValue,
type DoorAnimationState,
type DoorInteractiveState, type DoorInteractiveState,
type ItemInteractiveState, type ItemInteractiveState,
useInteractive, useInteractive,
@@ -17,9 +17,19 @@ export type DoorInteractiveState = {
swingAngle?: number swingAngle?: number
} }
export type DoorAnimationState = {
field: keyof DoorInteractiveState
from: number
to: number
startedAt: number | null
durationMs: number
persist: boolean
}
type InteractiveStore = { type InteractiveStore = {
items: Record<AnyNodeId, ItemInteractiveState> items: Record<AnyNodeId, ItemInteractiveState>
doors: Record<AnyNodeId, DoorInteractiveState> doors: Record<AnyNodeId, DoorInteractiveState>
doorAnimations: Record<AnyNodeId, DoorAnimationState>
/** Initialize a node's interactive state from its asset definition (idempotent) */ /** Initialize a node's interactive state from its asset definition (idempotent) */
initItem: (itemId: AnyNodeId, interactive: Interactive) => void initItem: (itemId: AnyNodeId, interactive: Interactive) => void
@@ -35,6 +45,12 @@ type InteractiveStore = {
/** Clear transient door open state */ /** Clear transient door open state */
removeDoorOpenState: (doorId: AnyNodeId) => void removeDoorOpenState: (doorId: AnyNodeId) => void
/** Queue a door animation for the viewer frame loop */
startDoorAnimation: (doorId: AnyNodeId, value: DoorAnimationState) => void
/** Cancel a queued door animation */
cancelDoorAnimation: (doorId: AnyNodeId) => void
} }
const defaultControlValue = (interactive: Interactive, index: number): ControlValue => { const defaultControlValue = (interactive: Interactive, index: number): ControlValue => {
@@ -53,6 +69,7 @@ const defaultControlValue = (interactive: Interactive, index: number): ControlVa
export const useInteractive = create<InteractiveStore>((set, get) => ({ export const useInteractive = create<InteractiveStore>((set, get) => ({
items: {}, items: {},
doors: {}, doors: {},
doorAnimations: {},
initItem: (itemId, interactive) => { initItem: (itemId, interactive) => {
const { controls } = interactive const { controls } = interactive
@@ -106,4 +123,20 @@ export const useInteractive = create<InteractiveStore>((set, get) => ({
return { doors: rest } return { doors: rest }
}) })
}, },
startDoorAnimation: (doorId, value) => {
set((state) => ({
doorAnimations: {
...state.doorAnimations,
[doorId]: value,
},
}))
},
cancelDoorAnimation: (doorId) => {
set((state) => {
const { [doorId]: _, ...rest } = state.doorAnimations
return { doorAnimations: rest }
})
},
})) }))
@@ -1,16 +1,16 @@
'use client' 'use client'
import '../../three-types' import '../../three-types'
import { type AnyNodeId, sceneRegistry, useInteractive, useScene } from '@pascal-app/core' import { type AnyNodeId, emitter, sceneRegistry, useInteractive, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { KeyboardControls } from '@react-three/drei' import { KeyboardControls } from '@react-three/drei'
import { useFrame, useThree } from '@react-three/fiber' 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 {
animateDoorOpenState,
DOOR_SWING_OPEN_ANGLE, DOOR_SWING_OPEN_ANGLE,
isOperationDoorType, isOperationDoorType,
toggleDoorOpenState,
} from '../../lib/door-interaction' } from '../../lib/door-interaction'
import useEditor from '../../store/use-editor' import useEditor from '../../store/use-editor'
import { import {
@@ -159,7 +159,9 @@ export const FirstPersonControls = () => {
const hingeX = node.hingesSide === 'right' ? leafW / 2 : -leafW / 2 const hingeX = node.hingesSide === 'right' ? leafW / 2 : -leafW / 2
const swingDirectionSign = node.swingDirection === 'inward' ? 1 : -1 const swingDirectionSign = node.swingDirection === 'inward' ? 1 : -1
const hingeDirectionSign = node.hingesSide === 'right' ? 1 : -1 const hingeDirectionSign = node.hingesSide === 'right' ? 1 : -1
const clampedSwingAngle = Math.max(0, Math.min(DOOR_SWING_OPEN_ANGLE, node.swingAngle ?? 0)) const currentSwingAngle =
useInteractive.getState().doors[doorId as AnyNodeId]?.swingAngle ?? node.swingAngle ?? 0
const clampedSwingAngle = Math.max(0, Math.min(DOOR_SWING_OPEN_ANGLE, currentSwingAngle))
const leafSwingRotation = clampedSwingAngle * swingDirectionSign * hingeDirectionSign const leafSwingRotation = clampedSwingAngle * swingDirectionSign * hingeDirectionSign
doorLeafMatrix doorLeafMatrix
@@ -194,30 +196,8 @@ export const FirstPersonControls = () => {
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
if (isOperationDoorType(node.doorType)) { toggleDoorOpenState(doorId, { persist: false })
const currentOpenAmount = }, [resolveInteractableDoorId])
useInteractive.getState().doors[doorId]?.operationState ?? node.operationState ?? 0
animateDoorOpenState(
doorId,
'operationState',
currentOpenAmount,
currentOpenAmount >= 0.5 ? 0 : 1,
rebuildColliderWorld,
{ persist: false },
)
} else {
const currentSwingAngle =
useInteractive.getState().doors[doorId]?.swingAngle ?? node.swingAngle ?? 0
animateDoorOpenState(
doorId,
'swingAngle',
currentSwingAngle,
currentSwingAngle >= DOOR_SWING_OPEN_ANGLE / 2 ? 0 : DOOR_SWING_OPEN_ANGLE,
rebuildColliderWorld,
{ persist: false },
)
}
}, [rebuildColliderWorld, resolveInteractableDoorId])
const placedSpawn = useMemo<FirstPersonSpawn | null>(() => { const placedSpawn = useMemo<FirstPersonSpawn | null>(() => {
if (!(placedSpawnNode && placedSpawnNode.type === 'spawn')) return null if (!(placedSpawnNode && placedSpawnNode.type === 'spawn')) return null
@@ -258,6 +238,11 @@ export const FirstPersonControls = () => {
} }
}, [rebuildColliderWorld]) }, [rebuildColliderWorld])
useEffect(() => {
emitter.on('door:animation-completed', rebuildColliderWorld)
return () => emitter.off('door:animation-completed', rebuildColliderWorld)
}, [rebuildColliderWorld])
useEffect(() => { useEffect(() => {
if (!world) return if (!world) return
if (controllerStart) return if (controllerStart) return
+3 -27
View File
@@ -1,12 +1,7 @@
import { type AnyNodeId, emitter, useScene } from '@pascal-app/core' import { type AnyNodeId, emitter, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useEffect } from 'react' import { useEffect } from 'react'
import { import { closeDoorOpenState, toggleDoorOpenState } from '../lib/door-interaction'
animateDoorOpenState,
DOOR_SWING_OPEN_ANGLE,
isOperationDoorType,
updateDoorOpenState,
} from '../lib/door-interaction'
import { runRedo, runUndo } from '../lib/history' import { runRedo, runUndo } from '../lib/history'
import { sfxEmitter } from '../lib/sfx-bus' import { sfxEmitter } from '../lib/sfx-bus'
import useEditor from '../store/use-editor' import useEditor from '../store/use-editor'
@@ -158,23 +153,7 @@ export const useKeyboard = ({
if (node?.type === 'door') { if (node?.type === 'door') {
e.preventDefault() e.preventDefault()
if (node.openingKind !== 'opening') { if (node.openingKind !== 'opening') {
if (isOperationDoorType(node.doorType)) { toggleDoorOpenState(node.id)
const currentOpenAmount = node.operationState ?? 0
animateDoorOpenState(
node.id,
'operationState',
currentOpenAmount,
currentOpenAmount >= 0.5 ? 0 : 1,
)
} else {
const currentSwingAngle = node.swingAngle ?? 0
animateDoorOpenState(
node.id,
'swingAngle',
currentSwingAngle,
currentSwingAngle >= DOOR_SWING_OPEN_ANGLE / 2 ? 0 : DOOR_SWING_OPEN_ANGLE,
)
}
sfxEmitter.emit('sfx:item-rotate') sfxEmitter.emit('sfx:item-rotate')
} }
} else if (node && 'rotation' in node) { } else if (node && 'rotation' in node) {
@@ -200,10 +179,7 @@ export const useKeyboard = ({
if (node?.type === 'door') { if (node?.type === 'door') {
e.preventDefault() e.preventDefault()
if (node.openingKind !== 'opening') { if (node.openingKind !== 'opening') {
updateDoorOpenState( closeDoorOpenState(node.id)
node.id,
isOperationDoorType(node.doorType) ? { operationState: 0 } : { swingAngle: 0 },
)
sfxEmitter.emit('sfx:item-rotate') sfxEmitter.emit('sfx:item-rotate')
} }
} else if (node && 'rotation' in node) { } else if (node && 'rotation' in node) {
+64 -56
View File
@@ -1,9 +1,13 @@
import { type AnyNodeId, isOperationDoorType, useInteractive, useScene } from '@pascal-app/core' import {
type AnyNodeId,
type DoorInteractiveState,
isOperationDoorType,
useInteractive,
useScene,
} from '@pascal-app/core'
export const DOOR_SWING_OPEN_ANGLE = Math.PI / 2 export const DOOR_SWING_OPEN_ANGLE = Math.PI / 2
export const DOOR_TOGGLE_ANIMATION_MS = 520
const DOOR_TOGGLE_ANIMATION_MS = 520
const activeDoorAnimations = new Map<AnyNodeId, number>()
export { isOperationDoorType } export { isOperationDoorType }
@@ -11,70 +15,74 @@ type DoorOpenAnimationOptions = {
persist?: boolean persist?: boolean
} }
export function updateDoorOpenState( function getDisplayedDoorValue(
doorId: AnyNodeId, doorId: AnyNodeId,
data: { operationState?: number; swingAngle?: number }, field: keyof DoorInteractiveState,
nodeValue: number | undefined,
) { ) {
const scene = useScene.getState() const interactive = useInteractive.getState()
const node = scene.nodes[doorId] const runtimeValue = interactive.doors[doorId]?.[field]
scene.updateNode(doorId, data) if (runtimeValue !== undefined) return runtimeValue
scene.dirtyNodes.add(doorId)
if (node?.parentId) scene.dirtyNodes.add(node.parentId as AnyNodeId) const queuedValue = interactive.doorAnimations[doorId]?.from
if (queuedValue !== undefined) return queuedValue
return nodeValue ?? 0
} }
function setRuntimeDoorOpenState( function startDoorOpenAnimation(
doorId: AnyNodeId, doorId: AnyNodeId,
data: { operationState?: number; swingAngle?: number }, field: keyof DoorInteractiveState,
) {
const scene = useScene.getState()
const node = scene.nodes[doorId]
useInteractive.getState().setDoorOpenState(doorId, data)
scene.dirtyNodes.add(doorId)
if (node?.parentId) scene.dirtyNodes.add(node.parentId as AnyNodeId)
}
function clearRuntimeDoorOpenState(doorId: AnyNodeId) {
const scene = useScene.getState()
const node = scene.nodes[doorId]
useInteractive.getState().removeDoorOpenState(doorId)
scene.dirtyNodes.add(doorId)
if (node?.parentId) scene.dirtyNodes.add(node.parentId as AnyNodeId)
}
export function animateDoorOpenState(
doorId: AnyNodeId,
field: 'operationState' | 'swingAngle',
from: number, from: number,
to: number, to: number,
onComplete?: () => void,
options?: DoorOpenAnimationOptions, options?: DoorOpenAnimationOptions,
) { ) {
const existingFrame = activeDoorAnimations.get(doorId) useInteractive.getState().startDoorAnimation(doorId, {
if (existingFrame !== undefined) { field,
window.cancelAnimationFrame(existingFrame) from,
to,
startedAt: null,
durationMs: DOOR_TOGGLE_ANIMATION_MS,
persist: options?.persist ?? true,
})
} }
const startedAt = performance.now() export function toggleDoorOpenState(doorId: AnyNodeId, options?: DoorOpenAnimationOptions) {
const ease = (value: number) => value * value * (3 - 2 * value) const node = useScene.getState().nodes[doorId]
if (node?.type !== 'door' || node.openingKind === 'opening') return
const tick = (now: number) => { if (isOperationDoorType(node.doorType)) {
const progress = Math.min(1, (now - startedAt) / DOOR_TOGGLE_ANIMATION_MS) const currentOpenAmount = getDisplayedDoorValue(doorId, 'operationState', node.operationState)
const value = from + (to - from) * ease(progress) startDoorOpenAnimation(
setRuntimeDoorOpenState(doorId, { [field]: value }) doorId,
'operationState',
if (progress < 1) { currentOpenAmount,
activeDoorAnimations.set(doorId, window.requestAnimationFrame(tick)) currentOpenAmount >= 0.5 ? 0 : 1,
} else { options,
activeDoorAnimations.delete(doorId) )
if (options?.persist ?? true) { return
updateDoorOpenState(doorId, { [field]: to })
clearRuntimeDoorOpenState(doorId)
} else {
setRuntimeDoorOpenState(doorId, { [field]: to })
}
onComplete?.()
}
} }
activeDoorAnimations.set(doorId, window.requestAnimationFrame(tick)) const currentSwingAngle = getDisplayedDoorValue(doorId, 'swingAngle', node.swingAngle)
startDoorOpenAnimation(
doorId,
'swingAngle',
currentSwingAngle,
currentSwingAngle >= DOOR_SWING_OPEN_ANGLE / 2 ? 0 : DOOR_SWING_OPEN_ANGLE,
options,
)
}
export function closeDoorOpenState(doorId: AnyNodeId, options?: DoorOpenAnimationOptions) {
const node = useScene.getState().nodes[doorId]
if (node?.type !== 'door' || node.openingKind === 'opening') return
if (isOperationDoorType(node.doorType)) {
const currentOpenAmount = getDisplayedDoorValue(doorId, 'operationState', node.operationState)
startDoorOpenAnimation(doorId, 'operationState', currentOpenAmount, 0, options)
return
}
const currentSwingAngle = getDisplayedDoorValue(doorId, 'swingAngle', node.swingAngle)
startDoorOpenAnimation(doorId, 'swingAngle', currentSwingAngle, 0, options)
} }
@@ -6,6 +6,7 @@ import { useEffect, useMemo, useRef } from 'react'
import * as THREE from 'three/webgpu' import * as THREE from 'three/webgpu'
import useViewer from '../../store/use-viewer' import useViewer from '../../store/use-viewer'
import { CeilingSystem } from '../../systems/ceiling/ceiling-system' import { CeilingSystem } from '../../systems/ceiling/ceiling-system'
import { DoorAnimationSystem } from '../../systems/door/door-animation-system'
import { DoorSystem } from '../../systems/door/door-system' import { DoorSystem } from '../../systems/door/door-system'
import { FenceSystem } from '../../systems/fence/fence-system' import { FenceSystem } from '../../systems/fence/fence-system'
import { GuideSystem } from '../../systems/guide/guide-system' import { GuideSystem } from '../../systems/guide/guide-system'
@@ -225,6 +226,7 @@ const Viewer: React.FC<ViewerProps> = ({
<WallCutout /> <WallCutout />
{/* Core systems */} {/* Core systems */}
<CeilingSystem /> <CeilingSystem />
<DoorAnimationSystem />
<DoorSystem /> <DoorSystem />
<FenceSystem /> <FenceSystem />
<ItemSystem /> <ItemSystem />
@@ -0,0 +1,59 @@
import { type AnyNodeId, type DoorNode, emitter, useInteractive, useScene } from '@pascal-app/core'
import { useFrame } from '@react-three/fiber'
const easeDoorAnimation = (value: number) => value * value * (3 - 2 * value)
function markDoorDirty(doorId: AnyNodeId) {
const scene = useScene.getState()
const node = scene.nodes[doorId]
scene.dirtyNodes.add(doorId)
if (node?.parentId) scene.dirtyNodes.add(node.parentId as AnyNodeId)
}
export const DoorAnimationSystem = () => {
useFrame(({ clock }) => {
const interactive = useInteractive.getState()
const entries = Object.entries(interactive.doorAnimations)
if (entries.length === 0) return
const now = clock.getElapsedTime() * 1000
for (const [doorId, animation] of entries) {
const typedDoorId = doorId as AnyNodeId
const scene = useScene.getState()
const node = scene.nodes[typedDoorId]
if (node?.type !== 'door') {
interactive.cancelDoorAnimation(typedDoorId)
interactive.removeDoorOpenState(typedDoorId)
continue
}
const startedAt = animation.startedAt ?? now
if (animation.startedAt === null) {
interactive.startDoorAnimation(typedDoorId, { ...animation, startedAt })
}
const progress = Math.min(1, (now - startedAt) / animation.durationMs)
const value = animation.from + (animation.to - animation.from) * easeDoorAnimation(progress)
interactive.setDoorOpenState(typedDoorId, { [animation.field]: value })
markDoorDirty(typedDoorId)
if (progress < 1) continue
interactive.cancelDoorAnimation(typedDoorId)
if (animation.persist) {
scene.updateNode(typedDoorId, { [animation.field]: animation.to })
interactive.removeDoorOpenState(typedDoorId)
markDoorDirty(typedDoorId)
} else {
interactive.setDoorOpenState(typedDoorId, { [animation.field]: animation.to })
}
emitter.emit('door:animation-completed', {
doorId: typedDoorId as DoorNode['id'],
field: animation.field,
})
}
}, 2)
return null
}