Refactor elevator runtime into core
This commit is contained in:
@@ -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),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user