ai-chatbot/packages/editor/src/components/editor/first-person-controls.tsx
Wassim SAMAD 36c48b7fc9 chore: biome auto-format pass (resolve persistent dirty-tree noise)
These files had been showing as modified in every dev session — biome's
canonical formatting (line-length collapses, import sort, type-modifier
placement) didn't match the committed state. No semantic changes.
Committing now so the working tree stays clean across sessions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 18:19:15 -04:00

1408 lines
48 KiB
TypeScript

'use client'
import '../../three-types'
import {
type AnyNode,
type AnyNodeId,
type ElevatorDoorSide,
type ElevatorNode,
emitter,
getElevatorCabCenterZ,
getElevatorCabDepth,
getElevatorCabWidth,
getElevatorDoorLeafSides,
getElevatorDoorLeafWidth,
getElevatorDoorLeafX,
getElevatorShaftDepth,
getElevatorShaftWallThickness,
getElevatorShaftWidth,
getResolvedElevatorDoorStyle,
openElevatorDoor,
requestElevatorLevel,
resolveElevatorBuildingLevels,
resolveElevatorDispatchTarget,
resolveElevatorServiceLevels,
sceneRegistry,
useInteractive,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { KeyboardControls } from '@react-three/drei'
import { useFrame, useThree } from '@react-three/fiber'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
Box3,
BoxGeometry,
Euler,
type Group,
Matrix4,
Mesh,
MeshBasicMaterial,
type Object3D,
Ray,
Raycaster,
Vector2,
Vector3,
} from 'three'
import { acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from 'three-mesh-bvh'
import {
closeDoorOpenState,
DOOR_SWING_OPEN_ANGLE,
isOperationDoorType,
toggleDoorOpenState,
} from '../../lib/door-interaction'
import {
closeWindowOpenState,
isOperableWindowType,
toggleWindowOpenState,
} from '../../lib/window-interaction'
import useEditor from '../../store/use-editor'
import {
buildFirstPersonColliderWorldFromRegistry,
deriveFirstPersonSpawn,
FIRST_PERSON_SPAWN_EYE_HEIGHT,
type FirstPersonColliderWorld,
type FirstPersonSpawn,
} from './first-person/build-collider-world'
import type { BVHEcctrlApi } from './first-person/bvh-ecctrl'
import BVHEcctrl from './first-person/bvh-ecctrl'
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_LEAF_INTERACTION_DEPTH = 0.08
const ELEVATOR_RIDE_HORIZONTAL_PADDING = 0.18
const ELEVATOR_COLLIDER_HORIZONTAL_PADDING = 0.14
const ELEVATOR_COLLIDER_FLOOR_THICKNESS = 0.08
const ELEVATOR_COLLIDER_DOOR_DEPTH = 0.12
const ELEVATOR_ENTRY_DOOR_OPEN_THRESHOLD = 0.72
const DEFAULT_ELEVATOR_LEVEL_HEIGHT = 2.5
const keyboardMap = [
{ name: 'forward', keys: ['ArrowUp', 'KeyW'] },
{ name: 'backward', keys: ['ArrowDown', 'KeyS'] },
{ name: 'leftward', keys: ['ArrowLeft', 'KeyA'] },
{ name: 'rightward', keys: ['ArrowRight', 'KeyD'] },
{ name: 'jump', keys: ['Space'] },
{ name: 'run', keys: ['ShiftLeft', 'ShiftRight'] },
]
const cameraOffset = new Vector3(0, CAMERA_EYE_OFFSET, 0)
const cameraEuler = new Euler(0, 0, 0, 'YXZ')
const centerScreenPoint = new Vector2(0, 0)
const doorInteractionRaycaster = new Raycaster()
const doorLeafBox = new Box3()
const doorLeafInverseMatrix = new Matrix4()
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 elevatorLocalControllerPosition = new Vector3()
const elevatorInteractionRaycaster = new Raycaster()
const elevatorColliderMatrix = new Matrix4()
const elevatorColliderLocalMatrix = new Matrix4()
const elevatorLocalEyePosition = new Vector3()
const elevatorWorldControllerPosition = new Vector3()
const elevatorColliderMaterial = new MeshBasicMaterial({ visible: false })
const spawnWorldPosition = new Vector3()
const spawnWorldEuler = new Euler(0, 0, 0, 'YXZ')
const windowInteractionRaycaster = new Raycaster()
type ElevatorColliderKind =
| 'cab-back'
| 'cab-ceiling'
| 'cab-door-left'
| 'cab-door-right'
| 'cab-door-gate'
| 'cab-floor'
| 'cab-left'
| 'cab-right'
| 'landing-door-gate'
| 'landing-door-left'
| 'landing-door-right'
| 'shaft-back'
| 'shaft-front-header'
| 'shaft-front-left'
| 'shaft-front-right'
| 'shaft-left'
| 'shaft-right'
| 'shaft-top'
type ElevatorColliderUserData = {
doorWidth?: number
dynamic?: boolean
elevatorId: AnyNodeId
kind: ElevatorColliderKind
levelId?: AnyNodeId
localPosition: [number, number, number]
matrixInitialized?: boolean
side?: ElevatorDoorSide
}
type ElevatorColliderMesh = Mesh & {
userData: Mesh['userData'] & ElevatorColliderUserData
}
type FirstPersonInteractableTarget =
| {
id: AnyNodeId
type: 'door' | 'window'
}
| {
action: 'open-door' | 'request-level'
buttonKind: 'cab' | 'landing'
id: AnyNodeId
levelId?: AnyNodeId
type: 'elevator'
}
type ElevatorButtonTarget = {
action: 'open-door' | 'request-level'
buttonKind: 'cab' | 'landing'
elevatorId: AnyNodeId
levelId?: AnyNodeId
}
function resolveElevatorButtonTarget(object: Object3D): ElevatorButtonTarget | null {
let current: Object3D | null = object
while (current) {
const candidate = (
current.userData as {
elevatorButton?: {
action?: unknown
disabled?: unknown
elevatorId?: unknown
kind?: unknown
levelId?: unknown
}
}
).elevatorButton
if (candidate?.disabled === true) {
return null
}
if (typeof candidate?.elevatorId === 'string' && candidate.kind === 'cab') {
const action = candidate.action === 'open-door' ? 'open-door' : 'request-level'
if (action === 'open-door') {
return {
action,
buttonKind: candidate.kind,
elevatorId: candidate.elevatorId as AnyNodeId,
}
}
}
if (
typeof candidate?.elevatorId === 'string' &&
typeof candidate.levelId === 'string' &&
(candidate.kind === 'cab' || candidate.kind === 'landing')
) {
return {
action: 'request-level',
buttonKind: candidate.kind,
elevatorId: candidate.elevatorId as AnyNodeId,
levelId: candidate.levelId as AnyNodeId,
}
}
current = current.parent
}
return null
}
function getInteractableTargetKey(target: FirstPersonInteractableTarget | null) {
if (!target) return null
return target.type === 'elevator'
? `${target.type}:${target.id}:${target.levelId}`
: `${target.type}:${target.id}`
}
function isDynamicElevatorCollider(kind: ElevatorColliderKind) {
return kind.startsWith('cab-') || kind.startsWith('landing-door')
}
function isInsideElevatorCab(
elevator: ElevatorNode,
runtime: NonNullable<ReturnType<typeof useInteractive.getState>['elevators'][AnyNodeId]>,
localEyePosition: Vector3,
) {
const halfWidth = getElevatorCabWidth(elevator) / 2 - ELEVATOR_RIDE_HORIZONTAL_PADDING
const halfDepth = getElevatorCabDepth(elevator) / 2 - ELEVATOR_RIDE_HORIZONTAL_PADDING
const cabCenterZ = getElevatorCabCenterZ(elevator)
const cabHeight = Math.max(elevator.cabHeight, 1.4)
return (
Math.abs(localEyePosition.x) <= Math.max(halfWidth, 0.24) &&
Math.abs(localEyePosition.z - cabCenterZ) <= Math.max(halfDepth, 0.24) &&
localEyePosition.y >= runtime.carY + 0.35 &&
localEyePosition.y <= runtime.carY + cabHeight + 0.7
)
}
function getFirstPersonLevelHeight(levelId: string, nodes: Record<string, AnyNode>) {
const level = nodes[levelId as AnyNodeId]
if (level?.type !== 'level') return DEFAULT_ELEVATOR_LEVEL_HEIGHT
let maxTop = 0
for (const childId of level.children) {
const child = nodes[childId as AnyNodeId]
if (!child) continue
if (child.type === 'ceiling') {
maxTop = Math.max(maxTop, child.height ?? DEFAULT_ELEVATOR_LEVEL_HEIGHT)
continue
}
if (child.type === 'wall') {
const meshY = Math.max(sceneRegistry.nodes.get(childId as AnyNodeId)?.position.y ?? 0, 0)
maxTop = Math.max(maxTop, meshY + (child.height ?? DEFAULT_ELEVATOR_LEVEL_HEIGHT))
}
}
return maxTop > 0 ? maxTop : DEFAULT_ELEVATOR_LEVEL_HEIGHT
}
function resolveElevatorColliderLevels(elevator: ElevatorNode, nodes: Record<string, AnyNode>) {
const allLevels = resolveElevatorBuildingLevels(elevator, nodes)
const baseYByLevelId = new Map<string, number>()
let cumulativeY = 0
for (const level of allLevels) {
baseYByLevelId.set(level.id, cumulativeY)
cumulativeY += getFirstPersonLevelHeight(level.id, nodes)
}
const serviceLevels = resolveElevatorServiceLevels(elevator, nodes)
const entries = serviceLevels.map((level) => ({
baseY: baseYByLevelId.get(level.id) ?? 0,
id: level.id as AnyNodeId,
}))
const firstServedLevel = serviceLevels[0] ?? null
const lastServedLevel = serviceLevels[serviceLevels.length - 1] ?? null
const shaftBaseY = firstServedLevel ? (baseYByLevelId.get(firstServedLevel.id) ?? 0) : 0
const lastServedIndex = lastServedLevel
? allLevels.findIndex((level) => level.id === lastServedLevel.id)
: -1
const nextLevel = lastServedIndex >= 0 ? allLevels[lastServedIndex + 1] : null
const shaftTopY = nextLevel
? (baseYByLevelId.get(nextLevel.id) ?? cumulativeY)
: lastServedLevel
? cumulativeY
: elevator.cabHeight + 0.3
return {
entries,
shaftBaseY,
shaftTopY,
totalHeight: Math.max(shaftTopY - shaftBaseY, elevator.cabHeight + 0.3),
}
}
function createElevatorColliderMesh(
elevatorId: AnyNodeId,
kind: ElevatorColliderKind,
size: [number, number, number],
localPosition: [number, number, number],
userData: Partial<ElevatorColliderUserData> = {},
) {
const geometry = new BoxGeometry(size[0], size[1], size[2])
const bvhGeometry = geometry as typeof geometry & {
computeBoundsTree?: typeof computeBoundsTree
disposeBoundsTree?: typeof disposeBoundsTree
}
;(bvhGeometry as any).computeBoundsTree = computeBoundsTree
;(bvhGeometry as any).disposeBoundsTree = disposeBoundsTree
bvhGeometry.computeBoundsTree?.({
maxLeafSize: 12,
strategy: 0,
} as never)
bvhGeometry.computeBoundingBox()
const mesh = new Mesh(bvhGeometry, elevatorColliderMaterial) as unknown as ElevatorColliderMesh
mesh.raycast = acceleratedRaycast
mesh.matrixAutoUpdate = false
mesh.visible = true
mesh.userData = {
...userData,
dynamic: isDynamicElevatorCollider(kind),
elevatorId,
excludeCollisionCheck: false,
excludeFloatHit: false,
friction: 0.8,
kind,
localPosition,
matrixInitialized: false,
restitution: 0.03,
type: 'ELEVATOR_COLLIDER',
}
return mesh
}
function buildElevatorColliderMeshes(): ElevatorColliderMesh[] {
const nodes = useScene.getState().nodes
const meshes: ElevatorColliderMesh[] = []
for (const elevatorId of sceneRegistry.byType.elevator) {
const typedElevatorId = elevatorId as AnyNodeId
const node = nodes[typedElevatorId]
if (node?.type !== 'elevator' || node.visible === false) continue
const { entries, shaftBaseY, shaftTopY, totalHeight } = resolveElevatorColliderLevels(
node,
nodes,
)
const cabWidth = getElevatorCabWidth(node)
const cabDepth = getElevatorCabDepth(node)
const shaftWidth = getElevatorShaftWidth(node, cabWidth)
const shaftDepth = getElevatorShaftDepth(node, cabDepth)
const cabHeight = Math.max(node.cabHeight, 1.4)
const doorWidth = Math.min(Math.max(node.doorWidth, 0.45), cabWidth - 0.18, shaftWidth - 0.18)
const doorHeight = Math.min(Math.max(node.doorHeight, 1.2), cabHeight - 0.1)
const doorStyle = getResolvedElevatorDoorStyle(node.doorStyle)
const shaftHeight = Math.max(totalHeight, cabHeight + 0.3)
const wallThickness = getElevatorShaftWallThickness(node)
const cabFloorWidth = Math.max(cabWidth - ELEVATOR_COLLIDER_HORIZONTAL_PADDING * 2, 0.48)
const cabFloorDepth = Math.max(cabDepth - ELEVATOR_COLLIDER_HORIZONTAL_PADDING * 2, 0.48)
const frontWallZ = -shaftDepth / 2 - wallThickness / 2
const frontZ = frontWallZ - wallThickness / 2 - 0.018
const cabCenterZ = -shaftDepth / 2 + cabDepth / 2
const leafWidth = getElevatorDoorLeafWidth(doorWidth, doorStyle)
const doorLeafSides = getElevatorDoorLeafSides(doorStyle)
const resolvedShaftTopY = Math.max(shaftTopY, shaftBaseY + shaftHeight)
meshes.push(
createElevatorColliderMesh(
typedElevatorId,
'shaft-back',
[shaftWidth + wallThickness * 2, shaftHeight, wallThickness],
[0, shaftBaseY + shaftHeight / 2, shaftDepth / 2 + wallThickness / 2],
),
createElevatorColliderMesh(
typedElevatorId,
'shaft-left',
[wallThickness, shaftHeight, shaftDepth + wallThickness * 2],
[-shaftWidth / 2 - wallThickness / 2, shaftBaseY + shaftHeight / 2, 0],
),
createElevatorColliderMesh(
typedElevatorId,
'shaft-right',
[wallThickness, shaftHeight, shaftDepth + wallThickness * 2],
[shaftWidth / 2 + wallThickness / 2, shaftBaseY + shaftHeight / 2, 0],
),
createElevatorColliderMesh(
typedElevatorId,
'shaft-top',
[shaftWidth + wallThickness * 2, wallThickness, shaftDepth + wallThickness * 2],
[0, shaftBaseY + shaftHeight - wallThickness / 2, 0],
),
createElevatorColliderMesh(
typedElevatorId,
'cab-floor',
[cabFloorWidth, ELEVATOR_COLLIDER_FLOOR_THICKNESS, cabFloorDepth],
[0, ELEVATOR_COLLIDER_FLOOR_THICKNESS / 2, cabCenterZ],
),
createElevatorColliderMesh(
typedElevatorId,
'cab-ceiling',
[cabWidth, wallThickness, cabDepth],
[0, cabHeight - wallThickness / 2, cabCenterZ],
),
createElevatorColliderMesh(
typedElevatorId,
'cab-back',
[cabWidth, cabHeight, wallThickness],
[0, cabHeight / 2, cabCenterZ + cabDepth / 2 - wallThickness / 2],
),
createElevatorColliderMesh(
typedElevatorId,
'cab-left',
[wallThickness, cabHeight, cabDepth],
[-cabWidth / 2 + wallThickness / 2, cabHeight / 2, cabCenterZ],
),
createElevatorColliderMesh(
typedElevatorId,
'cab-right',
[wallThickness, cabHeight, cabDepth],
[cabWidth / 2 - wallThickness / 2, cabHeight / 2, cabCenterZ],
),
createElevatorColliderMesh(
typedElevatorId,
'cab-door-gate',
[doorWidth, doorHeight, ELEVATOR_COLLIDER_DOOR_DEPTH],
[0, doorHeight / 2, frontZ],
{ doorWidth },
),
...doorLeafSides.map((side) =>
createElevatorColliderMesh(
typedElevatorId,
side === 'left' ? 'cab-door-left' : 'cab-door-right',
[leafWidth, doorHeight, ELEVATOR_COLLIDER_DOOR_DEPTH],
[0, doorHeight / 2, frontZ],
{ doorWidth, side },
),
),
)
const entrySpans = entries.map((entry, index) => {
const nextEntry = entries[index + 1]
return {
entry,
levelTopY: Math.max(nextEntry?.baseY ?? resolvedShaftTopY, entry.baseY + doorHeight + 0.24),
}
})
for (const { entry, levelTopY } of entrySpans) {
const wallDepth = wallThickness
const levelHeight = Math.max(levelTopY - entry.baseY, doorHeight + 0.24)
const jambWidth = Math.max((shaftWidth - doorWidth) / 2, 0.08)
const jambCenterOffset = doorWidth / 2 + jambWidth / 2
const headerHeight = Math.max(levelHeight - doorHeight, 0.14)
meshes.push(
createElevatorColliderMesh(
typedElevatorId,
'shaft-front-left',
[jambWidth, levelHeight, wallDepth],
[-jambCenterOffset, entry.baseY + levelHeight / 2, frontWallZ],
),
createElevatorColliderMesh(
typedElevatorId,
'shaft-front-right',
[jambWidth, levelHeight, wallDepth],
[jambCenterOffset, entry.baseY + levelHeight / 2, frontWallZ],
),
createElevatorColliderMesh(
typedElevatorId,
'shaft-front-header',
[shaftWidth, headerHeight, wallDepth],
[0, entry.baseY + doorHeight + headerHeight / 2, frontWallZ],
),
createElevatorColliderMesh(
typedElevatorId,
'landing-door-gate',
[doorWidth, doorHeight, ELEVATOR_COLLIDER_DOOR_DEPTH],
[0, entry.baseY + doorHeight / 2, frontZ - 0.02],
{ doorWidth, levelId: entry.id },
),
...doorLeafSides.map((side) =>
createElevatorColliderMesh(
typedElevatorId,
side === 'left' ? 'landing-door-left' : 'landing-door-right',
[leafWidth, doorHeight, ELEVATOR_COLLIDER_DOOR_DEPTH],
[0, entry.baseY + doorHeight / 2, frontZ - 0.02],
{ doorWidth, levelId: entry.id, side },
),
),
)
}
}
return meshes
}
function disposeElevatorColliderMeshes(meshes: ElevatorColliderMesh[]) {
for (const mesh of meshes) {
const geometry = mesh.geometry as typeof mesh.geometry & {
disposeBoundsTree?: typeof disposeBoundsTree
}
geometry.disposeBoundsTree?.()
geometry.dispose()
}
}
const resolvePlacedSpawnNode = (
nodes: ReturnType<typeof useScene.getState>['nodes'],
_levelId: string | null,
) => {
const candidates = Object.values(nodes).filter((node) => node.type === 'spawn')
if (candidates.length === 0) return null
return [...candidates].sort((a, b) => a.id.localeCompare(b.id))[0] ?? null
}
export const FirstPersonControls = () => {
const { camera, gl } = useThree()
const selectedLevelId = useViewer((state) => state.selection.levelId)
const placedSpawnNode = useScene((state) => resolvePlacedSpawnNode(state.nodes, selectedLevelId))
const controllerRef = useRef<BVHEcctrlApi | null>(null)
const yawRef = useRef(0)
const pitchRef = useRef(0)
const interactableTargetRef = useRef<FirstPersonInteractableTarget | null>(null)
const [isElevatorRideLocked, setIsElevatorRideLocked] = useState(false)
const ridingElevatorRef = useRef<{
elevatorId: AnyNodeId
localControllerY: number | null
previousCarY: number
} | null>(null)
const rideLockedRef = useRef(false)
const worldRef = useRef<FirstPersonColliderWorld | null>(null)
const elevatorColliderMeshesRef = useRef<ElevatorColliderMesh[]>([])
const [world, setWorld] = useState<FirstPersonColliderWorld | null>(null)
const [elevatorColliderMeshes, setElevatorColliderMeshes] = useState<ElevatorColliderMesh[]>([])
const [controllerStart, setControllerStart] = useState<{
position: [number, number, number]
yaw: number
} | null>(null)
const replaceColliderWorld = useCallback((nextWorld: FirstPersonColliderWorld | null) => {
worldRef.current?.dispose()
worldRef.current = nextWorld
setWorld(nextWorld)
}, [])
const replaceElevatorColliderMeshes = useCallback((nextMeshes: ElevatorColliderMesh[]) => {
disposeElevatorColliderMeshes(elevatorColliderMeshesRef.current)
elevatorColliderMeshesRef.current = nextMeshes
setElevatorColliderMeshes(nextMeshes)
}, [])
const rebuildColliderWorld = useCallback(() => {
replaceColliderWorld(buildFirstPersonColliderWorldFromRegistry())
replaceElevatorColliderMeshes(buildElevatorColliderMeshes())
}, [replaceColliderWorld, replaceElevatorColliderMeshes])
const setElevatorRideLocked = useCallback((locked: boolean) => {
if (rideLockedRef.current === locked) return
rideLockedRef.current = locked
setIsElevatorRideLocked(locked)
}, [])
const resolveInteractableDoorId = useCallback((): AnyNodeId | null => {
const nodes = useScene.getState().nodes
camera.updateMatrixWorld(true)
doorInteractionRaycaster.setFromCamera(centerScreenPoint, camera)
let closestDoorId: AnyNodeId | null = null
let closestDistance = DOOR_INTERACTION_DISTANCE
for (const doorId of sceneRegistry.byType.door) {
const node = nodes[doorId as AnyNodeId]
if (node?.type !== 'door') continue
if (node.openingKind === 'opening') continue
if (node.segments.every((segment) => segment.type === 'empty')) continue
const object = sceneRegistry.nodes.get(doorId)
if (!object) continue
object.updateWorldMatrix(true, true)
const placementHit = doorInteractionRaycaster
.intersectObject(object, true)
.find((intersection) => intersection.distance <= DOOR_INTERACTION_DISTANCE)
if (placementHit && placementHit.distance < closestDistance) {
closestDoorId = doorId as AnyNodeId
closestDistance = placementHit.distance
}
const leafW = node.width - 2 * node.frameThickness
const leafH = node.height - node.frameThickness
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
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
doorLeafMatrix
.copy(object.matrixWorld)
.multiply(new Matrix4().makeTranslation(hingeX, 0, 0))
.multiply(new Matrix4().makeRotationY(leafSwingRotation))
.multiply(new Matrix4().makeTranslation(-hingeX, leafCenterY, 0))
doorLeafInverseMatrix.copy(doorLeafMatrix).invert()
doorLeafBox.min.set(-leafW / 2, -leafH / 2, -DOOR_LEAF_INTERACTION_DEPTH / 2)
doorLeafBox.max.set(leafW / 2, leafH / 2, DOOR_LEAF_INTERACTION_DEPTH / 2)
doorLeafLocalRay.copy(doorInteractionRaycaster.ray).applyMatrix4(doorLeafInverseMatrix)
const localHit = doorLeafLocalRay.intersectBox(doorLeafBox, doorLeafLocalHit)
if (!localHit) continue
doorLeafWorldHit.copy(localHit).applyMatrix4(doorLeafMatrix)
const hitDistance = doorLeafWorldHit.distanceTo(doorInteractionRaycaster.ray.origin)
if (hitDistance <= DOOR_INTERACTION_DISTANCE && hitDistance < closestDistance) {
closestDoorId = doorId as AnyNodeId
closestDistance = hitDistance
}
}
return closestDoorId
}, [camera])
const resolveInteractableWindowId = useCallback((): AnyNodeId | null => {
const nodes = useScene.getState().nodes
camera.updateMatrixWorld(true)
windowInteractionRaycaster.setFromCamera(centerScreenPoint, camera)
let closestWindowId: AnyNodeId | null = null
let closestDistance = DOOR_INTERACTION_DISTANCE
for (const windowId of sceneRegistry.byType.window) {
const node = nodes[windowId as AnyNodeId]
if (node?.type !== 'window') continue
if (node.openingKind === 'opening') continue
if (!isOperableWindowType(node.windowType)) continue
const object = sceneRegistry.nodes.get(windowId)
if (!object) continue
const hit = windowInteractionRaycaster
.intersectObject(object, true)
.find((intersection) => intersection.distance <= DOOR_INTERACTION_DISTANCE)
if (!(hit && hit.distance < closestDistance)) continue
closestWindowId = windowId as AnyNodeId
closestDistance = hit.distance
}
return closestWindowId
}, [camera])
const resolveInteractableElevatorTarget =
useCallback((): FirstPersonInteractableTarget | null => {
const nodes = useScene.getState().nodes
camera.updateMatrixWorld(true)
elevatorInteractionRaycaster.setFromCamera(centerScreenPoint, camera)
let closestTarget: FirstPersonInteractableTarget | null = null
let closestDistance = DOOR_INTERACTION_DISTANCE
for (const elevatorId of sceneRegistry.byType.elevator) {
const typedElevatorId = elevatorId as AnyNodeId
const node = nodes[typedElevatorId]
if (node?.type !== 'elevator') continue
const object = sceneRegistry.nodes.get(typedElevatorId)
if (!object) continue
const runtime = useInteractive.getState().elevators[typedElevatorId]
object.updateWorldMatrix(true, true)
if (runtime) {
elevatorLocalEyePosition.copy(camera.position)
object.worldToLocal(elevatorLocalEyePosition)
}
const canUseCabButtons =
runtime && isInsideElevatorCab(node, runtime, elevatorLocalEyePosition)
const intersections = elevatorInteractionRaycaster.intersectObject(object, true)
for (const intersection of intersections) {
if (intersection.distance > closestDistance) continue
const target = resolveElevatorButtonTarget(intersection.object)
if (!target || target.elevatorId !== elevatorId) continue
if (target.action === 'request-level') {
if (!target.levelId || nodes[target.levelId]?.type !== 'level') continue
}
if (target.buttonKind === 'cab' && !canUseCabButtons) continue
closestTarget = {
action: target.action,
buttonKind: target.buttonKind,
id: target.elevatorId,
levelId: target.levelId,
type: 'elevator',
}
closestDistance = intersection.distance
}
}
return closestTarget
}, [camera])
const resolveInteractableTarget = useCallback((): FirstPersonInteractableTarget | null => {
const elevatorTarget = resolveInteractableElevatorTarget()
if (elevatorTarget) return elevatorTarget
const doorId = resolveInteractableDoorId()
if (doorId) return { id: doorId, type: 'door' }
const windowId = resolveInteractableWindowId()
if (windowId) return { id: windowId, type: 'window' }
return null
}, [resolveInteractableDoorId, resolveInteractableElevatorTarget, resolveInteractableWindowId])
const toggleInteractableTarget = useCallback(() => {
const target = interactableTargetRef.current ?? resolveInteractableTarget()
if (!target) return
if (target.type === 'elevator') {
if (target.buttonKind === 'cab') {
const state = useInteractive.getState().elevators[target.id]
if (state) {
ridingElevatorRef.current = {
elevatorId: target.id,
localControllerY: null,
previousCarY: state.carY,
}
}
}
if (target.action === 'open-door') {
openElevatorDoor(target.id)
return
}
if (target.levelId) {
const targetElevatorId =
target.buttonKind === 'landing'
? resolveElevatorDispatchTarget({
elevators: useInteractive.getState().elevators,
levelId: target.levelId,
nodes: useScene.getState().nodes,
requestedElevatorId: target.id,
})
: target.id
requestElevatorLevel(targetElevatorId, target.levelId)
}
return
}
if (target.type === 'window') {
const node = useScene.getState().nodes[target.id]
if (
node?.type !== 'window' ||
node.openingKind === 'opening' ||
!isOperableWindowType(node.windowType)
) {
return
}
toggleWindowOpenState(target.id, { persist: false })
return
}
const doorId = target.id
const node = useScene.getState().nodes[doorId]
if (node?.type !== 'door' || node.openingKind === 'opening') return
toggleDoorOpenState(doorId, { persist: false })
}, [resolveInteractableTarget])
const closeInteractableTarget = useCallback(() => {
const target = interactableTargetRef.current ?? resolveInteractableTarget()
if (!target) return
if (target.type === 'elevator') return
if (target.type === 'window') {
const node = useScene.getState().nodes[target.id]
if (
node?.type !== 'window' ||
node.openingKind === 'opening' ||
!isOperableWindowType(node.windowType)
) {
return
}
closeWindowOpenState(target.id, { persist: false })
return
}
const node = useScene.getState().nodes[target.id]
if (node?.type !== 'door' || node.openingKind === 'opening') return
closeDoorOpenState(target.id, { persist: false })
}, [resolveInteractableTarget])
const placedSpawn = useMemo<FirstPersonSpawn | null>(() => {
if (!(placedSpawnNode && placedSpawnNode.type === 'spawn')) return null
const spawnObject = sceneRegistry.nodes.get(placedSpawnNode.id)
if (spawnObject) {
spawnObject.updateWorldMatrix(true, false)
spawnObject.getWorldPosition(spawnWorldPosition)
spawnWorldEuler.setFromRotationMatrix(spawnObject.matrixWorld, 'YXZ')
return {
position: [
spawnWorldPosition.x,
spawnWorldPosition.y + FIRST_PERSON_SPAWN_EYE_HEIGHT,
spawnWorldPosition.z,
],
yaw: spawnWorldEuler.y,
}
}
return {
position: [
placedSpawnNode.position[0],
placedSpawnNode.position[1] + FIRST_PERSON_SPAWN_EYE_HEIGHT,
placedSpawnNode.position[2],
],
yaw: placedSpawnNode.rotation,
}
}, [placedSpawnNode])
useEffect(() => {
rebuildColliderWorld()
return () => {
worldRef.current?.dispose()
worldRef.current = null
disposeElevatorColliderMeshes(elevatorColliderMeshesRef.current)
elevatorColliderMeshesRef.current = []
setElevatorColliderMeshes([])
setWorld(null)
}
}, [rebuildColliderWorld])
useEffect(() => {
emitter.on('door:animation-completed', rebuildColliderWorld)
emitter.on('window:animation-completed', rebuildColliderWorld)
return () => {
emitter.off('door:animation-completed', rebuildColliderWorld)
emitter.off('window:animation-completed', rebuildColliderWorld)
}
}, [rebuildColliderWorld])
useEffect(() => {
if (!world) return
if (controllerStart) return
const spawn = placedSpawn ?? deriveFirstPersonSpawn(camera, world)
const [x, y, z] = spawn.position
yawRef.current = spawn.yaw
pitchRef.current = 0
setControllerStart({
position: [x, y - CONTROLLER_CENTER_FROM_EYE, z],
yaw: spawn.yaw,
})
}, [camera, controllerStart, placedSpawn, world])
useEffect(() => {
const canvas = gl.domElement
const handleMouseMove = (e: MouseEvent) => {
if (document.pointerLockElement !== canvas) return
yawRef.current -= e.movementX * LOOK_SENSITIVITY
pitchRef.current = Math.max(
-(Math.PI / 2 - 0.05),
Math.min(Math.PI / 2 - 0.05, pitchRef.current - e.movementY * LOOK_SENSITIVITY),
)
}
const handleClick = (event: MouseEvent) => {
const target = event.target
if (!(target instanceof HTMLElement)) return
if (!canvas.contains(target)) return
if (document.pointerLockElement !== canvas) {
canvas.requestPointerLock?.()
}
}
const handleMouseDown = (event: MouseEvent) => {
if (document.pointerLockElement !== canvas) return
if (event.button !== 0) return
event.preventDefault()
event.stopPropagation()
toggleInteractableTarget()
}
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('click', handleClick)
document.addEventListener('mousedown', handleMouseDown, true)
return () => {
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('click', handleClick)
document.removeEventListener('mousedown', handleMouseDown, true)
if (document.pointerLockElement === canvas) {
document.exitPointerLock()
}
}
}, [gl, toggleInteractableTarget])
useEffect(() => {
const canvas = gl.domElement
const handleKeyDown = (event: KeyboardEvent) => {
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
return
}
if (event.code === 'Escape') {
event.preventDefault()
event.stopPropagation()
if (document.pointerLockElement === canvas) {
document.exitPointerLock()
}
useEditor.getState().setFirstPersonMode(false)
} else if (event.code === 'KeyE' || event.code === 'KeyR') {
event.preventDefault()
event.stopPropagation()
toggleInteractableTarget()
} else if (event.code === 'KeyT') {
event.preventDefault()
event.stopPropagation()
closeInteractableTarget()
}
}
document.addEventListener('keydown', handleKeyDown, true)
return () => {
document.removeEventListener('keydown', handleKeyDown, true)
}
}, [closeInteractableTarget, gl, toggleInteractableTarget])
const syncElevatorColliderMeshes = useCallback(() => {
const nodes = useScene.getState().nodes
const interactive = useInteractive.getState()
for (const mesh of elevatorColliderMeshesRef.current) {
const { doorWidth, dynamic, elevatorId, kind, levelId, localPosition, side } = mesh.userData
const node = nodes[elevatorId]
const runtime = interactive.elevators[elevatorId]
const object = sceneRegistry.nodes.get(elevatorId)
if (!(node?.type === 'elevator' && object && node.visible !== false)) {
mesh.visible = false
continue
}
if (!dynamic && mesh.userData.matrixInitialized && mesh.visible) {
continue
}
let [localX, localY, localZ] = localPosition
const isCabCollider = kind.startsWith('cab-')
const isDoorCollider = kind === 'landing-door-left' || kind === 'landing-door-right'
const isCabDoorGate = kind === 'cab-door-gate'
const isLandingDoorGate = kind === 'landing-door-gate'
if (isCabCollider) {
localY += runtime?.carY ?? 0
}
if (kind === 'cab-door-left' || kind === 'cab-door-right') {
if (!runtime) {
mesh.visible = false
continue
}
localX = getElevatorDoorLeafX(
side ?? 'left',
doorWidth ?? node.doorWidth,
runtime.doorOpen,
node.doorStyle,
)
mesh.visible = true
} else if (isCabDoorGate) {
if (!runtime) {
mesh.visible = false
continue
}
mesh.visible = runtime.doorOpen < ELEVATOR_ENTRY_DOOR_OPEN_THRESHOLD
} else if (isDoorCollider) {
const doorOpen = runtime?.currentLevelId === levelId ? (runtime?.doorOpen ?? 0) : 0
localX = getElevatorDoorLeafX(
side ?? 'left',
doorWidth ?? node.doorWidth,
doorOpen,
node.doorStyle,
)
mesh.visible = true
} else if (isLandingDoorGate) {
const doorOpen = runtime?.currentLevelId === levelId ? (runtime?.doorOpen ?? 0) : 0
mesh.visible = doorOpen < ELEVATOR_ENTRY_DOOR_OPEN_THRESHOLD
} else {
mesh.visible = true
}
object.updateWorldMatrix(true, false)
elevatorColliderMatrix.copy(object.matrixWorld)
elevatorColliderMatrix.multiply(
elevatorColliderLocalMatrix.makeTranslation(localX, localY, localZ),
)
mesh.matrix.copy(elevatorColliderMatrix)
mesh.matrixWorld.copy(elevatorColliderMatrix)
mesh.userData.matrixInitialized = true
}
}, [])
useFrame(() => {
syncElevatorColliderMeshes()
}, -1)
const syncElevatorRide = useCallback(
(group: Group) => {
const nodes = useScene.getState().nodes
const interactive = useInteractive.getState()
const activeRide = ridingElevatorRef.current
let nextRide: {
cabHeight: number
cabCenterZ: number
carY: number
doorOpen: number
elevatorId: AnyNodeId
halfDepth: number
halfWidth: number
object: Object3D
phase: NonNullable<(typeof interactive.elevators)[AnyNodeId]>['phase']
} | null = null
const elevatorIds = activeRide
? [
activeRide.elevatorId,
...Array.from(sceneRegistry.byType.elevator).filter(
(elevatorId) => elevatorId !== activeRide.elevatorId,
),
]
: Array.from(sceneRegistry.byType.elevator)
for (const elevatorId of elevatorIds) {
const typedElevatorId = elevatorId as AnyNodeId
const node = nodes[typedElevatorId]
if (node?.type !== 'elevator') continue
const runtime = interactive.elevators[typedElevatorId]
const object = sceneRegistry.nodes.get(typedElevatorId)
if (!(runtime && object)) continue
object.updateWorldMatrix(true, true)
elevatorLocalEyePosition.copy(camera.position)
object.worldToLocal(elevatorLocalEyePosition)
const halfWidth = getElevatorCabWidth(node) / 2 - ELEVATOR_RIDE_HORIZONTAL_PADDING
const halfDepth = getElevatorCabDepth(node) / 2 - ELEVATOR_RIDE_HORIZONTAL_PADDING
const cabCenterZ = getElevatorCabCenterZ(node)
const cabHeight = Math.max(node.cabHeight, 1.4)
const insideFootprint =
Math.abs(elevatorLocalEyePosition.x) <= Math.max(halfWidth, 0.24) &&
Math.abs(elevatorLocalEyePosition.z - cabCenterZ) <= Math.max(halfDepth, 0.24)
const insideCabHeight =
elevatorLocalEyePosition.y >= runtime.carY + 0.35 &&
elevatorLocalEyePosition.y <= runtime.carY + cabHeight + 0.7
const continuingRide =
activeRide?.elevatorId === typedElevatorId &&
insideFootprint &&
elevatorLocalEyePosition.y >= runtime.carY - 0.2 &&
elevatorLocalEyePosition.y <= runtime.carY + cabHeight + 1.25
if ((insideFootprint && insideCabHeight) || continuingRide) {
nextRide = {
cabHeight,
cabCenterZ,
carY: runtime.carY,
doorOpen: runtime.doorOpen,
elevatorId: typedElevatorId,
halfDepth: Math.max(halfDepth, 0.24),
halfWidth: Math.max(halfWidth, 0.24),
object,
phase: runtime.phase,
}
break
}
}
if (!nextRide) {
ridingElevatorRef.current = null
setElevatorRideLocked(false)
return
}
const previousCarY =
activeRide?.elevatorId === nextRide.elevatorId ? activeRide.previousCarY : nextRide.carY
const deltaY = nextRide.carY - previousCarY
nextRide.object.updateWorldMatrix(true, true)
elevatorLocalControllerPosition.copy(group.position)
nextRide.object.worldToLocal(elevatorLocalControllerPosition)
const localControllerY =
activeRide?.elevatorId === nextRide.elevatorId && activeRide.localControllerY !== null
? activeRide.localControllerY
: elevatorLocalControllerPosition.y - nextRide.carY
if (Math.abs(deltaY) > 0.0001) {
group.position.y += deltaY
controllerRef.current?.resetLinVel()
}
const shouldLockToCab =
nextRide.phase === 'closing' ||
nextRide.phase === 'moving' ||
(nextRide.phase === 'opening' && nextRide.doorOpen < ELEVATOR_ENTRY_DOOR_OPEN_THRESHOLD)
if (shouldLockToCab) {
elevatorLocalControllerPosition.copy(group.position)
nextRide.object.worldToLocal(elevatorLocalControllerPosition)
const desiredLocalY = nextRide.carY + localControllerY
if (Math.abs(elevatorLocalControllerPosition.y - desiredLocalY) > 0.002) {
elevatorLocalControllerPosition.y = desiredLocalY
elevatorWorldControllerPosition.copy(elevatorLocalControllerPosition)
nextRide.object.localToWorld(elevatorWorldControllerPosition)
group.position.y = elevatorWorldControllerPosition.y
controllerRef.current?.resetLinVel()
elevatorLocalControllerPosition.copy(group.position)
nextRide.object.worldToLocal(elevatorLocalControllerPosition)
}
const clampedX = Math.max(
-nextRide.halfWidth,
Math.min(nextRide.halfWidth, elevatorLocalControllerPosition.x),
)
const clampedZ = Math.max(
nextRide.cabCenterZ - nextRide.halfDepth,
Math.min(nextRide.cabCenterZ + nextRide.halfDepth, elevatorLocalControllerPosition.z),
)
if (
Math.abs(clampedX - elevatorLocalControllerPosition.x) > 0.0001 ||
Math.abs(clampedZ - elevatorLocalControllerPosition.z) > 0.0001
) {
elevatorLocalControllerPosition.x = clampedX
elevatorLocalControllerPosition.z = clampedZ
elevatorWorldControllerPosition.copy(elevatorLocalControllerPosition)
nextRide.object.localToWorld(elevatorWorldControllerPosition)
group.position.x = elevatorWorldControllerPosition.x
group.position.z = elevatorWorldControllerPosition.z
controllerRef.current?.resetLinVel()
}
}
setElevatorRideLocked(shouldLockToCab)
ridingElevatorRef.current = {
elevatorId: nextRide.elevatorId,
localControllerY,
previousCarY: nextRide.carY,
}
},
[camera, setElevatorRideLocked],
)
useFrame(() => {
if (!controllerRef.current?.group) return
const group = controllerRef.current.group
group.rotation.y = 0
camera.position.copy(group.position).add(cameraOffset)
cameraEuler.set(pitchRef.current, yawRef.current, 0, 'YXZ')
camera.quaternion.setFromEuler(cameraEuler)
camera.updateMatrixWorld(true)
syncElevatorRide(group)
camera.position.copy(group.position).add(cameraOffset)
camera.updateMatrixWorld(true)
const nextInteractableTarget = resolveInteractableTarget()
const previousInteractableTarget = interactableTargetRef.current
if (
getInteractableTargetKey(previousInteractableTarget) !==
getInteractableTargetKey(nextInteractableTarget)
) {
interactableTargetRef.current = nextInteractableTarget
useViewer.getState().setHoveredId(nextInteractableTarget?.id ?? null)
}
}, 2.5)
useEffect(() => {
return () => {
if (useViewer.getState().hoveredId === interactableTargetRef.current?.id) {
useViewer.getState().setHoveredId(null)
}
}
}, [])
const firstPersonColliderMeshes = useMemo(
() => (world ? [world.mesh, ...elevatorColliderMeshes] : elevatorColliderMeshes),
[world, elevatorColliderMeshes],
)
if (!world) {
return null
}
return (
<>
{controllerStart && (
<KeyboardControls map={keyboardMap}>
<BVHEcctrl
acceleration={26}
airDragFactor={0.3}
colliderCapsuleArgs={[0.25, 0.8, 4, 8]}
colliderMeshes={firstPersonColliderMeshes}
collisionCheckIteration={3}
collisionPushBackDamping={0.1}
collisionPushBackThreshold={0.001}
debug={false}
deceleration={30}
delay={0}
fallGravityFactor={4}
floatCheckType="BOTH"
floatDampingC={36}
floatHeight={0.5}
floatPullBackHeight={0.35}
floatSensorRadius={0.15}
floatSpringK={1200}
gravity={9.81}
jumpVel={6}
key="first-person-controller"
maxRunSpeed={5.5}
maxSlope={1.2}
maxWalkSpeed={4}
paused={isElevatorRideLocked}
position={controllerStart.position}
ref={controllerRef}
/>
</KeyboardControls>
)}
</>
)
}
/**
* Overlay UI for first-person mode: crosshair, controls hint, exit button.
* Rendered as a regular DOM overlay (not inside the Canvas).
*/
export const FirstPersonOverlay = ({ onExit }: { onExit: () => void }) => {
const [isLocked, setIsLocked] = useState(false)
const hasPlacedSpawn = useScene((state) =>
Object.values(state.nodes).some((node) => node.type === 'spawn'),
)
useEffect(() => {
const handlePointerLockChange = () => {
setIsLocked(document.pointerLockElement != null)
}
handlePointerLockChange()
document.addEventListener('pointerlockchange', handlePointerLockChange)
return () => {
document.removeEventListener('pointerlockchange', handlePointerLockChange)
}
}, [])
const handleExit = useCallback(() => {
if (document.pointerLockElement) {
document.exitPointerLock()
}
onExit()
}, [onExit])
return (
<>
{isLocked && (
<div className="pointer-events-none fixed inset-0 z-40 flex items-center justify-center">
<div className="relative h-7 w-7">
<div className="absolute top-1/2 left-1/2 h-px w-7 -translate-x-1/2 -translate-y-1/2 bg-white/60" />
<div className="absolute top-1/2 left-1/2 h-7 w-px -translate-x-1/2 -translate-y-1/2 bg-white/60" />
</div>
</div>
)}
<div className="fixed top-4 right-4 z-50">
<button
className="pointer-events-auto flex items-center gap-2 rounded-xl border border-border/40 bg-background/90 px-4 py-2 font-medium text-foreground text-sm shadow-lg backdrop-blur-xl transition-colors hover:bg-background"
onClick={handleExit}
type="button"
>
<kbd className="rounded border border-border/50 bg-accent/50 px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
ESC
</kbd>
Exit Street View
</button>
</div>
{!hasPlacedSpawn && (
<div className="fixed top-4 left-1/2 z-50 -translate-x-1/2">
<div className="rounded-2xl border border-sky-300/35 bg-slate-950/88 px-4 py-2 text-center text-slate-100 text-sm shadow-lg backdrop-blur-xl">
Place a Spawn Point from the Build tab to control where walkthrough starts.
</div>
</div>
)}
{isLocked && (
<div className="pointer-events-none fixed top-1/2 right-6 z-40 -translate-y-1/2">
<div className="flex min-w-[148px] flex-col gap-3 rounded-2xl border border-border/35 bg-background/80 px-4 py-4 shadow-lg backdrop-blur-xl">
<ControlHint keys={['W', 'A', 'S', 'D']} label="Move" />
<div className="h-px w-full bg-border/30" />
<InlineControlHint keyLabel="Space" label="Jump" />
<InlineControlHint keyLabel="Shift" label="Sprint" />
<InlineControlHint keyLabel="E / R" label="Interact" />
<InlineControlHint keyLabel="T" label="Close" />
<div className="h-px w-full bg-border/30" />
<span className="text-center text-muted-foreground/60 text-xs">
Click to look around
</span>
</div>
</div>
)}
</>
)
}
function ControlHint({ label, keys }: { label: string; keys: string[] }) {
return (
<div className="flex flex-col items-center gap-1.5 text-center">
<span className="font-medium text-[10px] text-muted-foreground/60 tracking-[0.03em]">
{label}
</span>
<div className="flex flex-wrap items-center justify-center gap-1">
{keys.map((key) => (
<kbd
className="flex h-5 min-w-5 items-center justify-center rounded border border-border/50 bg-accent/40 px-1 font-mono text-[10px] text-foreground/80 leading-none"
key={key}
>
{key}
</kbd>
))}
</div>
</div>
)
}
function InlineControlHint({ label, keyLabel }: { label: string; keyLabel: string }) {
return (
<div className="flex items-center justify-between gap-3">
<span className="font-medium text-[10px] text-muted-foreground/60 uppercase tracking-[0.03em]">
{label}
</span>
<kbd className="flex h-5 min-w-5 items-center justify-center rounded border border-border/50 bg-accent/40 px-1.5 font-mono text-[10px] text-foreground/80 leading-none">
{keyLabel}
</kbd>
</div>
)
}