1164 lines
33 KiB
TypeScript

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<string, readonly SegmentName[]> = {
'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 (
<mesh
castShadow={castShadow}
dispose={null}
geometry={UNIT_BOX_GEOMETRY}
material={material}
position={position}
receiveShadow={receiveShadow}
rotation={rotation}
scale={scale}
/>
)
}
function MeshButtonLabel({
label,
material,
position,
scale,
}: {
label: string
material: MeshStandardMaterial
position: [number, number, number]
scale: number
}) {
const ref = useRef<InstancedMesh>(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 (
<instancedMesh
args={[UNIT_BOX_GEOMETRY, material, instances.length]}
dispose={null}
onUpdate={applyInstanceMatrices}
position={position}
ref={ref}
/>
)
}
function ElevatorDirectionGlyph({
direction,
material,
position,
scale,
}: {
direction: 'down' | 'up' | null
material: MeshStandardMaterial
position: [number, number, number]
scale: number
}) {
if (!direction) {
return (
<BoxPrimitive
material={material}
position={position}
scale={[0.08 * scale, 0.08 * scale, 0.018]}
/>
)
}
const ySign = direction === 'up' ? 1 : -1
return (
<group position={position}>
<BoxPrimitive
material={material}
position={[-0.04 * scale, -0.02 * ySign * scale, 0]}
rotation={[0, 0, (-ySign * Math.PI) / 4]}
scale={[0.16 * scale, 0.035 * scale, 0.018]}
/>
<BoxPrimitive
material={material}
position={[0.04 * scale, -0.02 * ySign * scale, 0]}
rotation={[0, 0, (ySign * Math.PI) / 4]}
scale={[0.16 * scale, 0.035 * scale, 0.018]}
/>
</group>
)
}
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 (
<group position={position}>
<BoxPrimitive
castShadow
material={PANEL_MATERIAL}
receiveShadow
scale={[0.42 * scale, 0.16 * scale, 0.045 * scale]}
/>
<BoxPrimitive
material={screenMaterial}
position={[0, 0, screenZ]}
scale={[0.34 * scale, 0.095 * scale, 0.012 * scale]}
/>
{showReadout ? (
<>
<ElevatorDirectionGlyph
direction={direction}
material={glyphMaterial}
position={[-0.115 * scale, 0, glyphZ]}
scale={scale}
/>
<MeshButtonLabel
label={displayLabel}
material={glyphMaterial}
position={[0.075 * scale, 0, glyphZ]}
scale={0.055 * scale}
/>
</>
) : (
<BoxPrimitive
material={glyphMaterial}
position={[0, 0, glyphZ]}
scale={[0.13 * scale, 0.018 * scale, 0.018]}
/>
)}
</group>
)
}
function DoorOpenGlyph({
material,
positionZ,
scale,
}: {
material: MeshStandardMaterial
positionZ: number
scale: number
}) {
return (
<group position={[0, 0, positionZ]}>
<BoxPrimitive
material={material}
position={[-0.014 * scale, 0, 0]}
scale={[0.006 * scale, 0.052 * scale, 0.012]}
/>
<BoxPrimitive
material={material}
position={[0.014 * scale, 0, 0]}
scale={[0.006 * scale, 0.052 * scale, 0.012]}
/>
<BoxPrimitive
material={material}
position={[-0.033 * scale, 0, 0]}
rotation={[0, 0, Math.PI / 4]}
scale={[0.026 * scale, 0.005 * scale, 0.012]}
/>
<BoxPrimitive
material={material}
position={[-0.033 * scale, 0, 0]}
rotation={[0, 0, -Math.PI / 4]}
scale={[0.026 * scale, 0.005 * scale, 0.012]}
/>
<BoxPrimitive
material={material}
position={[0.033 * scale, 0, 0]}
rotation={[0, 0, Math.PI / 4]}
scale={[0.026 * scale, 0.005 * scale, 0.012]}
/>
<BoxPrimitive
material={material}
position={[0.033 * scale, 0, 0]}
rotation={[0, 0, -Math.PI / 4]}
scale={[0.026 * scale, 0.005 * scale, 0.012]}
/>
</group>
)
}
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<PointerEvent>) => {
if (event.button !== 0) return
if (action === 'open-door') {
useInteractive.getState().openElevatorDoor(elevatorId)
return
}
if (levelId) useInteractive.getState().requestElevator(elevatorId, levelId)
}
return (
<group onPointerDown={press} position={position} userData={userData}>
{(active || queued) && (
<mesh
dispose={null}
geometry={BUTTON_GLOW_GEOMETRY}
material={active ? BUTTON_GLOW_MATERIALS.active : BUTTON_GLOW_MATERIALS.queued}
position={[0, 0, faceSign * (depth + 0.004)]}
receiveShadow
rotation-x={Math.PI / 2}
scale={[radius, 0.012, radius]}
/>
)}
<mesh
castShadow
dispose={null}
geometry={BUTTON_RING_GEOMETRY}
material={BUTTON_RING_MATERIALS[state]}
position={[0, 0, faceSign * (depth / 2 + 0.003)]}
receiveShadow
scale={[radius, radius, radius]}
/>
<mesh
castShadow
dispose={null}
geometry={BUTTON_FACE_GEOMETRY}
material={BUTTON_FACE_MATERIALS[state]}
receiveShadow
rotation-x={Math.PI / 2}
scale={[radius, depth, radius]}
/>
{label && (
<MeshButtonLabel
label={label}
material={labelMaterial}
position={[0, 0, faceZ]}
scale={radius * 0.72}
/>
)}
{glyph === 'door-open' && (
<DoorOpenGlyph material={labelMaterial} positionZ={faceZ} scale={radius / 0.055} />
)}
</group>
)
}
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<Group>(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 (
<group ref={ref} position={[getLeafX(doorOpen), y + height / 2, z]}>
<BoxPrimitive
castShadow
material={DOOR_MATERIAL}
position={[0, height / 2 - railHeight / 2, 0]}
receiveShadow
scale={[leafWidth, railHeight, 0.05]}
/>
<BoxPrimitive
castShadow
material={DOOR_MATERIAL}
position={[0, -height / 2 + railHeight / 2, 0]}
receiveShadow
scale={[leafWidth, railHeight, 0.05]}
/>
<BoxPrimitive
castShadow
material={DOOR_MATERIAL}
position={[-leafWidth / 2 + stileWidth / 2, 0, 0]}
receiveShadow
scale={[stileWidth, height, 0.05]}
/>
<BoxPrimitive
castShadow
material={DOOR_MATERIAL}
position={[leafWidth / 2 - stileWidth / 2, 0, 0]}
receiveShadow
scale={[stileWidth, height, 0.05]}
/>
<BoxPrimitive
material={GLASS_MATERIAL}
position={[0, 0, -0.004]}
scale={[glassWidth, glassHeight, 0.012]}
/>
</group>
)
}
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 (
<>
<BoxPrimitive
castShadow
material={SHAFT_WALL_MATERIAL}
position={[-jambCenterOffset, levelY + levelHeight / 2, z]}
receiveShadow
scale={[jambWidth, levelHeight, wallDepth]}
/>
<BoxPrimitive
castShadow
material={SHAFT_WALL_MATERIAL}
position={[jambCenterOffset, levelY + levelHeight / 2, z]}
receiveShadow
scale={[jambWidth, levelHeight, wallDepth]}
/>
{headerHeight > 0.01 && (
<BoxPrimitive
castShadow
material={SHAFT_WALL_MATERIAL}
position={[0, levelY + doorHeight + headerHeight / 2, z]}
receiveShadow
scale={[shaftWidth, headerHeight, wallDepth]}
/>
)}
<BoxPrimitive
castShadow
material={SHAFT_TRIM_MATERIAL}
position={[0, levelY + trim / 2, z - 0.006]}
receiveShadow
scale={[doorWidth + trim * 2, trim, wallDepth * 1.12]}
/>
<BoxPrimitive
castShadow
material={SHAFT_TRIM_MATERIAL}
position={[-doorWidth / 2 - trim / 2, levelY + doorHeight / 2, z - 0.006]}
receiveShadow
scale={[trim, doorHeight, wallDepth * 1.12]}
/>
<BoxPrimitive
castShadow
material={SHAFT_TRIM_MATERIAL}
position={[doorWidth / 2 + trim / 2, levelY + doorHeight / 2, z - 0.006]}
receiveShadow
scale={[trim, doorHeight, wallDepth * 1.12]}
/>
<BoxPrimitive
castShadow
material={SHAFT_TRIM_MATERIAL}
position={[0, levelY + doorHeight + trim / 2, z - 0.006]}
receiveShadow
scale={[doorWidth + trim * 2, trim, wallDepth * 1.12]}
/>
</>
)
}
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 (
<>
<DoorLeaf
animated={animated ? { elevatorId, kind: 'landing', levelId } : undefined}
doorOpen={doorOpen}
height={doorHeight}
side="left"
width={doorWidth}
y={levelY}
z={z}
/>
<DoorLeaf
animated={animated ? { elevatorId, kind: 'landing', levelId } : undefined}
doorOpen={doorOpen}
height={doorHeight}
side="right"
width={doorWidth}
y={levelY}
z={z}
/>
</>
)
}
export const ElevatorRenderer = ({ node }: { node: ElevatorNode }) => {
const ref = useRef<Group>(null!)
const cabRef = useRef<Group>(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<string>()
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 (
<group
position={liveTransform?.position ?? renderNode.position}
ref={ref}
rotation-y={liveTransform?.rotation ?? renderNode.rotation}
visible={renderNode.visible}
{...handlers}
>
<BoxPrimitive
castShadow
material={SHAFT_SIDE_MATERIAL}
position={[0, shaftBodyCenterY, shaftDepth / 2 + shaftWallThickness / 2]}
receiveShadow
scale={[shaftWidth + shaftWallThickness * 2, shaftBodyHeight, shaftWallThickness]}
/>
<BoxPrimitive
castShadow
material={SHAFT_SIDE_MATERIAL}
position={[-shaftWidth / 2 - shaftWallThickness / 2, shaftBodyCenterY, 0]}
receiveShadow
scale={[shaftWallThickness, shaftBodyHeight, shaftDepth + shaftWallThickness * 2]}
/>
<BoxPrimitive
castShadow
material={SHAFT_SIDE_MATERIAL}
position={[shaftWidth / 2 + shaftWallThickness / 2, shaftBodyCenterY, 0]}
receiveShadow
scale={[shaftWallThickness, shaftBodyHeight, shaftDepth + shaftWallThickness * 2]}
/>
<BoxPrimitive
castShadow
material={SHAFT_SIDE_MATERIAL}
position={[0, shaftBaseY + shaftHeight - shaftWallThickness / 2, 0]}
receiveShadow
scale={[
shaftWidth + shaftWallThickness * 2,
shaftWallThickness,
shaftDepth + shaftWallThickness * 2,
]}
/>
<group ref={cabRef} position={[0, cabBaseY, 0]}>
<BoxPrimitive
castShadow
material={CAB_MATERIAL}
position={[0, 0.04, 0]}
receiveShadow
scale={[shaftWidth, 0.08, shaftDepth]}
/>
<BoxPrimitive
castShadow
material={CAB_MATERIAL}
position={[0, cabHeight - 0.04, 0]}
receiveShadow
scale={[shaftWidth, 0.08, shaftDepth]}
/>
<BoxPrimitive
castShadow
material={CAB_MATERIAL}
position={[0, cabHeight / 2, shaftDepth / 2 - 0.04]}
receiveShadow
scale={[shaftWidth, cabHeight, 0.08]}
/>
<BoxPrimitive
castShadow
material={CAB_MATERIAL}
position={[-shaftWidth / 2 + 0.04, cabHeight / 2, 0]}
receiveShadow
scale={[0.08, cabHeight, shaftDepth]}
/>
<BoxPrimitive
castShadow
material={CAB_MATERIAL}
position={[shaftWidth / 2 - 0.04, cabHeight / 2, 0]}
receiveShadow
scale={[0.08, cabHeight, shaftDepth]}
/>
<DoorLeaf
animated={{ elevatorId, kind: 'cab' }}
doorOpen={doorOpen}
height={doorHeight}
side="left"
width={doorWidth}
y={0}
z={frontZ}
/>
<DoorLeaf
animated={{ elevatorId, kind: 'cab' }}
doorOpen={doorOpen}
height={doorHeight}
side="right"
width={doorWidth}
y={0}
z={frontZ}
/>
<ElevatorFloorIndicator
active={indicatorActive}
direction={indicatorDirection}
faceSign={1}
label={indicatorEntry?.label ?? '-'}
position={[0, doorHeight + 0.13, frontZ + 0.055]}
scale={0.78}
/>
<group position={[cabPanelX, cabPanelY, cabPanelZ]} rotation-y={-Math.PI / 2}>
<BoxPrimitive
castShadow
material={PANEL_MATERIAL}
receiveShadow
scale={[cabPanelWidth, cabPanelHeight, 0.045]}
/>
{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 (
<ElevatorMeshButton
active={activeLevelId === entry.id}
buttonKind="cab"
elevatorId={elevatorId}
faceSign={1}
key={entry.id}
label={entry.label}
levelId={entry.id as AnyNodeId}
position={[x, y, 0.045]}
queued={queuedLevelIds.has(entry.id)}
/>
)
})}
<ElevatorMeshButton
action="open-door"
active={doorOpenButtonActive}
buttonKind="cab"
elevatorId={elevatorId}
faceSign={1}
glyph="door-open"
position={[cabDoorButtonX, cabDoorButtonY, 0.045]}
queued={false}
radius={0.047}
/>
</group>
</group>
{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 (
<group key={entry.id}>
<LandingDoorFrame
doorHeight={doorHeight}
doorWidth={doorWidth}
levelTopY={levelTopY}
levelY={entry.baseY}
shaftWidth={shaftWidth}
z={frontWallZ}
/>
<LandingDoor
animated={isCurrentLevel}
elevatorId={elevatorId}
doorHeight={doorHeight}
doorOpen={isCurrentLevel ? doorOpen : 0}
doorWidth={doorWidth}
levelId={entry.id as AnyNodeId}
levelY={entry.baseY}
z={frontZ - 0.02}
/>
<ElevatorFloorIndicator
active={showLandingReadout}
direction={showLandingReadout ? indicatorDirection : null}
label={entry.label}
position={[0, entry.baseY + doorHeight + 0.16, frontZ - 0.055]}
scale={0.62}
showReadout={showLandingReadout}
/>
<group position={[landingPanelX, entry.baseY + panelRelativeY, frontZ - 0.035]}>
<BoxPrimitive
castShadow
material={LANDING_PANEL_MATERIAL}
receiveShadow
scale={[0.18, 0.42, 0.04]}
/>
<ElevatorMeshButton
active={isCurrentLevel && doorOpen > 0.5}
buttonKind="landing"
elevatorId={elevatorId}
levelId={entry.id as AnyNodeId}
position={[0, 0.06, -0.045]}
queued={isQueuedLevel}
radius={0.045}
/>
<BoxPrimitive
material={isQueuedLevel ? QUEUE_STRIP_MATERIALS.queued : QUEUE_STRIP_MATERIALS.idle}
position={[0, -0.12, -0.035]}
scale={[0.095, 0.025, 0.012]}
/>
</group>
</group>
)
})}
</group>
)
}