item system controls

This commit is contained in:
wass08
2026-03-06 13:08:21 +01:00
parent 927ecba054
commit 54051b11fa
7 changed files with 236 additions and 20 deletions
@@ -1,8 +1,9 @@
import { type AnyNodeId, type ItemNode, useRegistry, useScene } from '@pascal-app/core'
import { type AnimationEffect, type AnyNodeId, type ItemNode, useInteractive, useRegistry, useScene } from '@pascal-app/core'
import { useAnimations } from '@react-three/drei'
import { Clone } from '@react-three/drei/core/Clone'
import { useGLTF } from '@react-three/drei/core/Gltf'
import { Suspense, useEffect, useMemo, useRef } from 'react'
import { useShallow } from 'zustand/react/shallow'
import type { Group, Material, Mesh } from 'three'
import { positionLocal, smoothstep, time } from 'three/tsl'
import { DoubleSide, MeshStandardNodeMaterial } from 'three/webgpu'
@@ -83,12 +84,34 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => {
const { scene, nodes, animations } = useGLTF(resolveCdnUrl(node.asset.src) || '')
const ref = useRef<Group>(null!)
const { actions } = useAnimations(animations, ref)
// Freeze the interactive definition at mount — asset schemas don't change at runtime
const interactiveRef = useRef(node.asset.interactive)
// Subscribe only to this node's control values — useShallow prevents re-renders from other nodes' changes
const controlValues = useInteractive(
useShallow((state) => state.items[node.id]?.controlValues),
)
useEffect(() => {
if (animations.length > 0) {
actions[animations[0]!.name]!.play()
const interactive = interactiveRef.current
const animEffect = interactive?.effects.find((e): e is AnimationEffect => e.kind === 'animation')
if (!animEffect) {
// Non-interactive: play first available animation as default
if (animations.length > 0) actions[animations[0]!.name]?.play()
return
}
}, [actions, animations])
if (!controlValues) return
const toggleIndex = interactive!.controls.findIndex((c) => c.kind === 'toggle')
const isOn = toggleIndex >= 0 ? Boolean(controlValues[toggleIndex]) : false
for (const action of Object.values(actions)) action?.stop()
const clipName = isOn ? animEffect.clips.on : (animEffect.clips.off ?? animEffect.clips.loop)
if (clipName) actions[clipName]?.play()
}, [controlValues, actions, animations])
if (nodes.cutout) {
nodes.cutout.visible = false
@@ -101,6 +124,13 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => {
useScene.getState().dirtyNodes.add(node.parentId as AnyNodeId)
}, [node.parentId])
useEffect(() => {
const interactive = interactiveRef.current
if (!interactive) return
useInteractive.getState().initItem(node.id, interactive)
return () => useInteractive.getState().removeItem(node.id)
}, [node.id])
useMemo(() => {
scene.traverse((child) => {
if ((child as Mesh).isMesh) {
+1
View File
@@ -1,4 +1,5 @@
export { default as Viewer } from './components/viewer'
export { default as useViewer } from './store/use-viewer'
export { ASSETS_CDN_URL, resolveAssetUrl, resolveCdnUrl } from './lib/asset-url'
export { InteractiveSystem } from './systems/interactive/interactive-system'
export { snapLevelsToTruePositions } from './systems/level/level-utils'
@@ -0,0 +1,168 @@
'use client'
import {
type AnyNodeId,
type Control,
type ControlValue,
type ItemNode,
sceneRegistry,
useInteractive,
useScene,
} from '@pascal-app/core'
import { Html } from '@react-three/drei'
import { createPortal, useFrame } from '@react-three/fiber'
import { useState } from 'react'
import type { Object3D } from 'three'
import { useShallow } from 'zustand/react/shallow'
// ---- Parent: one overlay per interactive item ----
export const InteractiveSystem = () => {
const interactiveNodeIds = useScene(
useShallow((state) =>
Object.values(state.nodes)
.filter((n): n is ItemNode => n.type === 'item' && n.asset.interactive != null)
.map((n) => n.id),
),
)
return (
<>
{interactiveNodeIds.map((id) => (
<ItemControlsOverlay key={id} nodeId={id} />
))}
</>
)
}
// ---- Child: polls sceneRegistry then portals controls into the item group ----
const ItemControlsOverlay = ({ nodeId }: { nodeId: AnyNodeId }) => {
const node = useScene((state) => state.nodes[nodeId] as ItemNode)
const [itemObj, setItemObj] = useState<Object3D | null>(null)
useFrame(() => {
if (itemObj) return
const obj = sceneRegistry.nodes.get(nodeId)
if (obj) setItemObj(obj)
})
const controlValues = useInteractive(useShallow((state) => state.items[nodeId]?.controlValues))
const setControlValue = useInteractive((state) => state.setControlValue)
if (!itemObj || !controlValues || !node?.asset.interactive) return null
const { controls } = node.asset.interactive
const [, height] = node.asset.dimensions
return createPortal(
<Html center position={[0, height + 0.3, 0]} zIndexRange={[20, 0]} occlude distanceFactor={8}>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 6,
background: 'rgba(0,0,0,0.75)',
backdropFilter: 'blur(8px)',
borderRadius: 8,
padding: '8px 12px',
minWidth: 120,
pointerEvents: 'auto',
userSelect: 'none',
}}
>
{controls.map((control, i) => (
<ControlWidget
key={i}
control={control}
value={controlValues[i] ?? false}
onChange={(v) => setControlValue(nodeId, i, v)}
/>
))}
</div>
</Html>,
itemObj,
)
}
// ---- Control widgets ----
const ControlWidget = ({
control,
value,
onChange,
}: {
control: Control
value: ControlValue
onChange: (v: ControlValue) => void
}) => {
const labelStyle: React.CSSProperties = {
color: 'white',
fontSize: 11,
fontFamily: 'monospace',
display: 'flex',
flexDirection: 'column',
gap: 2,
}
if (control.kind === 'toggle') {
return (
<button
onClick={() => onChange(!value)}
style={{
background: value ? '#4ade80' : '#374151',
color: 'white',
border: 'none',
borderRadius: 4,
padding: '4px 8px',
cursor: 'pointer',
fontSize: 12,
fontFamily: 'monospace',
transition: 'background 0.2s',
}}
>
{control.label ?? (value ? 'On' : 'Off')}
</button>
)
}
if (control.kind === 'slider') {
return (
<label style={labelStyle}>
<span>
{control.label}: {value}
{control.unit ? ` ${control.unit}` : ''}
</span>
<input
type="range"
min={control.min}
max={control.max}
step={control.step}
value={value as number}
onChange={(e) => onChange(Number(e.target.value))}
/>
</label>
)
}
if (control.kind === 'temperature') {
return (
<label style={labelStyle}>
<span>
{control.label}: {value}°{control.unit}
</span>
<input
type="range"
min={control.min}
max={control.max}
step={1}
value={value as number}
onChange={(e) => onChange(Number(e.target.value))}
/>
</label>
)
}
return null
}