@linxai/3d-shared 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +41 -0
- package/src/components/SceneBuilder.tsx +34 -0
- package/src/components/SceneContent.tsx +63 -0
- package/src/contexts/LoadingContext.tsx +46 -0
- package/src/hooks/useAnimationControl.ts +145 -0
- package/src/hooks/useAnimationLoader.ts +149 -0
- package/src/hooks/useAnimationStateMachine.ts +229 -0
- package/src/hooks/useCharacterController.ts +203 -0
- package/src/hooks/useModelLoader.ts +115 -0
- package/src/hooks/useMorphControl.ts +46 -0
- package/src/hooks/useProceduralAnimations.ts +91 -0
- package/src/hooks/useTalkingControl.ts +109 -0
- package/src/index.ts +32 -0
- package/src/internal.ts +52 -0
- package/src/types/character.ts +136 -0
- package/src/types/index.ts +197 -0
- package/src/utils/characterUtils.ts +156 -0
- package/tsconfig.json +23 -0
- package/tsup.config.ts +13 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { useEffect, useRef, useCallback, useState } from 'react'
|
|
2
|
+
import { Bone } from 'three'
|
|
3
|
+
import { useFrame } from '@react-three/fiber'
|
|
4
|
+
import type { ModelLoadConfig, AnimationStatesConfig, CharacterInternal, Character } from '../types'
|
|
5
|
+
import type { ModelLoaderAdapter } from './useModelLoader'
|
|
6
|
+
import type { AnimationLoaderAdapter } from './useAnimationLoader'
|
|
7
|
+
import { useModelLoader } from './useModelLoader'
|
|
8
|
+
import { useAnimationLoader } from './useAnimationLoader'
|
|
9
|
+
import { useAnimationControl } from './useAnimationControl'
|
|
10
|
+
import { useAnimationStateMachine } from './useAnimationStateMachine'
|
|
11
|
+
import { useMorphControl } from './useMorphControl'
|
|
12
|
+
import { useProceduralAnimations } from './useProceduralAnimations'
|
|
13
|
+
import { useTalkingControl } from './useTalkingControl'
|
|
14
|
+
import { findHeadBone } from '../utils/characterUtils'
|
|
15
|
+
|
|
16
|
+
export interface UseCharacterControllerOptions {
|
|
17
|
+
/** 模型配置 */
|
|
18
|
+
model: ModelLoadConfig
|
|
19
|
+
/** 模型加载适配器 */
|
|
20
|
+
modelAdapter: ModelLoaderAdapter
|
|
21
|
+
/** 动画加载适配器 */
|
|
22
|
+
animationAdapter: AnimationLoaderAdapter
|
|
23
|
+
/** 动画状态机配置(可选) */
|
|
24
|
+
animationStates?: AnimationStatesConfig
|
|
25
|
+
/** 初始状态(默认 'idle') */
|
|
26
|
+
initialState?: string
|
|
27
|
+
/** 初始情绪 */
|
|
28
|
+
initialMood?: string
|
|
29
|
+
/** 是否启用 welcome 状态 */
|
|
30
|
+
enableWelcome?: boolean
|
|
31
|
+
/** 错误回调 */
|
|
32
|
+
onError?: (error: Error) => void
|
|
33
|
+
/** 状态变化回调 */
|
|
34
|
+
onStateChange?: (state: string, mood: string) => void
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Character 控制器核心 Hook
|
|
39
|
+
* 处理模型/动画加载、AnimationMixer 管理、Character 对象构建
|
|
40
|
+
* 支持基础动画控制和状态机控制
|
|
41
|
+
*
|
|
42
|
+
* @param options - 配置选项
|
|
43
|
+
* @returns Character 对象和加载状态
|
|
44
|
+
*/
|
|
45
|
+
export function useCharacterController(options: UseCharacterControllerOptions): {
|
|
46
|
+
loadedModel: any
|
|
47
|
+
character: CharacterInternal | null
|
|
48
|
+
isReady: boolean
|
|
49
|
+
} {
|
|
50
|
+
const {
|
|
51
|
+
model,
|
|
52
|
+
modelAdapter,
|
|
53
|
+
animationAdapter,
|
|
54
|
+
animationStates,
|
|
55
|
+
initialState = 'idle',
|
|
56
|
+
initialMood = 'neutral',
|
|
57
|
+
enableWelcome = false,
|
|
58
|
+
onError,
|
|
59
|
+
onStateChange
|
|
60
|
+
} = options
|
|
61
|
+
|
|
62
|
+
// 加载模型和动画
|
|
63
|
+
const { model: loadedModel, state } = useModelLoader(model, modelAdapter)
|
|
64
|
+
const { clips, animationsMap, state: animationState } = useAnimationLoader(model.animations, animationAdapter)
|
|
65
|
+
|
|
66
|
+
const headBoneRef = useRef<Bone | null>(null)
|
|
67
|
+
const [character, setCharacter] = useState<CharacterInternal | null>(null)
|
|
68
|
+
|
|
69
|
+
// 使用子 hooks 管理各个功能模块(注意顺序:morphControl 必须在 stateMachine 之前)
|
|
70
|
+
const animationControl = useAnimationControl({
|
|
71
|
+
model: loadedModel,
|
|
72
|
+
animationsMap,
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
const morphControl = useMorphControl(loadedModel)
|
|
76
|
+
const proceduralAnimations = useProceduralAnimations()
|
|
77
|
+
|
|
78
|
+
const stateMachine = useAnimationStateMachine({
|
|
79
|
+
animationStates,
|
|
80
|
+
initialState,
|
|
81
|
+
initialMood,
|
|
82
|
+
enableWelcome,
|
|
83
|
+
playAnimation: animationControl.play,
|
|
84
|
+
mixer: animationControl.mixer,
|
|
85
|
+
applyEmotion: morphControl.applyEmotion,
|
|
86
|
+
onStateChange,
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
// 用于记录当前状态下的动画列表和索引(用于顺序播放)
|
|
90
|
+
const currentAnimationListRef = useRef<string[]>([])
|
|
91
|
+
const currentAnimationIndexRef = useRef<number>(0)
|
|
92
|
+
|
|
93
|
+
// 用于记录目标状态(welcome 结束后要切换到的状态)
|
|
94
|
+
const targetStateAfterWelcomeRef = useRef<string>(initialState)
|
|
95
|
+
|
|
96
|
+
// 说话控制(需要在 stateMachine 之后初始化)
|
|
97
|
+
const talkingControl = useTalkingControl({
|
|
98
|
+
animationStates,
|
|
99
|
+
morphMeshes: morphControl.morphMeshes,
|
|
100
|
+
playAnimation: animationControl.play,
|
|
101
|
+
setState: stateMachine.setState,
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
// 处理错误
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
if (state.error && onError) {
|
|
107
|
+
onError(state.error)
|
|
108
|
+
}
|
|
109
|
+
if (animationState.error && onError) {
|
|
110
|
+
onError(animationState.error)
|
|
111
|
+
}
|
|
112
|
+
}, [state.error, animationState.error, onError])
|
|
113
|
+
|
|
114
|
+
// 初始化头部骨骼
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
if (loadedModel?.scene) {
|
|
117
|
+
headBoneRef.current = findHeadBone(loadedModel)
|
|
118
|
+
|
|
119
|
+
return () => {
|
|
120
|
+
headBoneRef.current = null
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}, [loadedModel, morphControl.morphMeshes.length, animationControl.mixer])
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
// 资源加载完成后初始化(只执行一次)
|
|
127
|
+
useEffect(() => {
|
|
128
|
+
console.log('[useCharacterController] Check:', {
|
|
129
|
+
hasCharacter: !!character,
|
|
130
|
+
hasModel: !!loadedModel,
|
|
131
|
+
hasMixer: !!animationControl.mixer,
|
|
132
|
+
hasAnimationsMap: !!animationsMap,
|
|
133
|
+
animationsMapSize: animationsMap?.size,
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
if (character) return // 已经初始化过了
|
|
137
|
+
if (!loadedModel || !animationControl.mixer) return
|
|
138
|
+
|
|
139
|
+
// 检查动画是否需要加载
|
|
140
|
+
const hasAnimations = model.animations && model.animations.length > 0
|
|
141
|
+
if (hasAnimations && (!animationsMap || animationsMap.size === 0)) return
|
|
142
|
+
|
|
143
|
+
console.log('[useCharacterController] ✅ Creating character...')
|
|
144
|
+
|
|
145
|
+
// 构建 Character 对象(极简公共接口 + 完整内部接口)
|
|
146
|
+
const characterObj: CharacterInternal = {
|
|
147
|
+
model: loadedModel,
|
|
148
|
+
clips,
|
|
149
|
+
animationsMap,
|
|
150
|
+
getClip: (id: string) => animationsMap?.get(id) || null,
|
|
151
|
+
mixer: animationControl.mixer,
|
|
152
|
+
|
|
153
|
+
// 公共 API(仅 4 个核心方法)
|
|
154
|
+
setState: stateMachine.setState,
|
|
155
|
+
getState: stateMachine.getCurrentState,
|
|
156
|
+
startSpeak: talkingControl.startSpeak,
|
|
157
|
+
stopSpeak: talkingControl.stopSpeak,
|
|
158
|
+
|
|
159
|
+
// 内部 API(不对外暴露,但内部可用)
|
|
160
|
+
play: animationControl.play,
|
|
161
|
+
stop: animationControl.stop,
|
|
162
|
+
pause: animationControl.pause,
|
|
163
|
+
resume: animationControl.resume,
|
|
164
|
+
getCurrentAnimation: animationControl.getCurrentAnimation,
|
|
165
|
+
isPlaying: animationControl.isPlaying,
|
|
166
|
+
isSpeaking: talkingControl.isSpeaking,
|
|
167
|
+
setMood: stateMachine.setMood,
|
|
168
|
+
applyEmotion: morphControl.applyEmotion,
|
|
169
|
+
setMorphWeight: morphControl.setMorphWeight,
|
|
170
|
+
setDualMorph: morphControl.setDualMorph,
|
|
171
|
+
setHeadSwayEnabled: proceduralAnimations.setHeadSwayEnabled,
|
|
172
|
+
setBlinkingEnabled: proceduralAnimations.setBlinkingEnabled,
|
|
173
|
+
setLipSyncEnabled: proceduralAnimations.setLipSyncEnabled,
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// 初始化状态机
|
|
177
|
+
stateMachine.initialize()
|
|
178
|
+
|
|
179
|
+
// 设置 character(这会触发 isReady = true)
|
|
180
|
+
setCharacter(characterObj)
|
|
181
|
+
console.log('[useCharacterController] ✅ Character created and set!')
|
|
182
|
+
}, [loadedModel, animationControl.mixer, animationsMap, character])
|
|
183
|
+
|
|
184
|
+
// 更新程序化动画
|
|
185
|
+
useFrame((_, delta) => {
|
|
186
|
+
// 更新程序化动画
|
|
187
|
+
proceduralAnimations.update(delta, {
|
|
188
|
+
headBone: headBoneRef.current,
|
|
189
|
+
morphMeshes: morphControl.morphMeshes,
|
|
190
|
+
emotion: talkingControl.getCurrentEmotion(),
|
|
191
|
+
isSpeaking: talkingControl.isSpeaking(),
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
// 更新说话手势调度
|
|
195
|
+
talkingControl.updateTalkGestures(delta)
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
loadedModel,
|
|
200
|
+
character,
|
|
201
|
+
isReady: !!character,
|
|
202
|
+
}
|
|
203
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react'
|
|
2
|
+
import type { ModelLoadConfig, SceneState } from '../types'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 平台特定的模型加载适配器
|
|
6
|
+
* 平台包提供具体的加载实现
|
|
7
|
+
*/
|
|
8
|
+
export interface ModelLoaderAdapter {
|
|
9
|
+
/**
|
|
10
|
+
* 加载模型文件
|
|
11
|
+
* @param path 模型文件路径
|
|
12
|
+
* @param onProgress 进度回调
|
|
13
|
+
* @returns Promise<GLTF 对象>
|
|
14
|
+
*/
|
|
15
|
+
load: (
|
|
16
|
+
path: string | number,
|
|
17
|
+
onProgress?: (progress: number) => void
|
|
18
|
+
) => Promise<any> // GLTF
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 使用模型加载器的 Hook
|
|
23
|
+
* 在 shared 中实现核心逻辑,使用平台适配器加载
|
|
24
|
+
*/
|
|
25
|
+
export function useModelLoader(
|
|
26
|
+
config: ModelLoadConfig,
|
|
27
|
+
adapter: ModelLoaderAdapter
|
|
28
|
+
): { model: any | null; state: SceneState } {
|
|
29
|
+
const [state, setState] = useState<SceneState>({
|
|
30
|
+
loaded: false,
|
|
31
|
+
progress: 0,
|
|
32
|
+
})
|
|
33
|
+
const [model, setModel] = useState<any | null>(null)
|
|
34
|
+
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
let mounted = true
|
|
37
|
+
|
|
38
|
+
const loadModel = async () => {
|
|
39
|
+
try {
|
|
40
|
+
setState({ loaded: false, progress: 0 })
|
|
41
|
+
|
|
42
|
+
const gltf = await adapter.load(config.path, (progress) => {
|
|
43
|
+
if (mounted) {
|
|
44
|
+
setState((prev) => ({
|
|
45
|
+
...prev,
|
|
46
|
+
progress,
|
|
47
|
+
}))
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
if (mounted) {
|
|
52
|
+
setModel(gltf)
|
|
53
|
+
setState({
|
|
54
|
+
loaded: true,
|
|
55
|
+
progress: 1,
|
|
56
|
+
error: null,
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
} catch (error) {
|
|
60
|
+
if (mounted) {
|
|
61
|
+
setState({
|
|
62
|
+
loaded: false,
|
|
63
|
+
progress: 0,
|
|
64
|
+
error: error as Error,
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
loadModel()
|
|
71
|
+
|
|
72
|
+
return () => {
|
|
73
|
+
mounted = false
|
|
74
|
+
}
|
|
75
|
+
}, [config.path, adapter])
|
|
76
|
+
|
|
77
|
+
// 应用配置(缩放、位置、旋转等)
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
if (model?.scene) {
|
|
80
|
+
const { scale, position, rotation, castShadow, receiveShadow } = config
|
|
81
|
+
|
|
82
|
+
if (scale) {
|
|
83
|
+
if (Array.isArray(scale)) {
|
|
84
|
+
model.scene.scale.set(...scale)
|
|
85
|
+
} else {
|
|
86
|
+
model.scene.scale.setScalar(scale)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (position) {
|
|
91
|
+
model.scene.position.set(...position)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (rotation) {
|
|
95
|
+
model.scene.rotation.set(...rotation)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// 遍历所有 mesh,设置阴影
|
|
99
|
+
model.scene.traverse((child: any) => {
|
|
100
|
+
if ('castShadow' in child) {
|
|
101
|
+
child.castShadow = castShadow ?? false
|
|
102
|
+
}
|
|
103
|
+
if ('receiveShadow' in child) {
|
|
104
|
+
child.receiveShadow = receiveShadow ?? false
|
|
105
|
+
}
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
}, [model, config])
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
model,
|
|
112
|
+
state,
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { useRef, useCallback, useEffect } from 'react'
|
|
2
|
+
import type { SkinnedMesh } from 'three'
|
|
3
|
+
import {
|
|
4
|
+
findMorphTargetMeshes,
|
|
5
|
+
setMorphWeight as utilSetMorphWeight,
|
|
6
|
+
setDualMorph as utilSetDualMorph,
|
|
7
|
+
applyEmotionMorphs,
|
|
8
|
+
} from '../utils/characterUtils'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Morph Target 控制 Hook
|
|
12
|
+
* 管理模型的 morph targets(表情控制)
|
|
13
|
+
*/
|
|
14
|
+
export function useMorphControl(model: any) {
|
|
15
|
+
const morphMeshesRef = useRef<SkinnedMesh[]>([])
|
|
16
|
+
|
|
17
|
+
// 初始化 morph meshes
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
if (model?.scene) {
|
|
20
|
+
morphMeshesRef.current = findMorphTargetMeshes(model)
|
|
21
|
+
}
|
|
22
|
+
return () => {
|
|
23
|
+
morphMeshesRef.current = []
|
|
24
|
+
}
|
|
25
|
+
}, [model])
|
|
26
|
+
|
|
27
|
+
const setMorphWeight = useCallback((morphName: string, value: number) => {
|
|
28
|
+
utilSetMorphWeight(morphMeshesRef.current, morphName, value)
|
|
29
|
+
}, [])
|
|
30
|
+
|
|
31
|
+
const setDualMorph = useCallback((baseName: string, value: number) => {
|
|
32
|
+
utilSetDualMorph(morphMeshesRef.current, baseName, value)
|
|
33
|
+
}, [])
|
|
34
|
+
|
|
35
|
+
const applyEmotion = useCallback((emotion: string) => {
|
|
36
|
+
applyEmotionMorphs(morphMeshesRef.current, emotion)
|
|
37
|
+
}, [])
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
morphMeshes: morphMeshesRef.current,
|
|
41
|
+
setMorphWeight,
|
|
42
|
+
setDualMorph,
|
|
43
|
+
applyEmotion,
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { useRef, useCallback } from 'react'
|
|
2
|
+
import type { Bone, SkinnedMesh } from 'three'
|
|
3
|
+
import {
|
|
4
|
+
setMorphWeight,
|
|
5
|
+
setDualMorph,
|
|
6
|
+
getEmotionHeadSwayParams,
|
|
7
|
+
} from '../utils/characterUtils'
|
|
8
|
+
|
|
9
|
+
export interface ProceduralAnimationsOptions {
|
|
10
|
+
headBone: Bone | null
|
|
11
|
+
morphMeshes: SkinnedMesh[]
|
|
12
|
+
emotion: string
|
|
13
|
+
isSpeaking: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 程序化动画控制 Hook
|
|
18
|
+
* 管理头部微动、眨眼、lip-sync 等程序化动画
|
|
19
|
+
*/
|
|
20
|
+
export function useProceduralAnimations() {
|
|
21
|
+
const elapsedTimeRef = useRef<number>(0)
|
|
22
|
+
const headSwayEnabledRef = useRef<boolean>(true)
|
|
23
|
+
const blinkingEnabledRef = useRef<boolean>(true)
|
|
24
|
+
const lipSyncEnabledRef = useRef<boolean>(true)
|
|
25
|
+
|
|
26
|
+
const setHeadSwayEnabled = useCallback((enabled: boolean) => {
|
|
27
|
+
headSwayEnabledRef.current = enabled
|
|
28
|
+
}, [])
|
|
29
|
+
|
|
30
|
+
const setBlinkingEnabled = useCallback((enabled: boolean) => {
|
|
31
|
+
blinkingEnabledRef.current = enabled
|
|
32
|
+
}, [])
|
|
33
|
+
|
|
34
|
+
const setLipSyncEnabled = useCallback((enabled: boolean) => {
|
|
35
|
+
lipSyncEnabledRef.current = enabled
|
|
36
|
+
}, [])
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 更新程序化动画(在 useFrame 中调用)
|
|
40
|
+
*/
|
|
41
|
+
const update = useCallback((delta: number, options: ProceduralAnimationsOptions) => {
|
|
42
|
+
elapsedTimeRef.current += delta
|
|
43
|
+
const time = elapsedTimeRef.current
|
|
44
|
+
const { headBone, morphMeshes, emotion, isSpeaking } = options
|
|
45
|
+
|
|
46
|
+
// 头部微动
|
|
47
|
+
if (headSwayEnabledRef.current && headBone) {
|
|
48
|
+
const { speed, range, pitchOffset } = getEmotionHeadSwayParams(emotion)
|
|
49
|
+
|
|
50
|
+
let yaw = Math.sin(time * speed) * range
|
|
51
|
+
let pitch = (Math.cos(time * speed * 0.7) * (range * 0.5)) + pitchOffset
|
|
52
|
+
|
|
53
|
+
if (isSpeaking) {
|
|
54
|
+
yaw *= 0.3
|
|
55
|
+
pitch += Math.sin(time * 8.0) * 0.03
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
headBone.rotation.y = yaw
|
|
59
|
+
headBone.rotation.x = pitch
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// 眨眼
|
|
63
|
+
if (blinkingEnabledRef.current && morphMeshes.length > 0) {
|
|
64
|
+
const blinkSignal = Math.sin(time * 1.2)
|
|
65
|
+
const isBlinking = blinkSignal > 0.98
|
|
66
|
+
setDualMorph(morphMeshes, 'eyeBlink', isBlinking ? 1.0 : 0.0)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Lip Sync
|
|
70
|
+
if (lipSyncEnabledRef.current && morphMeshes.length > 0) {
|
|
71
|
+
if (isSpeaking) {
|
|
72
|
+
const openAmount = (Math.sin(time * 18.0) + 1) * 0.25
|
|
73
|
+
setMorphWeight(morphMeshes, 'jawOpen', openAmount)
|
|
74
|
+
|
|
75
|
+
const funnelAmount = (Math.cos(time * 12.0) + 1) * 0.2
|
|
76
|
+
setDualMorph(morphMeshes, 'mouthFunnel', funnelAmount)
|
|
77
|
+
} else {
|
|
78
|
+
setMorphWeight(morphMeshes, 'jawOpen', 0)
|
|
79
|
+
setDualMorph(morphMeshes, 'mouthFunnel', 0)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}, [])
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
update,
|
|
86
|
+
setHeadSwayEnabled,
|
|
87
|
+
setBlinkingEnabled,
|
|
88
|
+
setLipSyncEnabled,
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { useRef, useCallback } from 'react'
|
|
2
|
+
import type { SkinnedMesh } from 'three'
|
|
3
|
+
import type { AnimationStatesConfig } from '../types'
|
|
4
|
+
import {
|
|
5
|
+
resetLipSyncMorphs,
|
|
6
|
+
getEmotionHeadSwayParams,
|
|
7
|
+
applyEmotionMorphs,
|
|
8
|
+
} from '../utils/characterUtils'
|
|
9
|
+
|
|
10
|
+
export interface TalkingControlOptions {
|
|
11
|
+
animationStates?: AnimationStatesConfig
|
|
12
|
+
morphMeshes: SkinnedMesh[]
|
|
13
|
+
playAnimation: (animationId: string, options?: { loop?: boolean }) => void
|
|
14
|
+
setState: (state: string, mood?: string) => void
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 说话控制 Hook
|
|
19
|
+
* 管理说话状态、情绪和手势调度
|
|
20
|
+
*/
|
|
21
|
+
export function useTalkingControl(options: TalkingControlOptions) {
|
|
22
|
+
const { animationStates, morphMeshes, playAnimation, setState } = options
|
|
23
|
+
|
|
24
|
+
const isSpeakingRef = useRef<boolean>(false)
|
|
25
|
+
const currentEmotionRef = useRef<string>('neutral')
|
|
26
|
+
const talkGestureAnimationsRef = useRef<string[]>([])
|
|
27
|
+
const talkTimerRef = useRef<number>(0)
|
|
28
|
+
const nextTalkSwitchRef = useRef<number>(0)
|
|
29
|
+
|
|
30
|
+
const extractTalkGestures = useCallback(() => {
|
|
31
|
+
if (!animationStates?.speaking) return
|
|
32
|
+
|
|
33
|
+
const speakingConfig = animationStates.speaking
|
|
34
|
+
// animations 现在直接是 string[]
|
|
35
|
+
talkGestureAnimationsRef.current = speakingConfig.animations
|
|
36
|
+
}, [animationStates])
|
|
37
|
+
|
|
38
|
+
const triggerRandomTalkGesture = useCallback(() => {
|
|
39
|
+
if (talkGestureAnimationsRef.current.length === 0) {
|
|
40
|
+
extractTalkGestures()
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (talkGestureAnimationsRef.current.length === 0) {
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const randomIndex = Math.floor(Math.random() * talkGestureAnimationsRef.current.length)
|
|
48
|
+
const nextAnim = talkGestureAnimationsRef.current[randomIndex]
|
|
49
|
+
|
|
50
|
+
playAnimation(nextAnim, { loop: true })
|
|
51
|
+
|
|
52
|
+
talkTimerRef.current = 0
|
|
53
|
+
nextTalkSwitchRef.current = 2.0 + Math.random() * 2.0
|
|
54
|
+
}, [playAnimation, extractTalkGestures])
|
|
55
|
+
|
|
56
|
+
const startSpeak = useCallback((emotion?: string) => {
|
|
57
|
+
const targetEmotion = emotion || currentEmotionRef.current
|
|
58
|
+
currentEmotionRef.current = targetEmotion
|
|
59
|
+
isSpeakingRef.current = true
|
|
60
|
+
|
|
61
|
+
applyEmotionMorphs(morphMeshes, targetEmotion)
|
|
62
|
+
|
|
63
|
+
if (animationStates?.speaking) {
|
|
64
|
+
setState('speaking', targetEmotion)
|
|
65
|
+
triggerRandomTalkGesture()
|
|
66
|
+
}
|
|
67
|
+
}, [animationStates, morphMeshes, setState, triggerRandomTalkGesture])
|
|
68
|
+
|
|
69
|
+
const stopSpeak = useCallback((resetEmotion?: string) => {
|
|
70
|
+
isSpeakingRef.current = false
|
|
71
|
+
|
|
72
|
+
const targetEmotion = resetEmotion || currentEmotionRef.current
|
|
73
|
+
if (resetEmotion) {
|
|
74
|
+
currentEmotionRef.current = resetEmotion
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
resetLipSyncMorphs(morphMeshes)
|
|
78
|
+
applyEmotionMorphs(morphMeshes, targetEmotion)
|
|
79
|
+
|
|
80
|
+
if (animationStates?.idle) {
|
|
81
|
+
setState('idle', targetEmotion)
|
|
82
|
+
}
|
|
83
|
+
}, [animationStates, morphMeshes, setState])
|
|
84
|
+
|
|
85
|
+
const isSpeaking = useCallback(() => isSpeakingRef.current, [])
|
|
86
|
+
|
|
87
|
+
const getCurrentEmotion = useCallback(() => currentEmotionRef.current, [])
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* 更新说话手势调度(在 useFrame 中调用)
|
|
91
|
+
*/
|
|
92
|
+
const updateTalkGestures = useCallback((delta: number) => {
|
|
93
|
+
if (isSpeakingRef.current && animationStates?.speaking) {
|
|
94
|
+
talkTimerRef.current += delta
|
|
95
|
+
if (talkTimerRef.current > nextTalkSwitchRef.current) {
|
|
96
|
+
triggerRandomTalkGesture()
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}, [animationStates, triggerRandomTalkGesture])
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
startSpeak,
|
|
103
|
+
stopSpeak,
|
|
104
|
+
isSpeaking,
|
|
105
|
+
getCurrentEmotion,
|
|
106
|
+
updateTalkGestures,
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 3D-Shared 公共 API
|
|
3
|
+
* 只暴露用户需要的配置类型和通用组件
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// ============================================================
|
|
7
|
+
// 类型定义 - 用户配置所需
|
|
8
|
+
// ============================================================
|
|
9
|
+
export type {
|
|
10
|
+
// 场景配置
|
|
11
|
+
SceneConfig,
|
|
12
|
+
LightConfig,
|
|
13
|
+
GridHelperConfig,
|
|
14
|
+
OrbitControlsConfig,
|
|
15
|
+
|
|
16
|
+
// 模型和动画配置
|
|
17
|
+
ModelLoadConfig,
|
|
18
|
+
AnimationConfig,
|
|
19
|
+
AnimationStatesConfig,
|
|
20
|
+
AnimationStateConfig,
|
|
21
|
+
|
|
22
|
+
// Character 接口(供用户调用,只包含高级 API)
|
|
23
|
+
Character,
|
|
24
|
+
CharacterViewProps,
|
|
25
|
+
OverlayConfig,
|
|
26
|
+
// 注意:不导出 CharacterInternal(内部使用)
|
|
27
|
+
} from './types'
|
|
28
|
+
|
|
29
|
+
// ============================================================
|
|
30
|
+
// 通用组件 - 跨平台复用
|
|
31
|
+
// ============================================================
|
|
32
|
+
export { SceneContent } from './components/SceneContent'
|
package/src/internal.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 3D-Shared 内部 API
|
|
3
|
+
* 仅供 @linxai/3d-web 和 @linxai/3d-native 使用
|
|
4
|
+
* 不对最终用户暴露
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// ============================================================
|
|
8
|
+
// 核心 Hook - 平台包只需要这一个
|
|
9
|
+
// ============================================================
|
|
10
|
+
export { useCharacterController } from './hooks/useCharacterController'
|
|
11
|
+
export type { UseCharacterControllerOptions } from './hooks/useCharacterController'
|
|
12
|
+
|
|
13
|
+
// ============================================================
|
|
14
|
+
// 适配器接口 - 平台包需要实现
|
|
15
|
+
// ============================================================
|
|
16
|
+
export type { ModelLoaderAdapter } from './hooks/useModelLoader'
|
|
17
|
+
export type { AnimationLoaderAdapter } from './hooks/useAnimationLoader'
|
|
18
|
+
|
|
19
|
+
// ============================================================
|
|
20
|
+
// 场景构建 - 平台包使用
|
|
21
|
+
// ============================================================
|
|
22
|
+
export type { SceneBuilder } from './components/SceneBuilder'
|
|
23
|
+
export { useSceneBuilder } from './components/SceneBuilder'
|
|
24
|
+
export { SceneContent } from './components/SceneContent'
|
|
25
|
+
|
|
26
|
+
// ============================================================
|
|
27
|
+
// Character 接口 - 平台包构建 Character 对象使用
|
|
28
|
+
// ============================================================
|
|
29
|
+
export type { Character, CharacterInternal, CharacterViewProps } from './types/character'
|
|
30
|
+
|
|
31
|
+
// ============================================================
|
|
32
|
+
// 上下文 - 平台包使用
|
|
33
|
+
// ============================================================
|
|
34
|
+
export { LoadingProvider, useLoading } from './contexts/LoadingContext'
|
|
35
|
+
|
|
36
|
+
// ============================================================
|
|
37
|
+
// 工具函数 - 平台包使用
|
|
38
|
+
// ============================================================
|
|
39
|
+
export {
|
|
40
|
+
findHeadBone,
|
|
41
|
+
findMorphTargetMeshes,
|
|
42
|
+
setMorphWeight,
|
|
43
|
+
setDualMorph,
|
|
44
|
+
resetLipSyncMorphs,
|
|
45
|
+
getEmotionHeadSwayParams,
|
|
46
|
+
applyEmotionMorphs,
|
|
47
|
+
} from './utils/characterUtils'
|
|
48
|
+
|
|
49
|
+
// ============================================================
|
|
50
|
+
// 类型定义 - 平台包使用
|
|
51
|
+
// ============================================================
|
|
52
|
+
export type { SceneConfig, SceneState } from './types'
|