Move door animations into viewer system
This commit is contained in:
@@ -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 &
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleDoorOpenState(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,
|
||||||
|
currentOpenAmount >= 0.5 ? 0 : 1,
|
||||||
|
options,
|
||||||
|
)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const startedAt = performance.now()
|
const currentSwingAngle = getDisplayedDoorValue(doorId, 'swingAngle', node.swingAngle)
|
||||||
const ease = (value: number) => value * value * (3 - 2 * value)
|
startDoorOpenAnimation(
|
||||||
|
doorId,
|
||||||
|
'swingAngle',
|
||||||
|
currentSwingAngle,
|
||||||
|
currentSwingAngle >= DOOR_SWING_OPEN_ANGLE / 2 ? 0 : DOOR_SWING_OPEN_ANGLE,
|
||||||
|
options,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const tick = (now: number) => {
|
export function closeDoorOpenState(doorId: AnyNodeId, options?: DoorOpenAnimationOptions) {
|
||||||
const progress = Math.min(1, (now - startedAt) / DOOR_TOGGLE_ANIMATION_MS)
|
const node = useScene.getState().nodes[doorId]
|
||||||
const value = from + (to - from) * ease(progress)
|
if (node?.type !== 'door' || node.openingKind === 'opening') return
|
||||||
setRuntimeDoorOpenState(doorId, { [field]: value })
|
|
||||||
|
|
||||||
if (progress < 1) {
|
if (isOperationDoorType(node.doorType)) {
|
||||||
activeDoorAnimations.set(doorId, window.requestAnimationFrame(tick))
|
const currentOpenAmount = getDisplayedDoorValue(doorId, 'operationState', node.operationState)
|
||||||
} else {
|
startDoorOpenAnimation(doorId, 'operationState', currentOpenAmount, 0, options)
|
||||||
activeDoorAnimations.delete(doorId)
|
return
|
||||||
if (options?.persist ?? true) {
|
|
||||||
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, 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user