Refactor elevator runtime into core

This commit is contained in:
sudhir
2026-05-13 01:56:06 +05:30
parent b782ce9cf2
commit 9838d2cd4f
14 changed files with 595 additions and 429 deletions
+29
View File
@@ -88,10 +88,39 @@ export {
export { default as useLiveTransforms, type LiveTransform } from './store/use-live-transforms' export { default as useLiveTransforms, type LiveTransform } from './store/use-live-transforms'
export { clearSceneHistory, default as useScene } from './store/use-scene' export { clearSceneHistory, default as useScene } from './store/use-scene'
export { resolveElevatorDispatchTarget } from './systems/elevator/elevator-dispatch' export { resolveElevatorDispatchTarget } from './systems/elevator/elevator-dispatch'
export {
getElevatorCabCenterZ,
getElevatorCabDepth,
getElevatorCabWidth,
getElevatorDoorLeafSides,
getElevatorDoorLeafWidth,
getElevatorDoorLeafX,
getElevatorShaftDepth,
getElevatorShaftWallThickness,
getElevatorShaftWidth,
getResolvedElevatorDoorPanelStyle,
getResolvedElevatorDoorStyle,
getResolvedElevatorShaftStyle,
type ElevatorDoorSide,
} from './systems/elevator/elevator-geometry'
export { syncAutoElevatorOpenings } from './systems/elevator/elevator-opening-sync' export { syncAutoElevatorOpenings } from './systems/elevator/elevator-opening-sync'
export { ElevatorOpeningSystem } from './systems/elevator/elevator-opening-system' export { ElevatorOpeningSystem } from './systems/elevator/elevator-opening-system'
export { export {
createElevatorInteractiveState,
openElevatorDoor,
openElevatorDoorState,
queueElevatorRequest,
requestElevatorLevel,
stepElevatorRuntimeState,
stepElevatorRuntimes,
} from './systems/elevator/elevator-runtime'
export { ElevatorRuntimeSystem } from './systems/elevator/elevator-runtime-system'
export {
DEFAULT_ELEVATOR_LEVEL_HEIGHT,
type ElevatorLevelEntry,
getElevatorLevelHeight,
resolveElevatorBuildingLevels, resolveElevatorBuildingLevels,
resolveElevatorLevels,
resolveElevatorServiceLevelIds, resolveElevatorServiceLevelIds,
resolveElevatorServiceLevels, resolveElevatorServiceLevels,
} from './systems/elevator/elevator-service' } from './systems/elevator/elevator-service'
@@ -95,12 +95,6 @@ type InteractiveStore = {
/** Initialize an elevator's runtime state from its default served level. */ /** Initialize an elevator's runtime state from its default served level. */
initElevator: (elevatorId: AnyNodeId, levelId: AnyNodeId, carY: number) => void initElevator: (elevatorId: AnyNodeId, levelId: AnyNodeId, carY: number) => void
/** Queue a request for an elevator to travel to a level. */
requestElevator: (elevatorId: AnyNodeId, levelId: AnyNodeId) => void
/** Open the elevator doors at the current level when the car is not moving. */
openElevatorDoor: (elevatorId: AnyNodeId) => void
/** Merge runtime elevator state. */ /** Merge runtime elevator state. */
setElevatorState: (elevatorId: AnyNodeId, value: Partial<ElevatorInteractiveState>) => void setElevatorState: (elevatorId: AnyNodeId, value: Partial<ElevatorInteractiveState>) => void
@@ -252,49 +246,6 @@ export const useInteractive = create<InteractiveStore>((set, get) => ({
})) }))
}, },
requestElevator: (elevatorId, levelId) => {
set((state) => {
const elevator = state.elevators[elevatorId] ?? {
currentLevelId: null,
targetLevelId: null,
carY: 0,
doorOpen: 0,
phase: 'idle' as const,
phaseStartedAt: null,
queue: [],
}
const isAlreadyQueued = elevator.queue.includes(levelId) || elevator.targetLevelId === levelId
return {
elevators: {
...state.elevators,
[elevatorId]: {
...elevator,
queue: isAlreadyQueued ? elevator.queue : [...elevator.queue, levelId],
},
},
}
})
},
openElevatorDoor: (elevatorId) => {
set((state) => {
const elevator = state.elevators[elevatorId]
if (!elevator?.currentLevelId || elevator.phase === 'moving') return state
return {
elevators: {
...state.elevators,
[elevatorId]: {
...elevator,
phase: 'opening',
phaseStartedAt: null,
},
},
}
})
},
setElevatorState: (elevatorId, value) => { setElevatorState: (elevatorId, value) => {
set((state) => { set((state) => {
const current = state.elevators[elevatorId] const current = state.elevators[elevatorId]
@@ -0,0 +1,92 @@
import type {
ElevatorDoorPanelStyle,
ElevatorDoorStyle,
ElevatorNode,
ElevatorShaftStyle,
} from '../../schema'
export type ElevatorDoorSide = 'left' | 'right'
const DEFAULT_ELEVATOR_SHAFT_WALL_THICKNESS = 0.09
export function getResolvedElevatorDoorStyle(
doorStyle: ElevatorNode['doorStyle'] | undefined,
): ElevatorDoorStyle {
return doorStyle ?? 'center-opening'
}
export function getResolvedElevatorDoorPanelStyle(
doorPanelStyle: ElevatorNode['doorPanelStyle'] | undefined,
): ElevatorDoorPanelStyle {
return doorPanelStyle ?? 'glass-frame'
}
export function getResolvedElevatorShaftStyle(
shaftStyle: ElevatorNode['shaftStyle'] | undefined,
): ElevatorShaftStyle {
return shaftStyle ?? 'solid'
}
export function getElevatorDoorLeafSides(
doorStyle: ElevatorNode['doorStyle'] | undefined,
): ElevatorDoorSide[] {
const resolvedDoorStyle = getResolvedElevatorDoorStyle(doorStyle)
if (resolvedDoorStyle === 'single-left') return ['left']
if (resolvedDoorStyle === 'single-right') return ['right']
return ['left', 'right']
}
export function getElevatorDoorLeafX(
side: ElevatorDoorSide,
openingWidth: number,
doorOpen: number,
doorStyle: ElevatorNode['doorStyle'] | undefined,
) {
const resolvedDoorStyle = getResolvedElevatorDoorStyle(doorStyle)
if (resolvedDoorStyle === 'center-opening') {
const direction = side === 'left' ? -1 : 1
return direction * (openingWidth / 4 + doorOpen * openingWidth * 0.34)
}
const direction = resolvedDoorStyle === 'single-left' ? -1 : 1
return direction * doorOpen * openingWidth * 0.68
}
export function getElevatorDoorLeafWidth(
openingWidth: number,
doorStyle: ElevatorNode['doorStyle'] | undefined,
) {
return getResolvedElevatorDoorStyle(doorStyle) === 'center-opening'
? Math.max(openingWidth / 2 - 0.018, 0.12)
: Math.max(openingWidth - 0.018, 0.18)
}
export function getElevatorCabWidth(node: ElevatorNode) {
return Math.max(node.width, 0.8)
}
export function getElevatorCabDepth(node: ElevatorNode) {
return Math.max(node.depth, 0.8)
}
export function getElevatorShaftWallThickness(node: ElevatorNode) {
return Math.max(node.shaftWallThickness ?? DEFAULT_ELEVATOR_SHAFT_WALL_THICKNESS, 0.04)
}
export function getElevatorShaftWidth(
node: ElevatorNode,
cabWidth = getElevatorCabWidth(node),
) {
return Math.max(node.shaftWidth ?? cabWidth, cabWidth, 0.8)
}
export function getElevatorShaftDepth(
node: ElevatorNode,
cabDepth = getElevatorCabDepth(node),
) {
return Math.max(node.shaftDepth ?? cabDepth, cabDepth, 0.8)
}
export function getElevatorCabCenterZ(node: ElevatorNode) {
return -getElevatorShaftDepth(node) / 2 + getElevatorCabDepth(node) / 2
}
@@ -0,0 +1,10 @@
import { useFrame } from '@react-three/fiber'
import { stepElevatorRuntimes } from './elevator-runtime'
export function ElevatorRuntimeSystem() {
useFrame(({ clock }, delta) => {
stepElevatorRuntimes(clock.getElapsedTime() * 1000, delta)
}, 2)
return null
}
@@ -0,0 +1,79 @@
import { describe, expect, test } from 'bun:test'
import type { AnyNodeId } from '../../schema'
import { ElevatorNode } from '../../schema'
import {
createElevatorInteractiveState,
openElevatorDoorState,
queueElevatorRequest,
stepElevatorRuntimeState,
} from './elevator-runtime'
import type { ElevatorLevelEntry } from './elevator-service'
const groundLevelId = 'level_ground' as AnyNodeId
const upperLevelId = 'level_upper' as AnyNodeId
const entries: ElevatorLevelEntry[] = [
{ id: groundLevelId as ElevatorLevelEntry['id'], label: '0', baseY: 0 },
{ id: upperLevelId as ElevatorLevelEntry['id'], label: '1', baseY: 2.5 },
]
const elevator = ElevatorNode.parse({
speed: 10,
doorDurationMs: 100,
dwellMs: 0,
})
describe('elevator runtime helpers', () => {
test('queues level requests without duplicating the target', () => {
const state = createElevatorInteractiveState(groundLevelId, 0)
const queued = queueElevatorRequest(state, upperLevelId)
const duplicated = queueElevatorRequest(queued, upperLevelId)
expect(queued.queue).toEqual([upperLevelId])
expect(duplicated.queue).toEqual([upperLevelId])
})
test('opens doors only when the elevator is not moving', () => {
const idle = createElevatorInteractiveState(groundLevelId, 0)
const moving = { ...idle, phase: 'moving' as const }
expect(openElevatorDoorState(idle).phase).toBe('opening')
expect(openElevatorDoorState(moving)).toBe(moving)
})
test('moves to a queued level and clears the served request on arrival', () => {
const queued = queueElevatorRequest(createElevatorInteractiveState(groundLevelId, 0), upperLevelId)
const moving = stepElevatorRuntimeState({
defaultEntry: entries[0]!,
delta: 0.016,
elevator,
entries,
now: 0,
state: queued,
})
const arrived = stepElevatorRuntimeState({
defaultEntry: entries[0]!,
delta: 1,
elevator,
entries,
now: 100,
state: moving,
})
const open = stepElevatorRuntimeState({
defaultEntry: entries[0]!,
delta: 1,
elevator,
entries,
now: 200,
state: arrived,
})
expect(moving.phase).toBe('moving')
expect(arrived.currentLevelId).toBe(upperLevelId)
expect(arrived.phase).toBe('opening')
expect(open.phase).toBe('open')
expect(open.queue).toEqual([])
})
})
@@ -0,0 +1,265 @@
import type { AnyNode, AnyNodeId, ElevatorNode } from '../../schema'
import { type ElevatorInteractiveState, useInteractive } from '../../store/use-interactive'
import useScene from '../../store/use-scene'
import { resolveElevatorLevels, type ElevatorLevelEntry } from './elevator-service'
const EPSILON = 0.001
function moveToward(current: number, target: number, maxDelta: number) {
const delta = target - current
if (Math.abs(delta) <= maxDelta) return target
return current + Math.sign(delta) * maxDelta
}
export function createElevatorInteractiveState(
levelId: AnyNodeId,
carY: number,
): ElevatorInteractiveState {
return {
currentLevelId: levelId,
targetLevelId: null,
carY,
doorOpen: 0,
phase: 'idle',
phaseStartedAt: null,
queue: [],
}
}
function getInitialElevatorState(
elevatorId: AnyNodeId,
nodes: Record<string, AnyNode>,
): ElevatorInteractiveState | null {
const node = nodes[elevatorId]
if (node?.type !== 'elevator') return null
const { defaultEntry } = resolveElevatorLevels(node, nodes)
if (!defaultEntry) return null
return createElevatorInteractiveState(defaultEntry.id as AnyNodeId, defaultEntry.baseY)
}
function ensureElevatorState(
elevatorId: AnyNodeId,
nodes: Record<string, AnyNode>,
): ElevatorInteractiveState | null {
const interactive = useInteractive.getState()
const existing = interactive.elevators[elevatorId]
if (existing) return existing
const initial = getInitialElevatorState(elevatorId, nodes)
if (!initial) return null
interactive.initElevator(elevatorId, initial.currentLevelId as AnyNodeId, initial.carY)
return initial
}
export function queueElevatorRequest(
state: ElevatorInteractiveState,
levelId: AnyNodeId,
): ElevatorInteractiveState {
const isAlreadyQueued = state.queue.includes(levelId) || state.targetLevelId === levelId
if (isAlreadyQueued) return state
return {
...state,
queue: [...state.queue, levelId],
}
}
export function openElevatorDoorState(
state: ElevatorInteractiveState,
): ElevatorInteractiveState {
if (!state.currentLevelId || state.phase === 'moving') return state
return {
...state,
phase: 'opening',
phaseStartedAt: null,
}
}
export function requestElevatorLevel(elevatorId: AnyNodeId, levelId: AnyNodeId) {
const nodes = useScene.getState().nodes
const current = ensureElevatorState(elevatorId, nodes)
if (!current) return
const next = queueElevatorRequest(current, levelId)
if (next === current) return
useInteractive.getState().setElevatorState(elevatorId, next)
}
export function openElevatorDoor(elevatorId: AnyNodeId) {
const nodes = useScene.getState().nodes
const current = ensureElevatorState(elevatorId, nodes)
if (!current) return
const next = openElevatorDoorState(current)
if (next === current) return
useInteractive.getState().setElevatorState(elevatorId, next)
}
export function stepElevatorRuntimeState({
defaultEntry,
delta,
elevator,
entries,
now,
state,
}: {
defaultEntry: ElevatorLevelEntry
delta: number
elevator: ElevatorNode
entries: ElevatorLevelEntry[]
now: number
state: ElevatorInteractiveState
}): ElevatorInteractiveState {
const currentEntry = entries.find((entry) => entry.id === state.currentLevelId) ?? defaultEntry
if (currentEntry.id !== state.currentLevelId) {
return {
...state,
currentLevelId: currentEntry.id as AnyNodeId,
carY: currentEntry.baseY,
targetLevelId: null,
phase: 'idle',
phaseStartedAt: null,
queue: [],
doorOpen: 0,
}
}
const targetEntry = state.targetLevelId
? entries.find((entry) => entry.id === state.targetLevelId)
: state.queue[0]
? entries.find((entry) => entry.id === state.queue[0])
: null
const doorDurationMs = Math.max(elevator.doorDurationMs ?? 900, 1)
const doorStep = (delta * 1000) / doorDurationMs
switch (state.phase) {
case 'idle': {
const nextLevelId = state.queue[0] ?? null
if (!nextLevelId) {
if (state.doorOpen > EPSILON) {
return {
...state,
doorOpen: Math.max(0, state.doorOpen - doorStep),
}
}
return state
}
return {
...state,
targetLevelId: nextLevelId,
phase:
state.doorOpen > EPSILON
? 'closing'
: nextLevelId === state.currentLevelId
? 'opening'
: 'moving',
phaseStartedAt: now,
}
}
case 'closing': {
const doorOpen = Math.max(0, state.doorOpen - doorStep)
return {
...state,
doorOpen,
phase: doorOpen <= EPSILON ? (state.targetLevelId ? 'moving' : 'idle') : 'closing',
phaseStartedAt: doorOpen <= EPSILON ? now : state.phaseStartedAt,
}
}
case 'moving': {
if (!targetEntry) {
return {
...state,
targetLevelId: null,
phase: 'idle',
queue: [],
}
}
const speed = Math.max(elevator.speed ?? 2.2, 0.1)
const nextY = moveToward(state.carY, targetEntry.baseY, speed * delta)
const arrived = Math.abs(nextY - targetEntry.baseY) <= EPSILON
return {
...state,
carY: nextY,
currentLevelId: arrived ? (targetEntry.id as AnyNodeId) : state.currentLevelId,
phase: arrived ? 'opening' : 'moving',
phaseStartedAt: arrived ? now : state.phaseStartedAt,
}
}
case 'opening': {
const doorOpen = Math.min(1, state.doorOpen + doorStep)
return {
...state,
doorOpen,
phase: doorOpen >= 1 - EPSILON ? 'open' : 'opening',
phaseStartedAt: doorOpen >= 1 - EPSILON ? now : state.phaseStartedAt,
targetLevelId: doorOpen >= 1 - EPSILON ? null : state.targetLevelId,
queue:
doorOpen >= 1 - EPSILON && state.queue[0] === state.currentLevelId
? state.queue.slice(1)
: state.queue,
}
}
case 'open': {
const elapsed = now - (state.phaseStartedAt ?? now)
if (elapsed < Math.max(elevator.dwellMs ?? 1400, 0)) return state
return {
...state,
phase: 'closing',
phaseStartedAt: now,
targetLevelId: state.queue[0] ?? null,
}
}
}
}
export function stepElevatorRuntimes(now: number, delta: number) {
const nodes = useScene.getState().nodes
const interactive = useInteractive.getState()
for (const elevatorId of Object.keys(interactive.elevators)) {
const typedElevatorId = elevatorId as AnyNodeId
if (nodes[typedElevatorId]?.type !== 'elevator') {
interactive.removeElevator(typedElevatorId)
}
}
for (const node of Object.values(nodes)) {
if (node.type !== 'elevator') continue
const elevatorId = node.id as AnyNodeId
const { entries, defaultEntry } = resolveElevatorLevels(node, nodes)
if (!defaultEntry) continue
const state = useInteractive.getState().elevators[elevatorId]
if (!state) {
useInteractive.getState().initElevator(elevatorId, defaultEntry.id as AnyNodeId, defaultEntry.baseY)
continue
}
const next = stepElevatorRuntimeState({
defaultEntry,
delta,
elevator: node,
entries,
now,
state,
})
if (next !== state) {
useInteractive.getState().setElevatorState(elevatorId, next)
}
}
}
@@ -1,4 +1,12 @@
import type { AnyNode, AnyNodeId, ElevatorNode, LevelNode } from '../../schema' import type { AnyNode, AnyNodeId, CeilingNode, ElevatorNode, LevelNode, WallNode } from '../../schema'
export const DEFAULT_ELEVATOR_LEVEL_HEIGHT = 2.5
export type ElevatorLevelEntry = {
id: LevelNode['id']
label: string
baseY: number
}
function getBuildingLevels(elevator: ElevatorNode, nodes: Record<string, AnyNode>): LevelNode[] { function getBuildingLevels(elevator: ElevatorNode, nodes: Record<string, AnyNode>): LevelNode[] {
const building = const building =
@@ -65,3 +73,78 @@ export function resolveElevatorServiceLevels(
return levels.slice(minIndex, maxIndex + 1) return levels.slice(minIndex, maxIndex + 1)
} }
export function getElevatorLevelHeight(levelId: string, nodes: Record<string, AnyNode>): number {
const level = nodes[levelId as AnyNodeId] as LevelNode | undefined
if (!level || 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') {
const height = (child as CeilingNode).height ?? DEFAULT_ELEVATOR_LEVEL_HEIGHT
if (height > maxTop) maxTop = height
} else if (child.type === 'wall') {
const height = (child as WallNode).height ?? DEFAULT_ELEVATOR_LEVEL_HEIGHT
if (height > maxTop) maxTop = height
}
}
return maxTop > 0 ? maxTop : DEFAULT_ELEVATOR_LEVEL_HEIGHT
}
export function resolveElevatorLevels(
elevator: ElevatorNode,
nodes: Record<string, AnyNode>,
): {
entries: ElevatorLevelEntry[]
defaultEntry: ElevatorLevelEntry | null
shaftBaseY: number
shaftTopY: number
totalHeight: number
} {
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 += getElevatorLevelHeight(level.id, nodes)
}
const serviceLevels = resolveElevatorServiceLevels(elevator, nodes)
const entries = serviceLevels.map((level) => ({
id: level.id,
label: String(level.level),
baseY: baseYByLevelId.get(level.id) ?? 0,
}))
const defaultEntry =
entries.find((entry) => entry.id === elevator.defaultLevelId) ??
entries.find((entry) => entry.id === elevator.fromLevelId) ??
entries[0] ??
null
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,
defaultEntry,
shaftBaseY,
shaftTopY,
totalHeight: Math.max(shaftTopY - shaftBaseY, elevator.cabHeight + 0.3),
}
}
@@ -5,10 +5,23 @@ import {
type AnyNode, type AnyNode,
type AnyNodeId, type AnyNodeId,
type ElevatorNode, type ElevatorNode,
type ElevatorDoorSide,
emitter, emitter,
getElevatorCabCenterZ,
getElevatorCabDepth,
getElevatorCabWidth,
getElevatorDoorLeafSides,
getElevatorDoorLeafWidth,
getElevatorDoorLeafX,
getElevatorShaftDepth,
getElevatorShaftWallThickness,
getElevatorShaftWidth,
getResolvedElevatorDoorStyle,
openElevatorDoor,
resolveElevatorBuildingLevels, resolveElevatorBuildingLevels,
resolveElevatorDispatchTarget, resolveElevatorDispatchTarget,
resolveElevatorServiceLevels, resolveElevatorServiceLevels,
requestElevatorLevel,
sceneRegistry, sceneRegistry,
useInteractive, useInteractive,
useScene, useScene,
@@ -62,7 +75,6 @@ const DOOR_LEAF_INTERACTION_DEPTH = 0.08
const ELEVATOR_RIDE_HORIZONTAL_PADDING = 0.18 const ELEVATOR_RIDE_HORIZONTAL_PADDING = 0.18
const ELEVATOR_COLLIDER_HORIZONTAL_PADDING = 0.14 const ELEVATOR_COLLIDER_HORIZONTAL_PADDING = 0.14
const ELEVATOR_COLLIDER_FLOOR_THICKNESS = 0.08 const ELEVATOR_COLLIDER_FLOOR_THICKNESS = 0.08
const ELEVATOR_COLLIDER_WALL_THICKNESS = 0.16
const ELEVATOR_COLLIDER_DOOR_DEPTH = 0.12 const ELEVATOR_COLLIDER_DOOR_DEPTH = 0.12
const ELEVATOR_ENTRY_DOOR_OPEN_THRESHOLD = 0.72 const ELEVATOR_ENTRY_DOOR_OPEN_THRESHOLD = 0.72
const DEFAULT_ELEVATOR_LEVEL_HEIGHT = 2.5 const DEFAULT_ELEVATOR_LEVEL_HEIGHT = 2.5
@@ -133,9 +145,6 @@ type ElevatorColliderUserData = {
side?: ElevatorDoorSide side?: ElevatorDoorSide
} }
type ElevatorDoorSide = 'left' | 'right'
type ElevatorDoorStyleValue = ElevatorNode['doorStyle']
type ElevatorColliderMesh = Mesh & { type ElevatorColliderMesh = Mesh & {
userData: Mesh['userData'] & ElevatorColliderUserData userData: Mesh['userData'] & ElevatorColliderUserData
} }
@@ -217,67 +226,6 @@ function getInteractableTargetKey(target: FirstPersonInteractableTarget | null)
: `${target.type}:${target.id}` : `${target.type}:${target.id}`
} }
function getResolvedElevatorDoorStyle(
doorStyle: ElevatorDoorStyleValue | undefined,
): ElevatorDoorStyleValue {
return doorStyle ?? 'center-opening'
}
function getElevatorDoorLeafSides(
doorStyle: ElevatorDoorStyleValue | undefined,
): ElevatorDoorSide[] {
const resolvedDoorStyle = getResolvedElevatorDoorStyle(doorStyle)
if (resolvedDoorStyle === 'single-left') return ['left']
if (resolvedDoorStyle === 'single-right') return ['right']
return ['left', 'right']
}
function getElevatorDoorLeafWidth(width: number, doorStyle: ElevatorDoorStyleValue | undefined) {
return getResolvedElevatorDoorStyle(doorStyle) === 'center-opening'
? Math.max(width / 2 - 0.018, 0.12)
: Math.max(width - 0.018, 0.18)
}
function getElevatorDoorLeafX(
side: ElevatorDoorSide,
width: number,
doorOpen: number,
doorStyle: ElevatorDoorStyleValue | undefined,
) {
const resolvedDoorStyle = getResolvedElevatorDoorStyle(doorStyle)
if (resolvedDoorStyle === 'center-opening') {
const direction = side === 'left' ? -1 : 1
return direction * (width / 4 + doorOpen * width * 0.34)
}
const direction = resolvedDoorStyle === 'single-left' ? -1 : 1
return direction * doorOpen * width * 0.68
}
function getElevatorCabWidth(elevator: ElevatorNode) {
return Math.max(elevator.width, 0.8)
}
function getElevatorCabDepth(elevator: ElevatorNode) {
return Math.max(elevator.depth, 0.8)
}
function getElevatorShaftWallThickness(elevator: ElevatorNode) {
return Math.max(elevator.shaftWallThickness ?? ELEVATOR_COLLIDER_WALL_THICKNESS, 0.04)
}
function getElevatorShaftWidth(elevator: ElevatorNode, cabWidth = getElevatorCabWidth(elevator)) {
return Math.max(elevator.shaftWidth ?? cabWidth, cabWidth, 0.8)
}
function getElevatorShaftDepth(elevator: ElevatorNode, cabDepth = getElevatorCabDepth(elevator)) {
return Math.max(elevator.shaftDepth ?? cabDepth, cabDepth, 0.8)
}
function getElevatorCabCenterZ(elevator: ElevatorNode) {
return -getElevatorShaftDepth(elevator) / 2 + getElevatorCabDepth(elevator) / 2
}
function isDynamicElevatorCollider(kind: ElevatorColliderKind) { function isDynamicElevatorCollider(kind: ElevatorColliderKind) {
return kind.startsWith('cab-') || kind.startsWith('landing-door') return kind.startsWith('cab-') || kind.startsWith('landing-door')
} }
@@ -836,7 +784,7 @@ export const FirstPersonControls = () => {
} }
} }
if (target.action === 'open-door') { if (target.action === 'open-door') {
useInteractive.getState().openElevatorDoor(target.id) openElevatorDoor(target.id)
return return
} }
if (target.levelId) { if (target.levelId) {
@@ -849,7 +797,7 @@ export const FirstPersonControls = () => {
requestedElevatorId: target.id, requestedElevatorId: target.id,
}) })
: target.id : target.id
useInteractive.getState().requestElevator(targetElevatorId, target.levelId) requestElevatorLevel(targetElevatorId, target.levelId)
} }
return return
} }
@@ -6,6 +6,7 @@ import {
type ElevatorNode, type ElevatorNode,
ElevatorNode as ElevatorNodeSchema, ElevatorNode as ElevatorNodeSchema,
type LevelNode, type LevelNode,
requestElevatorLevel,
useInteractive, useInteractive,
useLiveNodeOverrides, useLiveNodeOverrides,
useLiveTransforms, useLiveTransforms,
@@ -337,7 +338,7 @@ export function ElevatorPanel() {
(levelId: LevelNode['id']) => { (levelId: LevelNode['id']) => {
if (!node) return if (!node) return
if ((node.disabledLevelIds ?? []).includes(levelId)) return if ((node.disabledLevelIds ?? []).includes(levelId)) return
useInteractive.getState().requestElevator(node.id as AnyNodeId, levelId as AnyNodeId) requestElevatorLevel(node.id as AnyNodeId, levelId as AnyNodeId)
}, },
[node], [node],
) )
@@ -1,7 +1,20 @@
import { import {
type AnyNode, type AnyNode,
type AnyNodeId, type AnyNodeId,
type ElevatorDoorSide,
type ElevatorNode, type ElevatorNode,
getElevatorCabDepth,
getElevatorCabWidth,
getElevatorDoorLeafSides,
getElevatorDoorLeafWidth,
getElevatorDoorLeafX,
getElevatorShaftDepth,
getElevatorShaftWallThickness,
getElevatorShaftWidth,
getResolvedElevatorDoorPanelStyle as getResolvedDoorPanelStyle,
getResolvedElevatorDoorStyle as getResolvedDoorStyle,
getResolvedElevatorShaftStyle as getResolvedShaftStyle,
resolveElevatorLevels,
useInteractive, useInteractive,
useLiveNodeOverrides, useLiveNodeOverrides,
useLiveTransforms, useLiveTransforms,
@@ -21,7 +34,6 @@ import {
} from 'three' } from 'three'
import { useShallow } from 'zustand/react/shallow' import { useShallow } from 'zustand/react/shallow'
import { useNodeEvents } from '../../../hooks/use-node-events' import { useNodeEvents } from '../../../hooks/use-node-events'
import { resolveElevatorLevels } from '../../../systems/elevator/elevator-utils'
const SHAFT_WALL_COLOR = '#d7dce4' const SHAFT_WALL_COLOR = '#d7dce4'
const SHAFT_SIDE_COLOR = '#4b5563' const SHAFT_SIDE_COLOR = '#4b5563'
@@ -30,7 +42,6 @@ const CAB_COLOR = '#d7dde5'
const GLASS_COLOR = '#f8fafc' const GLASS_COLOR = '#f8fafc'
const DOOR_COLOR = '#8e98a6' const DOOR_COLOR = '#8e98a6'
const PANEL_COLOR = '#1f2937' const PANEL_COLOR = '#1f2937'
const DEFAULT_SHAFT_WALL_THICKNESS = 0.09
type Vector3Tuple = [number, number, number] type Vector3Tuple = [number, number, number]
@@ -41,10 +52,8 @@ const BUTTON_RING_GEOMETRY = new TorusGeometry(1.12, 0.12, 8, 24)
const LABEL_MATRIX_DUMMY = new Object3D() const LABEL_MATRIX_DUMMY = new Object3D()
const SHAFT_TOP_FRAME_CLEARANCE = 0.006 const SHAFT_TOP_FRAME_CLEARANCE = 0.006
type ElevatorDoorSide = 'left' | 'right'
type ElevatorDoorPanelStyleValue = ElevatorNode['doorPanelStyle'] type ElevatorDoorPanelStyleValue = ElevatorNode['doorPanelStyle']
type ElevatorDoorStyleValue = ElevatorNode['doorStyle'] type ElevatorDoorStyleValue = ElevatorNode['doorStyle']
type ElevatorShaftStyleValue = ElevatorNode['shaftStyle']
const SHAFT_WALL_MATERIAL = new MeshStandardMaterial({ const SHAFT_WALL_MATERIAL = new MeshStandardMaterial({
color: SHAFT_WALL_COLOR, color: SHAFT_WALL_COLOR,
@@ -611,78 +620,6 @@ function ElevatorMeshButton({
) )
} }
function getResolvedDoorStyle(
doorStyle: ElevatorDoorStyleValue | undefined,
): ElevatorDoorStyleValue {
return doorStyle ?? 'center-opening'
}
function getResolvedDoorPanelStyle(
doorPanelStyle: ElevatorDoorPanelStyleValue | undefined,
): ElevatorDoorPanelStyleValue {
return doorPanelStyle ?? 'glass-frame'
}
function getResolvedShaftStyle(
shaftStyle: ElevatorShaftStyleValue | undefined,
): ElevatorShaftStyleValue {
return shaftStyle ?? 'solid'
}
function getElevatorDoorLeafSides(
doorStyle: ElevatorDoorStyleValue | undefined,
): ElevatorDoorSide[] {
const resolvedDoorStyle = getResolvedDoorStyle(doorStyle)
if (resolvedDoorStyle === 'single-left') return ['left']
if (resolvedDoorStyle === 'single-right') return ['right']
return ['left', 'right']
}
function getElevatorDoorLeafX(
side: ElevatorDoorSide,
openingWidth: number,
doorOpen: number,
doorStyle: ElevatorDoorStyleValue | undefined,
) {
const resolvedDoorStyle = getResolvedDoorStyle(doorStyle)
if (resolvedDoorStyle === 'center-opening') {
const direction = side === 'left' ? -1 : 1
return direction * (openingWidth / 4 + doorOpen * openingWidth * 0.34)
}
const direction = resolvedDoorStyle === 'single-left' ? -1 : 1
return direction * doorOpen * openingWidth * 0.68
}
function getElevatorDoorLeafWidth(
openingWidth: number,
doorStyle: ElevatorDoorStyleValue | undefined,
) {
return getResolvedDoorStyle(doorStyle) === 'center-opening'
? Math.max(openingWidth / 2 - 0.018, 0.12)
: Math.max(openingWidth - 0.018, 0.18)
}
function getElevatorCabWidth(node: ElevatorNode) {
return Math.max(node.width, 0.8)
}
function getElevatorCabDepth(node: ElevatorNode) {
return Math.max(node.depth, 0.8)
}
function getElevatorShaftWallThickness(node: ElevatorNode) {
return Math.max(node.shaftWallThickness ?? DEFAULT_SHAFT_WALL_THICKNESS, 0.04)
}
function getElevatorShaftWidth(node: ElevatorNode, cabWidth = getElevatorCabWidth(node)) {
return Math.max(node.shaftWidth ?? cabWidth, cabWidth, 0.8)
}
function getElevatorShaftDepth(node: ElevatorNode, cabDepth = getElevatorCabDepth(node)) {
return Math.max(node.shaftDepth ?? cabDepth, cabDepth, 0.8)
}
function getElevatorLevelContextNodes( function getElevatorLevelContextNodes(
elevator: ElevatorNode, elevator: ElevatorNode,
nodes: ReturnType<typeof useScene.getState>['nodes'], nodes: ReturnType<typeof useScene.getState>['nodes'],
@@ -1,6 +1,6 @@
'use client' 'use client'
import { ElevatorOpeningSystem } from '@pascal-app/core' import { ElevatorOpeningSystem, ElevatorRuntimeSystem } from '@pascal-app/core'
import { Canvas, extend, type ThreeToJSXElements, useFrame, useThree } from '@react-three/fiber' import { Canvas, extend, type ThreeToJSXElements, useFrame, useThree } from '@react-three/fiber'
import { useEffect, useMemo, useRef } from 'react' import { useEffect, useMemo, useRef } from 'react'
import * as THREE from 'three/webgpu' import * as THREE from 'three/webgpu'
@@ -8,7 +8,6 @@ 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 { DoorAnimationSystem } from '../../systems/door/door-animation-system'
import { DoorSystem } from '../../systems/door/door-system' import { DoorSystem } from '../../systems/door/door-system'
import { ElevatorAnimationSystem } from '../../systems/elevator/elevator-animation-system'
import { ElevatorInteractionSystem } from '../../systems/elevator/elevator-interaction-system' import { ElevatorInteractionSystem } from '../../systems/elevator/elevator-interaction-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,7 +224,7 @@ const Viewer: React.FC<ViewerProps> = ({
{/* Core systems */} {/* Core systems */}
<CeilingSystem /> <CeilingSystem />
<DoorAnimationSystem /> <DoorAnimationSystem />
<ElevatorAnimationSystem /> <ElevatorRuntimeSystem />
<ElevatorInteractionSystem /> <ElevatorInteractionSystem />
<ElevatorOpeningSystem /> <ElevatorOpeningSystem />
<WindowAnimationSystem /> <WindowAnimationSystem />
@@ -1,162 +0,0 @@
import {
type AnyNodeId,
type ElevatorNode,
sceneRegistry,
useInteractive,
useScene,
} from '@pascal-app/core'
import { useFrame } from '@react-three/fiber'
import { resolveElevatorLevels } from './elevator-utils'
const EPSILON = 0.001
function moveToward(current: number, target: number, maxDelta: number) {
const delta = target - current
if (Math.abs(delta) <= maxDelta) return target
return current + Math.sign(delta) * maxDelta
}
export function ElevatorAnimationSystem() {
useFrame(({ clock }, delta) => {
const interactive = useInteractive.getState()
const nodes = useScene.getState().nodes
const now = clock.getElapsedTime() * 1000
for (const elevatorId of Object.keys(interactive.elevators)) {
const typedElevatorId = elevatorId as AnyNodeId
if (nodes[typedElevatorId]?.type !== 'elevator') {
interactive.removeElevator(typedElevatorId)
}
}
for (const elevatorId of sceneRegistry.byType.elevator) {
const typedElevatorId = elevatorId as AnyNodeId
const node = nodes[typedElevatorId]
if (node?.type !== 'elevator') {
interactive.removeElevator(typedElevatorId)
continue
}
const elevator = node as ElevatorNode
const { entries, defaultEntry } = resolveElevatorLevels(elevator, nodes)
if (!defaultEntry) continue
const state = interactive.elevators[typedElevatorId]
if (!state) {
interactive.initElevator(typedElevatorId, defaultEntry.id as AnyNodeId, defaultEntry.baseY)
continue
}
const currentEntry =
entries.find((entry) => entry.id === state.currentLevelId) ?? defaultEntry
if (currentEntry.id !== state.currentLevelId) {
interactive.setElevatorState(typedElevatorId, {
currentLevelId: currentEntry.id as AnyNodeId,
carY: currentEntry.baseY,
targetLevelId: null,
phase: 'idle',
phaseStartedAt: null,
queue: [],
doorOpen: 0,
})
continue
}
const targetEntry = state.targetLevelId
? entries.find((entry) => entry.id === state.targetLevelId)
: state.queue[0]
? entries.find((entry) => entry.id === state.queue[0])
: null
const doorDurationMs = Math.max(elevator.doorDurationMs ?? 900, 1)
const doorStep = (delta * 1000) / doorDurationMs
switch (state.phase) {
case 'idle': {
const nextLevelId = state.queue[0] ?? null
if (!nextLevelId) {
if (state.doorOpen > EPSILON) {
interactive.setElevatorState(typedElevatorId, {
doorOpen: Math.max(0, state.doorOpen - doorStep),
})
}
break
}
interactive.setElevatorState(typedElevatorId, {
targetLevelId: nextLevelId,
phase:
state.doorOpen > EPSILON
? 'closing'
: nextLevelId === state.currentLevelId
? 'opening'
: 'moving',
phaseStartedAt: now,
})
break
}
case 'closing': {
const doorOpen = Math.max(0, state.doorOpen - doorStep)
interactive.setElevatorState(typedElevatorId, {
doorOpen,
phase: doorOpen <= EPSILON ? (state.targetLevelId ? 'moving' : 'idle') : 'closing',
phaseStartedAt: doorOpen <= EPSILON ? now : state.phaseStartedAt,
})
break
}
case 'moving': {
if (!targetEntry) {
interactive.setElevatorState(typedElevatorId, {
targetLevelId: null,
phase: 'idle',
queue: [],
})
break
}
const speed = Math.max(elevator.speed ?? 2.2, 0.1)
const nextY = moveToward(state.carY, targetEntry.baseY, speed * delta)
const arrived = Math.abs(nextY - targetEntry.baseY) <= EPSILON
interactive.setElevatorState(typedElevatorId, {
carY: nextY,
currentLevelId: arrived ? (targetEntry.id as AnyNodeId) : state.currentLevelId,
phase: arrived ? 'opening' : 'moving',
phaseStartedAt: arrived ? now : state.phaseStartedAt,
})
break
}
case 'opening': {
const doorOpen = Math.min(1, state.doorOpen + doorStep)
interactive.setElevatorState(typedElevatorId, {
doorOpen,
phase: doorOpen >= 1 - EPSILON ? 'open' : 'opening',
phaseStartedAt: doorOpen >= 1 - EPSILON ? now : state.phaseStartedAt,
targetLevelId: doorOpen >= 1 - EPSILON ? null : state.targetLevelId,
queue:
doorOpen >= 1 - EPSILON && state.queue[0] === state.currentLevelId
? state.queue.slice(1)
: state.queue,
})
break
}
case 'open': {
const elapsed = now - (state.phaseStartedAt ?? now)
if (elapsed < Math.max(elevator.dwellMs ?? 1400, 0)) break
interactive.setElevatorState(typedElevatorId, {
phase: 'closing',
phaseStartedAt: now,
targetLevelId: state.queue[0] ?? null,
})
break
}
}
}
}, 2)
return null
}
@@ -1,6 +1,8 @@
import { import {
type AnyNodeId, type AnyNodeId,
openElevatorDoor,
resolveElevatorDispatchTarget, resolveElevatorDispatchTarget,
requestElevatorLevel,
useInteractive, useInteractive,
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
@@ -62,7 +64,7 @@ export function ElevatorInteractionSystem() {
event.stopPropagation() event.stopPropagation()
if (button.action === 'open-door') { if (button.action === 'open-door') {
useInteractive.getState().openElevatorDoor(button.elevatorId) openElevatorDoor(button.elevatorId)
return return
} }
@@ -78,7 +80,7 @@ export function ElevatorInteractionSystem() {
}) })
: button.elevatorId : button.elevatorId
useInteractive.getState().requestElevator(targetElevatorId, button.levelId) requestElevatorLevel(targetElevatorId, button.levelId)
} }
canvas.addEventListener('pointerdown', handlePointerDown, true) canvas.addEventListener('pointerdown', handlePointerDown, true)
@@ -1,68 +0,0 @@
import {
resolveElevatorBuildingLevels,
resolveElevatorServiceLevels,
type AnyNode,
type AnyNodeId,
type ElevatorNode,
type LevelNode,
} from '@pascal-app/core'
import { getLevelHeight } from '../level/level-utils'
export type ElevatorLevelEntry = {
id: LevelNode['id']
label: string
baseY: number
}
export function resolveElevatorLevels(
elevator: ElevatorNode,
nodes: Record<AnyNodeId, AnyNode>,
): {
entries: ElevatorLevelEntry[]
defaultEntry: ElevatorLevelEntry | null
shaftBaseY: number
shaftTopY: number
totalHeight: number
} {
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 += getLevelHeight(level.id, nodes)
}
const serviceLevels = resolveElevatorServiceLevels(elevator, nodes)
const entries = serviceLevels.map((level) => ({
id: level.id,
label: String(level.level),
baseY: baseYByLevelId.get(level.id) ?? 0,
}))
const defaultEntry =
entries.find((entry) => entry.id === elevator.defaultLevelId) ??
entries.find((entry) => entry.id === elevator.fromLevelId) ??
entries[0] ??
null
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,
defaultEntry,
shaftBaseY,
shaftTopY,
totalHeight: Math.max(shaftTopY - shaftBaseY, elevator.cabHeight + 0.3),
}
}