diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index aa94614d..9d50139f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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' diff --git a/packages/core/src/store/use-interactive.ts b/packages/core/src/store/use-interactive.ts index 84354557..22997afc 100644 --- a/packages/core/src/store/use-interactive.ts +++ b/packages/core/src/store/use-interactive.ts @@ -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) => void @@ -252,49 +246,6 @@ export const useInteractive = create((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] diff --git a/packages/core/src/systems/elevator/elevator-geometry.ts b/packages/core/src/systems/elevator/elevator-geometry.ts new file mode 100644 index 00000000..c08b296b --- /dev/null +++ b/packages/core/src/systems/elevator/elevator-geometry.ts @@ -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 +} diff --git a/packages/core/src/systems/elevator/elevator-runtime-system.tsx b/packages/core/src/systems/elevator/elevator-runtime-system.tsx new file mode 100644 index 00000000..91c897a2 --- /dev/null +++ b/packages/core/src/systems/elevator/elevator-runtime-system.tsx @@ -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 +} diff --git a/packages/core/src/systems/elevator/elevator-runtime.test.ts b/packages/core/src/systems/elevator/elevator-runtime.test.ts new file mode 100644 index 00000000..959dd681 --- /dev/null +++ b/packages/core/src/systems/elevator/elevator-runtime.test.ts @@ -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([]) + }) +}) diff --git a/packages/core/src/systems/elevator/elevator-runtime.ts b/packages/core/src/systems/elevator/elevator-runtime.ts new file mode 100644 index 00000000..c75a30bc --- /dev/null +++ b/packages/core/src/systems/elevator/elevator-runtime.ts @@ -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, +): 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, +): 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) + } + } +} diff --git a/packages/core/src/systems/elevator/elevator-service.ts b/packages/core/src/systems/elevator/elevator-service.ts index c775d708..59798da5 100644 --- a/packages/core/src/systems/elevator/elevator-service.ts +++ b/packages/core/src/systems/elevator/elevator-service.ts @@ -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): LevelNode[] { const building = @@ -65,3 +73,78 @@ export function resolveElevatorServiceLevels( return levels.slice(minIndex, maxIndex + 1) } + +export function getElevatorLevelHeight(levelId: string, nodes: Record): 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, +): { + entries: ElevatorLevelEntry[] + defaultEntry: ElevatorLevelEntry | null + shaftBaseY: number + shaftTopY: number + totalHeight: number +} { + const allLevels = resolveElevatorBuildingLevels(elevator, nodes) + + const baseYByLevelId = new Map() + 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), + } +} diff --git a/packages/editor/src/components/editor/first-person-controls.tsx b/packages/editor/src/components/editor/first-person-controls.tsx index 02e7a01c..2b0074fd 100644 --- a/packages/editor/src/components/editor/first-person-controls.tsx +++ b/packages/editor/src/components/editor/first-person-controls.tsx @@ -5,10 +5,23 @@ import { type AnyNode, type AnyNodeId, type ElevatorNode, + type ElevatorDoorSide, emitter, + getElevatorCabCenterZ, + getElevatorCabDepth, + getElevatorCabWidth, + getElevatorDoorLeafSides, + getElevatorDoorLeafWidth, + getElevatorDoorLeafX, + getElevatorShaftDepth, + getElevatorShaftWallThickness, + getElevatorShaftWidth, + getResolvedElevatorDoorStyle, + openElevatorDoor, resolveElevatorBuildingLevels, resolveElevatorDispatchTarget, resolveElevatorServiceLevels, + requestElevatorLevel, sceneRegistry, useInteractive, useScene, @@ -62,7 +75,6 @@ const DOOR_LEAF_INTERACTION_DEPTH = 0.08 const ELEVATOR_RIDE_HORIZONTAL_PADDING = 0.18 const ELEVATOR_COLLIDER_HORIZONTAL_PADDING = 0.14 const ELEVATOR_COLLIDER_FLOOR_THICKNESS = 0.08 -const ELEVATOR_COLLIDER_WALL_THICKNESS = 0.16 const ELEVATOR_COLLIDER_DOOR_DEPTH = 0.12 const ELEVATOR_ENTRY_DOOR_OPEN_THRESHOLD = 0.72 const DEFAULT_ELEVATOR_LEVEL_HEIGHT = 2.5 @@ -133,9 +145,6 @@ type ElevatorColliderUserData = { side?: ElevatorDoorSide } -type ElevatorDoorSide = 'left' | 'right' -type ElevatorDoorStyleValue = ElevatorNode['doorStyle'] - type ElevatorColliderMesh = Mesh & { userData: Mesh['userData'] & ElevatorColliderUserData } @@ -217,67 +226,6 @@ function getInteractableTargetKey(target: FirstPersonInteractableTarget | null) : `${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) { return kind.startsWith('cab-') || kind.startsWith('landing-door') } @@ -836,7 +784,7 @@ export const FirstPersonControls = () => { } } if (target.action === 'open-door') { - useInteractive.getState().openElevatorDoor(target.id) + openElevatorDoor(target.id) return } if (target.levelId) { @@ -849,7 +797,7 @@ export const FirstPersonControls = () => { requestedElevatorId: target.id, }) : target.id - useInteractive.getState().requestElevator(targetElevatorId, target.levelId) + requestElevatorLevel(targetElevatorId, target.levelId) } return } diff --git a/packages/editor/src/components/ui/panels/elevator-panel.tsx b/packages/editor/src/components/ui/panels/elevator-panel.tsx index 1e95f00d..02bbc4aa 100644 --- a/packages/editor/src/components/ui/panels/elevator-panel.tsx +++ b/packages/editor/src/components/ui/panels/elevator-panel.tsx @@ -6,6 +6,7 @@ import { type ElevatorNode, ElevatorNode as ElevatorNodeSchema, type LevelNode, + requestElevatorLevel, useInteractive, useLiveNodeOverrides, useLiveTransforms, @@ -337,7 +338,7 @@ export function ElevatorPanel() { (levelId: LevelNode['id']) => { if (!node) 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], ) diff --git a/packages/viewer/src/components/renderers/elevator/elevator-renderer.tsx b/packages/viewer/src/components/renderers/elevator/elevator-renderer.tsx index 8aa0815e..facfe5c8 100644 --- a/packages/viewer/src/components/renderers/elevator/elevator-renderer.tsx +++ b/packages/viewer/src/components/renderers/elevator/elevator-renderer.tsx @@ -1,7 +1,20 @@ import { type AnyNode, type AnyNodeId, + type ElevatorDoorSide, type ElevatorNode, + getElevatorCabDepth, + getElevatorCabWidth, + getElevatorDoorLeafSides, + getElevatorDoorLeafWidth, + getElevatorDoorLeafX, + getElevatorShaftDepth, + getElevatorShaftWallThickness, + getElevatorShaftWidth, + getResolvedElevatorDoorPanelStyle as getResolvedDoorPanelStyle, + getResolvedElevatorDoorStyle as getResolvedDoorStyle, + getResolvedElevatorShaftStyle as getResolvedShaftStyle, + resolveElevatorLevels, useInteractive, useLiveNodeOverrides, useLiveTransforms, @@ -21,7 +34,6 @@ import { } from 'three' import { useShallow } from 'zustand/react/shallow' import { useNodeEvents } from '../../../hooks/use-node-events' -import { resolveElevatorLevels } from '../../../systems/elevator/elevator-utils' const SHAFT_WALL_COLOR = '#d7dce4' const SHAFT_SIDE_COLOR = '#4b5563' @@ -30,7 +42,6 @@ const CAB_COLOR = '#d7dde5' const GLASS_COLOR = '#f8fafc' const DOOR_COLOR = '#8e98a6' const PANEL_COLOR = '#1f2937' -const DEFAULT_SHAFT_WALL_THICKNESS = 0.09 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 SHAFT_TOP_FRAME_CLEARANCE = 0.006 -type ElevatorDoorSide = 'left' | 'right' type ElevatorDoorPanelStyleValue = ElevatorNode['doorPanelStyle'] type ElevatorDoorStyleValue = ElevatorNode['doorStyle'] -type ElevatorShaftStyleValue = ElevatorNode['shaftStyle'] const SHAFT_WALL_MATERIAL = new MeshStandardMaterial({ 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( elevator: ElevatorNode, nodes: ReturnType['nodes'], diff --git a/packages/viewer/src/components/viewer/index.tsx b/packages/viewer/src/components/viewer/index.tsx index 1c8404fd..b620ad9d 100644 --- a/packages/viewer/src/components/viewer/index.tsx +++ b/packages/viewer/src/components/viewer/index.tsx @@ -1,6 +1,6 @@ '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 { useEffect, useMemo, useRef } from 'react' import * as THREE from 'three/webgpu' @@ -8,7 +8,6 @@ import useViewer from '../../store/use-viewer' import { CeilingSystem } from '../../systems/ceiling/ceiling-system' import { DoorAnimationSystem } from '../../systems/door/door-animation-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 { FenceSystem } from '../../systems/fence/fence-system' import { GuideSystem } from '../../systems/guide/guide-system' @@ -225,7 +224,7 @@ const Viewer: React.FC = ({ {/* Core systems */} - + diff --git a/packages/viewer/src/systems/elevator/elevator-animation-system.tsx b/packages/viewer/src/systems/elevator/elevator-animation-system.tsx deleted file mode 100644 index f512579e..00000000 --- a/packages/viewer/src/systems/elevator/elevator-animation-system.tsx +++ /dev/null @@ -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 -} diff --git a/packages/viewer/src/systems/elevator/elevator-interaction-system.tsx b/packages/viewer/src/systems/elevator/elevator-interaction-system.tsx index 447259fb..ded3d499 100644 --- a/packages/viewer/src/systems/elevator/elevator-interaction-system.tsx +++ b/packages/viewer/src/systems/elevator/elevator-interaction-system.tsx @@ -1,6 +1,8 @@ import { type AnyNodeId, + openElevatorDoor, resolveElevatorDispatchTarget, + requestElevatorLevel, useInteractive, useScene, } from '@pascal-app/core' @@ -62,7 +64,7 @@ export function ElevatorInteractionSystem() { event.stopPropagation() if (button.action === 'open-door') { - useInteractive.getState().openElevatorDoor(button.elevatorId) + openElevatorDoor(button.elevatorId) return } @@ -78,7 +80,7 @@ export function ElevatorInteractionSystem() { }) : button.elevatorId - useInteractive.getState().requestElevator(targetElevatorId, button.levelId) + requestElevatorLevel(targetElevatorId, button.levelId) } canvas.addEventListener('pointerdown', handlePointerDown, true) diff --git a/packages/viewer/src/systems/elevator/elevator-utils.ts b/packages/viewer/src/systems/elevator/elevator-utils.ts deleted file mode 100644 index 4c0c03fa..00000000 --- a/packages/viewer/src/systems/elevator/elevator-utils.ts +++ /dev/null @@ -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, -): { - entries: ElevatorLevelEntry[] - defaultEntry: ElevatorLevelEntry | null - shaftBaseY: number - shaftTopY: number - totalHeight: number -} { - const allLevels = resolveElevatorBuildingLevels(elevator, nodes) - - const baseYByLevelId = new Map() - 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), - } -}