322 lines
8.2 KiB
TypeScript
322 lines
8.2 KiB
TypeScript
'use client'
|
|
|
|
import { create } from 'zustand'
|
|
import type { Interactive } from '../schema/nodes/item'
|
|
import type { AnyNodeId } from '../schema/types'
|
|
|
|
// Runtime value for each control (matches discriminated union kinds)
|
|
export type ControlValue = boolean | number
|
|
|
|
export type ItemInteractiveState = {
|
|
// Indexed by control position in asset.interactive.controls[]
|
|
controlValues: ControlValue[]
|
|
}
|
|
|
|
export type DoorInteractiveState = {
|
|
operationState?: number
|
|
swingAngle?: number
|
|
}
|
|
|
|
export type DoorAnimationState = {
|
|
field: keyof DoorInteractiveState
|
|
from: number
|
|
to: number
|
|
startedAt: number | null
|
|
durationMs: number
|
|
persist: boolean
|
|
}
|
|
|
|
export type WindowInteractiveState = {
|
|
operationState?: number
|
|
}
|
|
|
|
export type WindowAnimationState = {
|
|
field: keyof WindowInteractiveState
|
|
from: number
|
|
to: number
|
|
startedAt: number | null
|
|
durationMs: number
|
|
persist: boolean
|
|
}
|
|
|
|
export type ElevatorPhase = 'idle' | 'closing' | 'moving' | 'opening' | 'open'
|
|
|
|
export type ElevatorInteractiveState = {
|
|
currentLevelId: AnyNodeId | null
|
|
targetLevelId: AnyNodeId | null
|
|
carY: number
|
|
doorOpen: number
|
|
phase: ElevatorPhase
|
|
phaseStartedAt: number | null
|
|
queue: AnyNodeId[]
|
|
}
|
|
|
|
type InteractiveStore = {
|
|
items: Record<AnyNodeId, ItemInteractiveState>
|
|
doors: Record<AnyNodeId, DoorInteractiveState>
|
|
doorAnimations: Record<AnyNodeId, DoorAnimationState>
|
|
windows: Record<AnyNodeId, WindowInteractiveState>
|
|
windowAnimations: Record<AnyNodeId, WindowAnimationState>
|
|
elevators: Record<AnyNodeId, ElevatorInteractiveState>
|
|
|
|
/** Initialize a node's interactive state from its asset definition (idempotent) */
|
|
initItem: (itemId: AnyNodeId, interactive: Interactive) => void
|
|
|
|
/** Set a single control value */
|
|
setControlValue: (itemId: AnyNodeId, index: number, value: ControlValue) => void
|
|
|
|
/** Remove a node's state (e.g. on unmount) */
|
|
removeItem: (itemId: AnyNodeId) => void
|
|
|
|
/** Set transient door open state without committing it to the scene node */
|
|
setDoorOpenState: (doorId: AnyNodeId, value: DoorInteractiveState) => void
|
|
|
|
/** Clear transient door open state */
|
|
removeDoorOpenState: (doorId: AnyNodeId) => void
|
|
|
|
/** Queue a door animation for the viewer frame loop */
|
|
startDoorAnimation: (doorId: AnyNodeId, value: DoorAnimationState) => void
|
|
|
|
/** Cancel a queued door animation */
|
|
cancelDoorAnimation: (doorId: AnyNodeId) => void
|
|
|
|
/** Set transient window open state without committing it to the scene node */
|
|
setWindowOpenState: (windowId: AnyNodeId, value: WindowInteractiveState) => void
|
|
|
|
/** Clear transient window open state */
|
|
removeWindowOpenState: (windowId: AnyNodeId) => void
|
|
|
|
/** Queue a window animation for the viewer frame loop */
|
|
startWindowAnimation: (windowId: AnyNodeId, value: WindowAnimationState) => void
|
|
|
|
/** Cancel a queued window animation */
|
|
cancelWindowAnimation: (windowId: AnyNodeId) => void
|
|
|
|
/** 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
|
|
|
|
/** Remove elevator runtime state when its renderer unmounts. */
|
|
removeElevator: (elevatorId: AnyNodeId) => void
|
|
}
|
|
|
|
const defaultControlValue = (interactive: Interactive, index: number): ControlValue => {
|
|
const control = interactive.controls[index]
|
|
if (!control) return false
|
|
switch (control.kind) {
|
|
case 'toggle':
|
|
return control.default ?? false
|
|
case 'slider':
|
|
return control.default ?? control.min
|
|
case 'temperature':
|
|
return control.default ?? control.min
|
|
}
|
|
}
|
|
|
|
export const useInteractive = create<InteractiveStore>((set, get) => ({
|
|
items: {},
|
|
doors: {},
|
|
doorAnimations: {},
|
|
windows: {},
|
|
windowAnimations: {},
|
|
elevators: {},
|
|
|
|
initItem: (itemId, interactive) => {
|
|
const { controls } = interactive
|
|
if (controls.length === 0) return
|
|
|
|
// Don't overwrite existing state (idempotent)
|
|
if (get().items[itemId]) return
|
|
|
|
set((state) => ({
|
|
items: {
|
|
...state.items,
|
|
[itemId]: {
|
|
controlValues: controls.map((_, i) => defaultControlValue(interactive, i)),
|
|
},
|
|
},
|
|
}))
|
|
},
|
|
|
|
setControlValue: (itemId, index, value) => {
|
|
set((state) => {
|
|
const item = state.items[itemId]
|
|
if (!item) return state
|
|
const next = [...item.controlValues]
|
|
next[index] = value
|
|
return { items: { ...state.items, [itemId]: { controlValues: next } } }
|
|
})
|
|
},
|
|
|
|
removeItem: (itemId) => {
|
|
set((state) => {
|
|
const { [itemId]: _, ...rest } = state.items
|
|
return { items: rest }
|
|
})
|
|
},
|
|
|
|
setDoorOpenState: (doorId, value) => {
|
|
set((state) => ({
|
|
doors: {
|
|
...state.doors,
|
|
[doorId]: {
|
|
...state.doors[doorId],
|
|
...value,
|
|
},
|
|
},
|
|
}))
|
|
},
|
|
|
|
removeDoorOpenState: (doorId) => {
|
|
set((state) => {
|
|
const { [doorId]: _, ...rest } = state.doors
|
|
return { doors: rest }
|
|
})
|
|
},
|
|
|
|
startDoorAnimation: (doorId, value) => {
|
|
set((state) => ({
|
|
doorAnimations: {
|
|
...state.doorAnimations,
|
|
[doorId]: value,
|
|
},
|
|
}))
|
|
},
|
|
|
|
cancelDoorAnimation: (doorId) => {
|
|
set((state) => {
|
|
const { [doorId]: _, ...rest } = state.doorAnimations
|
|
return { doorAnimations: rest }
|
|
})
|
|
},
|
|
|
|
setWindowOpenState: (windowId, value) => {
|
|
set((state) => ({
|
|
windows: {
|
|
...state.windows,
|
|
[windowId]: {
|
|
...state.windows[windowId],
|
|
...value,
|
|
},
|
|
},
|
|
}))
|
|
},
|
|
|
|
removeWindowOpenState: (windowId) => {
|
|
set((state) => {
|
|
const { [windowId]: _, ...rest } = state.windows
|
|
return { windows: rest }
|
|
})
|
|
},
|
|
|
|
startWindowAnimation: (windowId, value) => {
|
|
set((state) => ({
|
|
windowAnimations: {
|
|
...state.windowAnimations,
|
|
[windowId]: value,
|
|
},
|
|
}))
|
|
},
|
|
|
|
cancelWindowAnimation: (windowId) => {
|
|
set((state) => {
|
|
const { [windowId]: _, ...rest } = state.windowAnimations
|
|
return { windowAnimations: rest }
|
|
})
|
|
},
|
|
|
|
initElevator: (elevatorId, levelId, carY) => {
|
|
if (get().elevators[elevatorId]) return
|
|
|
|
set((state) => ({
|
|
elevators: {
|
|
...state.elevators,
|
|
[elevatorId]: {
|
|
currentLevelId: levelId,
|
|
targetLevelId: null,
|
|
carY,
|
|
doorOpen: 0,
|
|
phase: 'idle',
|
|
phaseStartedAt: null,
|
|
queue: [],
|
|
},
|
|
},
|
|
}))
|
|
},
|
|
|
|
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]
|
|
if (!current) return state
|
|
|
|
return {
|
|
elevators: {
|
|
...state.elevators,
|
|
[elevatorId]: {
|
|
...current,
|
|
...value,
|
|
},
|
|
},
|
|
}
|
|
})
|
|
},
|
|
|
|
removeElevator: (elevatorId) => {
|
|
set((state) => {
|
|
const { [elevatorId]: _, ...rest } = state.elevators
|
|
return { elevators: rest }
|
|
})
|
|
},
|
|
}))
|