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 { clearSceneHistory, default as useScene } from './store/use-scene'
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 { ElevatorOpeningSystem } from './systems/elevator/elevator-opening-system'
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,
resolveElevatorLevels,
resolveElevatorServiceLevelIds,
resolveElevatorServiceLevels,
} from './systems/elevator/elevator-service'
@@ -95,12 +95,6 @@ type InteractiveStore = {
/** Initialize an elevator's runtime state from its default served level. */
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. */
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) => {
set((state) => {
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[] {
const building =
@@ -65,3 +73,78 @@ export function resolveElevatorServiceLevels(
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),
}
}