import { type AnyNodeId, type ElevatorNode, useInteractive, useLiveNodeOverrides, useLiveTransforms, useRegistry, useScene, } from '@pascal-app/core' import { type ThreeEvent, useFrame } from '@react-three/fiber' import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react' import { BoxGeometry, CylinderGeometry, type Group, type InstancedMesh, MeshStandardMaterial, Object3D, TorusGeometry, } from 'three' import { useShallow } from 'zustand/react/shallow' import { useNodeEvents } from '../../../hooks/use-node-events' import { resolveElevatorLevels } from '../../../systems/elevator/elevator-utils' const SHAFT_WALL_COLOR = '#d7dce4' const SHAFT_SIDE_COLOR = '#4b5563' const SHAFT_TRIM_COLOR = '#eef2f7' const CAB_COLOR = '#d7dde5' const GLASS_COLOR = '#f8fafc' const DOOR_COLOR = '#8e98a6' const PANEL_COLOR = '#1f2937' type Vector3Tuple = [number, number, number] const UNIT_BOX_GEOMETRY = new BoxGeometry(1, 1, 1) const BUTTON_FACE_GEOMETRY = new CylinderGeometry(1, 0.92, 1, 24) const BUTTON_GLOW_GEOMETRY = new CylinderGeometry(1.42, 1.42, 1, 24) const BUTTON_RING_GEOMETRY = new TorusGeometry(1.12, 0.12, 8, 24) const LABEL_MATRIX_DUMMY = new Object3D() const SHAFT_TOP_FRAME_CLEARANCE = 0.006 const SHAFT_WALL_MATERIAL = new MeshStandardMaterial({ color: SHAFT_WALL_COLOR, metalness: 0.08, roughness: 0.56, }) const SHAFT_SIDE_MATERIAL = new MeshStandardMaterial({ color: SHAFT_SIDE_COLOR, metalness: 0.12, roughness: 0.58, }) const SHAFT_TRIM_MATERIAL = new MeshStandardMaterial({ color: SHAFT_TRIM_COLOR, metalness: 0.2, roughness: 0.38, }) const CAB_MATERIAL = new MeshStandardMaterial({ color: CAB_COLOR, metalness: 0.2, roughness: 0.48, }) const DOOR_MATERIAL = new MeshStandardMaterial({ color: DOOR_COLOR, metalness: 0.34, roughness: 0.34, }) const GLASS_MATERIAL = new MeshStandardMaterial({ color: GLASS_COLOR, depthWrite: false, metalness: 0, opacity: 0.2, roughness: 0.08, transparent: true, }) const PANEL_MATERIAL = new MeshStandardMaterial({ color: PANEL_COLOR, metalness: 0.32, roughness: 0.36, }) const LANDING_PANEL_MATERIAL = new MeshStandardMaterial({ color: PANEL_COLOR, metalness: 0.25, roughness: 0.4, }) const INDICATOR_SCREEN_MATERIALS = { active: new MeshStandardMaterial({ color: '#041f2f', emissive: '#0ea5e9', emissiveIntensity: 0.16, metalness: 0.12, roughness: 0.38, }), idle: new MeshStandardMaterial({ color: '#111827', metalness: 0.12, roughness: 0.38, }), } const INDICATOR_GLYPH_MATERIALS = { active: new MeshStandardMaterial({ color: '#38bdf8', emissive: '#38bdf8', emissiveIntensity: 0.36, metalness: 0.08, roughness: 0.32, }), idle: new MeshStandardMaterial({ color: '#94a3b8', emissive: '#94a3b8', emissiveIntensity: 0.18, metalness: 0.08, roughness: 0.32, }), } const BUTTON_FACE_MATERIALS = { active: new MeshStandardMaterial({ color: '#38bdf8', emissive: '#38bdf8', emissiveIntensity: 0.28, metalness: 0.22, roughness: 0.3, }), queued: new MeshStandardMaterial({ color: '#fbbf24', emissive: '#fbbf24', emissiveIntensity: 0.18, metalness: 0.22, roughness: 0.3, }), idle: new MeshStandardMaterial({ color: '#d6dde7', metalness: 0.22, roughness: 0.3, }), } const BUTTON_RING_MATERIALS = { active: new MeshStandardMaterial({ color: '#0ea5e9', emissive: '#0ea5e9', emissiveIntensity: 0.16, metalness: 0.48, roughness: 0.28, }), queued: new MeshStandardMaterial({ color: '#f59e0b', emissive: '#f59e0b', emissiveIntensity: 0.1, metalness: 0.48, roughness: 0.28, }), idle: new MeshStandardMaterial({ color: '#64748b', metalness: 0.48, roughness: 0.28, }), } const BUTTON_GLOW_MATERIALS = { active: new MeshStandardMaterial({ color: '#38bdf8', depthWrite: false, emissive: '#38bdf8', emissiveIntensity: 0.28, opacity: 0.58, transparent: true, }), queued: new MeshStandardMaterial({ color: '#fbbf24', depthWrite: false, emissive: '#fbbf24', emissiveIntensity: 0.18, opacity: 0.58, transparent: true, }), } const BUTTON_LABEL_MATERIALS = { lit: new MeshStandardMaterial({ color: '#111827', metalness: 0.12, roughness: 0.34, }), idle: new MeshStandardMaterial({ color: '#334155', metalness: 0.12, roughness: 0.34, }), } const QUEUE_STRIP_MATERIALS = { queued: new MeshStandardMaterial({ color: '#fbbf24', emissive: '#fbbf24', emissiveIntensity: 0.16, metalness: 0.18, roughness: 0.42, }), idle: new MeshStandardMaterial({ color: '#64748b', metalness: 0.18, roughness: 0.42, }), } type ElevatorButtonAction = 'open-door' | 'request-level' type SegmentName = | 'bottom' | 'lowerLeft' | 'lowerRight' | 'middle' | 'top' | 'upperLeft' | 'upperRight' const DIGIT_SEGMENTS: Record = { '0': ['top', 'upperLeft', 'upperRight', 'lowerLeft', 'lowerRight', 'bottom'], '1': ['upperRight', 'lowerRight'], '2': ['top', 'upperRight', 'middle', 'lowerLeft', 'bottom'], '3': ['top', 'upperRight', 'middle', 'lowerRight', 'bottom'], '4': ['upperLeft', 'upperRight', 'middle', 'lowerRight'], '5': ['top', 'upperLeft', 'middle', 'lowerRight', 'bottom'], '6': ['top', 'upperLeft', 'middle', 'lowerLeft', 'lowerRight', 'bottom'], '7': ['top', 'upperRight', 'lowerRight'], '8': ['top', 'upperLeft', 'upperRight', 'middle', 'lowerLeft', 'lowerRight', 'bottom'], '9': ['top', 'upperLeft', 'upperRight', 'middle', 'lowerRight', 'bottom'], '-': ['middle'], } const SEGMENT_PROPS: Record< SegmentName, { position: [number, number, number]; size: [number, number, number] } > = { bottom: { position: [0, -0.44, 0], size: [0.56, 0.11, 0.018] }, lowerLeft: { position: [-0.32, -0.22, 0], size: [0.11, 0.42, 0.018] }, lowerRight: { position: [0.32, -0.22, 0], size: [0.11, 0.42, 0.018] }, middle: { position: [0, 0, 0], size: [0.52, 0.1, 0.018] }, top: { position: [0, 0.44, 0], size: [0.56, 0.11, 0.018] }, upperLeft: { position: [-0.32, 0.22, 0], size: [0.11, 0.42, 0.018] }, upperRight: { position: [0.32, 0.22, 0], size: [0.11, 0.42, 0.018] }, } function BoxPrimitive({ castShadow = false, material, position, receiveShadow = false, rotation, scale, }: { castShadow?: boolean material: MeshStandardMaterial position?: Vector3Tuple receiveShadow?: boolean rotation?: Vector3Tuple scale: Vector3Tuple }) { return ( ) } function MeshButtonLabel({ label, material, position, scale, }: { label: string material: MeshStandardMaterial position: [number, number, number] scale: number }) { const ref = useRef(null) const instances = useMemo(() => { const characters = label.split('').filter((character) => DIGIT_SEGMENTS[character]) const spacing = 0.72 * scale const startX = -((characters.length - 1) * spacing) / 2 return characters.flatMap((character, charIndex) => (DIGIT_SEGMENTS[character] ?? []).map((segment) => { const props = SEGMENT_PROPS[segment] return { position: [ startX + charIndex * spacing + props.position[0] * scale, props.position[1] * scale, props.position[2], ] as Vector3Tuple, scale: [props.size[0] * scale, props.size[1] * scale, props.size[2]] as Vector3Tuple, } }), ) }, [label, scale]) const applyInstanceMatrices = useCallback( (mesh: InstancedMesh) => { for (let index = 0; index < instances.length; index += 1) { const instance = instances[index] if (!instance) continue LABEL_MATRIX_DUMMY.position.set(...instance.position) LABEL_MATRIX_DUMMY.rotation.set(0, 0, 0) LABEL_MATRIX_DUMMY.scale.set(...instance.scale) LABEL_MATRIX_DUMMY.updateMatrix() mesh.setMatrixAt(index, LABEL_MATRIX_DUMMY.matrix) } mesh.instanceMatrix.needsUpdate = true }, [instances], ) useLayoutEffect(() => { const mesh = ref.current if (!mesh) return applyInstanceMatrices(mesh) }, [applyInstanceMatrices]) if (instances.length === 0) return null return ( ) } function ElevatorDirectionGlyph({ direction, material, position, scale, }: { direction: 'down' | 'up' | null material: MeshStandardMaterial position: [number, number, number] scale: number }) { if (!direction) { return ( ) } const ySign = direction === 'up' ? 1 : -1 return ( ) } function ElevatorFloorIndicator({ active, direction, faceSign = -1, label, position, scale = 1, showReadout = true, }: { active: boolean direction: 'down' | 'up' | null faceSign?: -1 | 1 label: string position: [number, number, number] scale?: number showReadout?: boolean }) { const glyphMaterial = active ? INDICATOR_GLYPH_MATERIALS.active : INDICATOR_GLYPH_MATERIALS.idle const screenMaterial = active ? INDICATOR_SCREEN_MATERIALS.active : INDICATOR_SCREEN_MATERIALS.idle const displayLabel = label || '-' const screenZ = faceSign * 0.026 * scale const glyphZ = faceSign * 0.041 * scale return ( {showReadout ? ( <> ) : ( )} ) } function DoorOpenGlyph({ material, positionZ, scale, }: { material: MeshStandardMaterial positionZ: number scale: number }) { return ( ) } function ElevatorMeshButton({ action = 'request-level', active, buttonKind, elevatorId, faceSign = -1, glyph, label, levelId, position, queued, radius = 0.055, }: { action?: ElevatorButtonAction active: boolean buttonKind: 'cab' | 'landing' elevatorId: AnyNodeId faceSign?: -1 | 1 glyph?: 'door-open' label?: string levelId?: AnyNodeId position: [number, number, number] queued: boolean radius?: number }) { const state = active ? 'active' : queued ? 'queued' : 'idle' const depth = active ? 0.028 : 0.04 const faceZ = faceSign * (depth / 2 + 0.004) const labelMaterial = active || queued ? BUTTON_LABEL_MATERIALS.lit : BUTTON_LABEL_MATERIALS.idle const userData = useMemo( () => ({ elevatorButton: { action, elevatorId, kind: buttonKind, levelId, }, }), [action, buttonKind, elevatorId, levelId], ) const press = (event: ThreeEvent) => { if (event.button !== 0) return if (action === 'open-door') { useInteractive.getState().openElevatorDoor(elevatorId) return } if (levelId) useInteractive.getState().requestElevator(elevatorId, levelId) } return ( {(active || queued) && ( )} {label && ( )} {glyph === 'door-open' && ( )} ) } function DoorLeaf({ animated, doorOpen, height, side, width, y, z, }: { animated?: | { elevatorId: AnyNodeId kind: 'cab' } | { elevatorId: AnyNodeId kind: 'landing' levelId: AnyNodeId } doorOpen: number height: number side: 'left' | 'right' width: number y: number z: number }) { const ref = useRef(null) const direction = side === 'left' ? -1 : 1 const getLeafX = (openAmount: number) => direction * (width / 4 + openAmount * width * 0.34) const leafWidth = Math.max(width / 2 - 0.018, 0.12) const railHeight = Math.min(0.09, Math.max(0.055, height * 0.04)) const stileWidth = Math.min(0.07, Math.max(0.04, leafWidth * 0.18)) const glassWidth = Math.max(leafWidth - stileWidth * 2.2, 0.03) const glassHeight = Math.max(height - railHeight * 3, 0.2) useFrame(() => { if (!(animated && ref.current)) return const runtime = useInteractive.getState().elevators[animated.elevatorId] const nextDoorOpen = animated.kind === 'cab' ? (runtime?.doorOpen ?? 0) : runtime?.currentLevelId === animated.levelId ? (runtime?.doorOpen ?? 0) : 0 ref.current.position.x = getLeafX(nextDoorOpen) }, 2.6) return ( ) } function LandingDoorFrame({ doorHeight, doorWidth, levelTopY, levelY, shaftWidth, z, }: { doorHeight: number doorWidth: number levelTopY: number levelY: number shaftWidth: number z: number }) { const wallDepth = 0.09 const levelHeight = Math.max(levelTopY - levelY, 0.01) const jambWidth = Math.max((shaftWidth - doorWidth) / 2, 0.08) const jambCenterOffset = doorWidth / 2 + jambWidth / 2 const headerHeight = Math.max(levelTopY - (levelY + doorHeight), 0) const trim = 0.055 return ( <> {headerHeight > 0.01 && ( )} ) } function LandingDoor({ animated, elevatorId, doorOpen, doorHeight, doorWidth, levelId, levelY, z, }: { animated: boolean elevatorId: AnyNodeId doorOpen: number doorHeight: number doorWidth: number levelId: AnyNodeId levelY: number z: number }) { return ( <> ) } export const ElevatorRenderer = ({ node }: { node: ElevatorNode }) => { const ref = useRef(null!) const cabRef = useRef(null) const nodes = useScene((state) => state.nodes) const handlers = useNodeEvents(node, 'elevator') const liveOverrides = useLiveNodeOverrides((state) => state.get(node.id)) const liveTransform = useLiveTransforms((state) => state.get(node.id)) const renderNode = useMemo( () => (liveOverrides ? ({ ...node, ...liveOverrides } as ElevatorNode) : node), [liveOverrides, node], ) useRegistry(node.id, 'elevator', ref) const { entries, defaultEntry, shaftBaseY, totalHeight } = useMemo( () => resolveElevatorLevels(renderNode, nodes), [renderNode, nodes], ) const elevatorId = node.id as AnyNodeId const runtimeStatus = useInteractive( useShallow((state) => { const runtime = state.elevators[elevatorId] if (!runtime) return null return { currentLevelId: runtime.currentLevelId, phase: runtime.phase, queue: runtime.queue, targetLevelId: runtime.targetLevelId, } }), ) useEffect(() => { if (!defaultEntry) return const elevatorId = node.id as AnyNodeId const interactive = useInteractive.getState() const current = interactive.elevators[elevatorId] if (!current) { interactive.initElevator(elevatorId, defaultEntry.id as AnyNodeId, defaultEntry.baseY) } else if (!entries.some((entry) => entry.id === current.currentLevelId)) { interactive.setElevatorState(elevatorId, { carY: defaultEntry.baseY, currentLevelId: defaultEntry.id as AnyNodeId, doorOpen: 0, phase: 'idle', phaseStartedAt: null, queue: [], targetLevelId: null, }) } }, [defaultEntry, entries, node.id]) useEffect(() => { return () => { useInteractive.getState().removeElevator(elevatorId) } }, [elevatorId]) useFrame(() => { if (!cabRef.current) return const runtime = useInteractive.getState().elevators[elevatorId] if (!runtime) return cabRef.current.position.y = runtime.carY }, 2.6) const shaftWidth = Math.max(renderNode.width, 0.8) const shaftDepth = Math.max(renderNode.depth, 0.8) const cabHeight = Math.max(renderNode.cabHeight, 1.4) const doorWidth = Math.min(Math.max(renderNode.doorWidth, 0.45), shaftWidth - 0.18) const doorHeight = Math.min(Math.max(renderNode.doorHeight, 1.2), cabHeight - 0.1) const shaftHeight = Math.max(totalHeight, cabHeight + 0.3) const shaftWallThickness = 0.09 const shaftBodyHeight = Math.max(shaftHeight - shaftWallThickness, 0.01) const shaftBodyCenterY = shaftBaseY + shaftBodyHeight / 2 const shaftTopCapBottomY = shaftBaseY + shaftHeight - shaftWallThickness const shaftFrameTopY = Math.max(shaftBaseY, shaftTopCapBottomY - SHAFT_TOP_FRAME_CLEARANCE) const runtimeSnapshot = useInteractive.getState().elevators[elevatorId] const cabBaseY = runtimeSnapshot?.carY ?? defaultEntry?.baseY ?? 0 const activeLevelId = runtimeStatus?.currentLevelId ?? runtimeSnapshot?.currentLevelId ?? defaultEntry?.id ?? null const pendingLevelId = runtimeStatus?.targetLevelId ?? runtimeSnapshot?.targetLevelId ?? runtimeStatus?.queue[0] ?? runtimeSnapshot?.queue[0] ?? null const currentEntry = entries.find((entry) => entry.id === activeLevelId) ?? defaultEntry ?? entries[0] ?? null const pendingEntry = pendingLevelId ? entries.find((entry) => entry.id === pendingLevelId) : null const indicatorEntry = pendingEntry ?? currentEntry const indicatorDirection = currentEntry && pendingEntry && Math.abs(pendingEntry.baseY - currentEntry.baseY) > 0.001 ? pendingEntry.baseY > currentEntry.baseY ? 'up' : 'down' : null const indicatorActive = Boolean( pendingEntry || runtimeStatus?.phase === 'moving' || runtimeSnapshot?.phase === 'moving' || runtimeStatus?.phase === 'opening' || runtimeSnapshot?.phase === 'opening', ) const queuedLevelIds = useMemo(() => { const next = new Set() for (const levelId of runtimeStatus?.queue ?? runtimeSnapshot?.queue ?? []) next.add(levelId) const targetLevelId = runtimeStatus?.targetLevelId ?? runtimeSnapshot?.targetLevelId if (targetLevelId) next.add(targetLevelId) return next }, [ runtimeSnapshot?.queue, runtimeSnapshot?.targetLevelId, runtimeStatus?.queue, runtimeStatus?.targetLevelId, ]) const doorOpen = runtimeSnapshot?.doorOpen ?? 0 const doorOpenButtonActive = doorOpen > 0.12 || runtimeStatus?.phase === 'opening' || runtimeSnapshot?.phase === 'opening' || runtimeStatus?.phase === 'open' || runtimeSnapshot?.phase === 'open' const frontWallZ = -shaftDepth / 2 - shaftWallThickness / 2 const frontZ = frontWallZ - shaftWallThickness / 2 - 0.018 const landingPanelX = Math.min(shaftWidth / 2 - 0.16, doorWidth / 2 + 0.18) const cabPanelX = shaftWidth / 2 - 0.075 const cabPanelZ = -shaftDepth / 2 + 0.36 const cabButtonColumns = entries.length > 1 ? 2 : 1 const cabButtonRows = Math.max(1, Math.ceil(entries.length / cabButtonColumns)) const cabButtonSpacingX = 0.14 const cabButtonSpacingY = 0.15 const cabDoorButtonOffsetX = 0.17 const cabFloorButtonOffsetX = entries.length > 0 ? -cabDoorButtonOffsetX / 2 : 0 const cabDoorButtonX = cabFloorButtonOffsetX + ((cabButtonColumns - 1) / 2) * cabButtonSpacingX + cabDoorButtonOffsetX const cabDoorButtonY = -((cabButtonRows - 1) / 2) * cabButtonSpacingY const cabPanelWidth = cabButtonColumns * cabButtonSpacingX + 0.13 + cabDoorButtonOffsetX const cabPanelHeight = cabButtonRows * cabButtonSpacingY + 0.12 const panelRelativeY = Math.min(Math.max(doorHeight * 0.6, 0.95), cabHeight - 0.35) const cabPanelY = panelRelativeY const entrySpans = useMemo( () => entries.map((entry, index) => { const nextEntry = entries[index + 1] const minDoorFrameTopY = entry.baseY + doorHeight + 0.12 const targetTopY = Math.max(nextEntry?.baseY ?? shaftFrameTopY, minDoorFrameTopY) return { entry, levelTopY: nextEntry ? targetTopY : Math.min(targetTopY, shaftFrameTopY), } }), [doorHeight, entries, shaftFrameTopY], ) return ( {entries.map((entry, index) => { const column = index % cabButtonColumns const row = Math.floor(index / cabButtonColumns) const x = cabFloorButtonOffsetX + (column - (cabButtonColumns - 1) / 2) * cabButtonSpacingX const y = (row - (cabButtonRows - 1) / 2) * cabButtonSpacingY return ( ) })} {entrySpans.map(({ entry, levelTopY }) => { const isCurrentLevel = activeLevelId === entry.id const isQueuedLevel = queuedLevelIds.has(entry.id) const isPendingLevel = pendingLevelId === entry.id const showLandingReadout = isCurrentLevel || isPendingLevel || isQueuedLevel return ( 0.5} buttonKind="landing" elevatorId={elevatorId} levelId={entry.id as AnyNodeId} position={[0, 0.06, -0.045]} queued={isQueuedLevel} radius={0.045} /> ) })} ) }