@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
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@linxai/3d-shared",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "3D 可视化共享组件 - Web & React Native 通用",
|
|
5
|
+
"main": "./dist/index.js",
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"access": "public"
|
|
8
|
+
},
|
|
9
|
+
"module": "./dist/index.mjs",
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"import": "./dist/index.mjs",
|
|
15
|
+
"require": "./dist/index.js"
|
|
16
|
+
},
|
|
17
|
+
"./internal": {
|
|
18
|
+
"types": "./dist/internal.d.ts",
|
|
19
|
+
"import": "./dist/internal.mjs",
|
|
20
|
+
"require": "./dist/internal.js"
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"scripts": {
|
|
24
|
+
"build": "tsup",
|
|
25
|
+
"dev": "tsup src/index.ts --format cjs,esm --dts --watch",
|
|
26
|
+
"typecheck": "tsc --noEmit"
|
|
27
|
+
},
|
|
28
|
+
"peerDependencies": {
|
|
29
|
+
"@react-three/fiber": ">=8.0.0",
|
|
30
|
+
"react": ">=18.0.0",
|
|
31
|
+
"react-native": "*",
|
|
32
|
+
"three": ">=0.160.0"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@types/react": "^19.2.7",
|
|
36
|
+
"@types/three": "^0.166.0",
|
|
37
|
+
"tsup": "^8.5.1",
|
|
38
|
+
"typescript": "^5.9.3"
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import type { SceneConfig, OrbitControlsConfig, GridHelperConfig } from '../types'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 场景构建器接口
|
|
6
|
+
* Web 和 Native 分别实现此接口来装配场景
|
|
7
|
+
*/
|
|
8
|
+
export interface SceneBuilder {
|
|
9
|
+
/** 构建 Canvas 组件 */
|
|
10
|
+
buildCanvas: (props: {
|
|
11
|
+
children: ReactNode
|
|
12
|
+
config?: SceneConfig
|
|
13
|
+
style?: any
|
|
14
|
+
className?: string
|
|
15
|
+
}) => ReactNode
|
|
16
|
+
|
|
17
|
+
/** 构建 OrbitControls 组件 */
|
|
18
|
+
buildOrbitControls: (config?: OrbitControlsConfig) => ReactNode | null
|
|
19
|
+
|
|
20
|
+
/** 构建 GridHelper 组件 */
|
|
21
|
+
buildGridHelper: (config?: GridHelperConfig) => ReactNode | null
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 使用构建器创建场景的 Hook
|
|
26
|
+
* 返回构建好的场景组件
|
|
27
|
+
*/
|
|
28
|
+
export function useSceneBuilder(builder: SceneBuilder) {
|
|
29
|
+
return {
|
|
30
|
+
buildCanvas: builder.buildCanvas,
|
|
31
|
+
buildOrbitControls: builder.buildOrbitControls,
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import type { SceneConfig } from '../types'
|
|
3
|
+
|
|
4
|
+
interface SceneContentProps {
|
|
5
|
+
/** 子组件 */
|
|
6
|
+
children: ReactNode
|
|
7
|
+
/** 场景配置 */
|
|
8
|
+
config?: SceneConfig
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 通用的场景内容组件
|
|
13
|
+
* 包含背景色、光源等通用元素
|
|
14
|
+
* 这个组件在 Web 和 Native 中完全复用
|
|
15
|
+
*
|
|
16
|
+
* 注意:此组件返回 JSX 片段,需要在 R3F Canvas 内部使用
|
|
17
|
+
*/
|
|
18
|
+
export function SceneContent({ children, config = {} }: SceneContentProps) {
|
|
19
|
+
const {
|
|
20
|
+
backgroundColor = '#000',
|
|
21
|
+
ambientLightIntensity = 0.5, // 向后兼容
|
|
22
|
+
lights,
|
|
23
|
+
} = config
|
|
24
|
+
|
|
25
|
+
// 光源配置(支持新配置和旧配置)
|
|
26
|
+
const ambientIntensity = lights?.ambientIntensity ?? ambientLightIntensity
|
|
27
|
+
const pointLight = lights?.pointLight
|
|
28
|
+
const directionalLight = lights?.directionalLight
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<>
|
|
32
|
+
{/* @ts-ignore - R3F JSX 元素 */}
|
|
33
|
+
<color attach="background" args={[backgroundColor]} />
|
|
34
|
+
|
|
35
|
+
{/* 环境光 */}
|
|
36
|
+
{ambientIntensity > 0 && (
|
|
37
|
+
/* @ts-ignore - R3F JSX 元素 */
|
|
38
|
+
<ambientLight intensity={ambientIntensity} />
|
|
39
|
+
)}
|
|
40
|
+
|
|
41
|
+
{/* 点光源 */}
|
|
42
|
+
{pointLight && (
|
|
43
|
+
/* @ts-ignore - R3F JSX 元素 */
|
|
44
|
+
<pointLight
|
|
45
|
+
position={pointLight.position || [10, 10, 10]}
|
|
46
|
+
intensity={pointLight.intensity ?? 1}
|
|
47
|
+
/>
|
|
48
|
+
)}
|
|
49
|
+
|
|
50
|
+
{/* 方向光 */}
|
|
51
|
+
{directionalLight && (
|
|
52
|
+
/* @ts-ignore - R3F JSX 元素 */
|
|
53
|
+
<directionalLight
|
|
54
|
+
position={directionalLight.position || [-10, -10, -5]}
|
|
55
|
+
intensity={directionalLight.intensity ?? 0.5}
|
|
56
|
+
/>
|
|
57
|
+
)}
|
|
58
|
+
|
|
59
|
+
{children}
|
|
60
|
+
</>
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { createContext, useContext, useState, useCallback, ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
interface LoadingContextValue {
|
|
4
|
+
progress: number
|
|
5
|
+
isLoading: boolean
|
|
6
|
+
setProgress: (progress: number) => void
|
|
7
|
+
setLoading: (loading: boolean) => void
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const LoadingContext = createContext<LoadingContextValue | null>(null)
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 简单的加载状态管理 Context (Web)
|
|
14
|
+
*/
|
|
15
|
+
export function LoadingProvider({ children }: { children: ReactNode }) {
|
|
16
|
+
const [progress, setProgressState] = useState(0)
|
|
17
|
+
const [isLoading, setLoadingState] = useState(false)
|
|
18
|
+
|
|
19
|
+
const setProgress = useCallback((value: number) => {
|
|
20
|
+
setProgressState(Math.max(0, Math.min(1, value)))
|
|
21
|
+
}, [])
|
|
22
|
+
|
|
23
|
+
const setLoading = useCallback((loading: boolean) => {
|
|
24
|
+
setLoadingState(loading)
|
|
25
|
+
}, [])
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<LoadingContext.Provider
|
|
29
|
+
value={{ progress, isLoading, setProgress, setLoading }}
|
|
30
|
+
>
|
|
31
|
+
{children}
|
|
32
|
+
</LoadingContext.Provider>
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 使用加载状态的 Hook
|
|
38
|
+
*/
|
|
39
|
+
export function useLoading() {
|
|
40
|
+
const context = useContext(LoadingContext)
|
|
41
|
+
if (!context) {
|
|
42
|
+
throw new Error('useLoading must be used within LoadingProvider')
|
|
43
|
+
}
|
|
44
|
+
return context
|
|
45
|
+
}
|
|
46
|
+
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { useRef, useCallback, useState, useEffect } from 'react'
|
|
2
|
+
import { AnimationMixer, AnimationAction, LoopOnce, LoopRepeat } from 'three'
|
|
3
|
+
import { useFrame } from '@react-three/fiber'
|
|
4
|
+
|
|
5
|
+
export interface AnimationControlOptions {
|
|
6
|
+
/** 加载的模型 */
|
|
7
|
+
model: any
|
|
8
|
+
/** 动画 Map */
|
|
9
|
+
animationsMap: Map<string, any> | null
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface PlayOptions {
|
|
13
|
+
/** 是否循环播放 */
|
|
14
|
+
loop?: boolean
|
|
15
|
+
/** 淡入时间(秒) */
|
|
16
|
+
fadeIn?: number
|
|
17
|
+
/** 淡出时间(秒) */
|
|
18
|
+
fadeOut?: number
|
|
19
|
+
/** 播放速度 */
|
|
20
|
+
speed?: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 动画控制 Hook
|
|
25
|
+
* 管理 AnimationMixer、动画播放、切换和过渡
|
|
26
|
+
*/
|
|
27
|
+
export function useAnimationControl(options: AnimationControlOptions) {
|
|
28
|
+
const { model, animationsMap } = options
|
|
29
|
+
|
|
30
|
+
const [mixer, setMixer] = useState<AnimationMixer | null>(null)
|
|
31
|
+
const currentActionRef = useRef<AnimationAction | null>(null)
|
|
32
|
+
const currentAnimationIdRef = useRef<string | null>(null)
|
|
33
|
+
|
|
34
|
+
// 初始化 AnimationMixer
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
if (model?.scene) {
|
|
37
|
+
const newMixer = new AnimationMixer(model.scene)
|
|
38
|
+
setMixer(newMixer)
|
|
39
|
+
|
|
40
|
+
return () => {
|
|
41
|
+
setMixer(null)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}, [model])
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 播放动画
|
|
49
|
+
*/
|
|
50
|
+
const play = useCallback((animationId: string, options: PlayOptions = {}) => {
|
|
51
|
+
if (!mixer || !animationsMap) return
|
|
52
|
+
|
|
53
|
+
const clip = animationsMap.get(animationId)
|
|
54
|
+
if (!clip) {
|
|
55
|
+
console.warn(`[AnimationControl] Animation clip not found: ${animationId}`)
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const { loop = true, fadeIn = 0.3, speed = 1 } = options
|
|
60
|
+
const blendTime = fadeIn
|
|
61
|
+
|
|
62
|
+
const newAction = mixer.clipAction(clip)
|
|
63
|
+
newAction.loop = loop ? LoopRepeat : LoopOnce
|
|
64
|
+
newAction.clampWhenFinished = !loop
|
|
65
|
+
|
|
66
|
+
// 如果是同一个动画,不做任何操作
|
|
67
|
+
if (currentActionRef.current === newAction) {
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// 如果有当前动画,使用 crossFadeTo 实现平滑过渡
|
|
72
|
+
if (currentActionRef.current && currentActionRef.current.isRunning()) {
|
|
73
|
+
// 准备新动画
|
|
74
|
+
newAction.reset().setEffectiveWeight(1).setEffectiveTimeScale(speed).play()
|
|
75
|
+
// 从当前动画平滑过渡到新动画
|
|
76
|
+
currentActionRef.current.crossFadeTo(newAction, blendTime, false)
|
|
77
|
+
} else {
|
|
78
|
+
// 首次播放,直接启动
|
|
79
|
+
newAction.reset().fadeIn(blendTime).play()
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
currentActionRef.current = newAction
|
|
83
|
+
currentAnimationIdRef.current = animationId
|
|
84
|
+
}, [mixer, animationsMap])
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 停止动画
|
|
88
|
+
*/
|
|
89
|
+
const stop = useCallback((fadeOut = 0.3) => {
|
|
90
|
+
if (currentActionRef.current) {
|
|
91
|
+
currentActionRef.current.fadeOut(fadeOut)
|
|
92
|
+
currentActionRef.current = null
|
|
93
|
+
currentAnimationIdRef.current = null
|
|
94
|
+
}
|
|
95
|
+
}, [])
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 暂停动画
|
|
99
|
+
*/
|
|
100
|
+
const pause = useCallback(() => {
|
|
101
|
+
if (currentActionRef.current?.isRunning()) {
|
|
102
|
+
currentActionRef.current.paused = true
|
|
103
|
+
}
|
|
104
|
+
}, [])
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* 恢复播放
|
|
108
|
+
*/
|
|
109
|
+
const resume = useCallback(() => {
|
|
110
|
+
if (currentActionRef.current?.paused) {
|
|
111
|
+
currentActionRef.current.paused = false
|
|
112
|
+
}
|
|
113
|
+
}, [])
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* 获取当前播放的动画 ID
|
|
117
|
+
*/
|
|
118
|
+
const getCurrentAnimation = useCallback(() => currentAnimationIdRef.current, [])
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* 是否正在播放
|
|
122
|
+
*/
|
|
123
|
+
const isPlaying = useCallback(() => {
|
|
124
|
+
return currentActionRef.current ? currentActionRef.current.isRunning() : false
|
|
125
|
+
}, [])
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* 更新 AnimationMixer
|
|
129
|
+
*/
|
|
130
|
+
useFrame((_, delta) => {
|
|
131
|
+
mixer?.update(delta)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
mixer,
|
|
136
|
+
currentAction: currentActionRef.current,
|
|
137
|
+
play,
|
|
138
|
+
stop,
|
|
139
|
+
pause,
|
|
140
|
+
resume,
|
|
141
|
+
getCurrentAnimation,
|
|
142
|
+
isPlaying,
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { useState, useEffect, useRef } from 'react'
|
|
2
|
+
import type { AnimationConfig } from '../types'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 动画加载状态
|
|
6
|
+
*/
|
|
7
|
+
export interface AnimationLoadState {
|
|
8
|
+
/** 是否已加载 */
|
|
9
|
+
loaded: boolean
|
|
10
|
+
/** 加载进度 (0-1) */
|
|
11
|
+
progress?: number
|
|
12
|
+
/** 错误信息 */
|
|
13
|
+
error?: Error | null
|
|
14
|
+
/** 加载的动画剪辑 */
|
|
15
|
+
clips?: any[] // Three.js AnimationClip[]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 动画加载器 Hook 返回值
|
|
20
|
+
*/
|
|
21
|
+
export interface AnimationLoaderResult {
|
|
22
|
+
/** 动画剪辑列表 */
|
|
23
|
+
clips: any[] | null
|
|
24
|
+
/** 加载状态 */
|
|
25
|
+
state: AnimationLoadState
|
|
26
|
+
/** 根据 ID 获取动画剪辑 */
|
|
27
|
+
getClip: (id: string) => any | null
|
|
28
|
+
/** 动画 Map (id -> clip) */
|
|
29
|
+
animationsMap: Map<string, any>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 平台特定的动画加载适配器
|
|
34
|
+
* 平台包提供具体的加载实现
|
|
35
|
+
*/
|
|
36
|
+
export interface AnimationLoaderAdapter {
|
|
37
|
+
/**
|
|
38
|
+
* 加载单个动画文件
|
|
39
|
+
* @param path 动画文件路径
|
|
40
|
+
* @param onProgress 进度回调
|
|
41
|
+
* @returns Promise<动画对象(包含 animations 数组)>
|
|
42
|
+
*/
|
|
43
|
+
load: (
|
|
44
|
+
path: string | number,
|
|
45
|
+
onProgress?: (progress: number) => void
|
|
46
|
+
) => Promise<{ animations: any[] }>
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 使用动画加载器的 Hook
|
|
51
|
+
* 在 shared 中实现核心逻辑,使用平台适配器加载
|
|
52
|
+
* 加载后使用配置的 ID 重命名动画剪辑
|
|
53
|
+
*/
|
|
54
|
+
export function useAnimationLoader(
|
|
55
|
+
animations: AnimationConfig[] | undefined,
|
|
56
|
+
adapter: AnimationLoaderAdapter
|
|
57
|
+
): AnimationLoaderResult {
|
|
58
|
+
const [state, setState] = useState<AnimationLoadState>({
|
|
59
|
+
loaded: false,
|
|
60
|
+
progress: 0,
|
|
61
|
+
})
|
|
62
|
+
const clipsMapRef = useRef<Map<string, any>>(new Map())
|
|
63
|
+
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
if (!animations || animations.length === 0) {
|
|
66
|
+
setState({ loaded: true, progress: 1, clips: [] })
|
|
67
|
+
clipsMapRef.current.clear()
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
let mounted = true
|
|
72
|
+
let loadedCount = 0
|
|
73
|
+
const totalCount = animations.length
|
|
74
|
+
clipsMapRef.current.clear()
|
|
75
|
+
|
|
76
|
+
const loadAll = async () => {
|
|
77
|
+
try {
|
|
78
|
+
const loadPromises = animations.map(async (config, index) => {
|
|
79
|
+
try {
|
|
80
|
+
const fbxObject = await adapter.load(config.path, (progress) => {
|
|
81
|
+
if (mounted) {
|
|
82
|
+
const overallProgress = (index + progress) / totalCount
|
|
83
|
+
setState((prev) => ({
|
|
84
|
+
...prev,
|
|
85
|
+
progress: overallProgress,
|
|
86
|
+
}))
|
|
87
|
+
}
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
if (mounted && fbxObject.animations && fbxObject.animations.length > 0) {
|
|
91
|
+
const clip = fbxObject.animations[0] // 取第一个动画剪辑
|
|
92
|
+
// 使用配置的 ID 重命名
|
|
93
|
+
clip.name = config.id
|
|
94
|
+
clipsMapRef.current.set(config.id, clip)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
loadedCount++
|
|
98
|
+
if (mounted) {
|
|
99
|
+
setState((prev) => ({
|
|
100
|
+
...prev,
|
|
101
|
+
progress: loadedCount / totalCount,
|
|
102
|
+
}))
|
|
103
|
+
}
|
|
104
|
+
} catch (error) {
|
|
105
|
+
console.error(`加载动画失败 (${config.id}):`, error)
|
|
106
|
+
if (mounted) {
|
|
107
|
+
setState((prev) => ({
|
|
108
|
+
...prev,
|
|
109
|
+
error: error as Error,
|
|
110
|
+
}))
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
await Promise.all(loadPromises)
|
|
116
|
+
|
|
117
|
+
if (mounted) {
|
|
118
|
+
setState({
|
|
119
|
+
loaded: true,
|
|
120
|
+
progress: 1,
|
|
121
|
+
error: null,
|
|
122
|
+
clips: Array.from(clipsMapRef.current.values()),
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
} catch (error) {
|
|
126
|
+
if (mounted) {
|
|
127
|
+
setState({
|
|
128
|
+
loaded: false,
|
|
129
|
+
progress: 0,
|
|
130
|
+
error: error as Error,
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
loadAll()
|
|
137
|
+
|
|
138
|
+
return () => {
|
|
139
|
+
mounted = false
|
|
140
|
+
}
|
|
141
|
+
}, [animations, adapter])
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
clips: state.clips || null,
|
|
145
|
+
state,
|
|
146
|
+
getClip: (id: string) => clipsMapRef.current.get(id) || null,
|
|
147
|
+
animationsMap: clipsMapRef.current,
|
|
148
|
+
}
|
|
149
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { useRef, useCallback, useState, useEffect } from 'react'
|
|
2
|
+
import type { AnimationStatesConfig } from '../types'
|
|
3
|
+
import type { PlayOptions } from './useAnimationControl'
|
|
4
|
+
|
|
5
|
+
export interface AnimationStateMachineOptions {
|
|
6
|
+
/** 动画状态机配置 */
|
|
7
|
+
animationStates?: AnimationStatesConfig
|
|
8
|
+
/** 初始状态 */
|
|
9
|
+
initialState?: string
|
|
10
|
+
/** 初始情绪 */
|
|
11
|
+
initialMood?: string
|
|
12
|
+
/** 是否启用 welcome 状态 */
|
|
13
|
+
enableWelcome?: boolean
|
|
14
|
+
/** 播放动画函数 */
|
|
15
|
+
playAnimation: (animationId: string, options?: PlayOptions) => void
|
|
16
|
+
/** AnimationMixer */
|
|
17
|
+
mixer: any
|
|
18
|
+
/** 应用情绪表情函数 */
|
|
19
|
+
applyEmotion?: (emotion: string) => void
|
|
20
|
+
/** 状态变化回调 */
|
|
21
|
+
onStateChange?: (state: string, mood: string) => void
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 动画状态机 Hook
|
|
26
|
+
* 管理状态、情绪切换和动画调度
|
|
27
|
+
*/
|
|
28
|
+
export function useAnimationStateMachine(options: AnimationStateMachineOptions) {
|
|
29
|
+
const {
|
|
30
|
+
animationStates,
|
|
31
|
+
initialState = 'idle',
|
|
32
|
+
initialMood = 'neutral',
|
|
33
|
+
enableWelcome = false,
|
|
34
|
+
playAnimation,
|
|
35
|
+
mixer,
|
|
36
|
+
applyEmotion,
|
|
37
|
+
onStateChange,
|
|
38
|
+
} = options
|
|
39
|
+
|
|
40
|
+
// 状态初始化:如果启用 welcome 则先用 welcome,否则用 initialState
|
|
41
|
+
const getInitialState = () => {
|
|
42
|
+
if (enableWelcome && animationStates?.welcome) {
|
|
43
|
+
return 'welcome'
|
|
44
|
+
}
|
|
45
|
+
return initialState
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const [currentState, setCurrentStateInternal] = useState<string>(getInitialState())
|
|
49
|
+
const [currentMood, setCurrentMoodInternal] = useState<string>(initialMood)
|
|
50
|
+
|
|
51
|
+
const nextAnimationTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
|
52
|
+
const currentAnimationListRef = useRef<string[]>([])
|
|
53
|
+
const currentAnimationIndexRef = useRef<number>(0)
|
|
54
|
+
const targetStateAfterWelcomeRef = useRef<string>(initialState)
|
|
55
|
+
const initializedRef = useRef<boolean>(false)
|
|
56
|
+
|
|
57
|
+
// 清理定时器
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
return () => {
|
|
60
|
+
if (nextAnimationTimeoutRef.current) {
|
|
61
|
+
clearTimeout(nextAnimationTimeoutRef.current)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}, [])
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 播放状态机中的下一个动画
|
|
68
|
+
* State 决定动画,Mood 决定表情
|
|
69
|
+
*/
|
|
70
|
+
const playNextAnimation = useCallback((state: string, mood: string, isWelcome = false) => {
|
|
71
|
+
if (!animationStates || !animationStates[state]) return
|
|
72
|
+
|
|
73
|
+
const stateConfig = animationStates[state]
|
|
74
|
+
const animations = stateConfig.animations
|
|
75
|
+
|
|
76
|
+
if (!animations || animations.length === 0) {
|
|
77
|
+
console.warn(`[StateMachine] No animations found for state: ${state}`)
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// 应用情绪表情(morphTargets)
|
|
82
|
+
if (applyEmotion) {
|
|
83
|
+
applyEmotion(mood)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const config = stateConfig.config || {}
|
|
87
|
+
const { random = false, loop = true, fadeIn = 0.3, fadeOut = 0.3, duration } = config
|
|
88
|
+
|
|
89
|
+
let animationId: string
|
|
90
|
+
|
|
91
|
+
if (random) {
|
|
92
|
+
// 随机选择
|
|
93
|
+
const randomIndex = Math.floor(Math.random() * animations.length)
|
|
94
|
+
animationId = animations[randomIndex]
|
|
95
|
+
} else {
|
|
96
|
+
// 顺序播放
|
|
97
|
+
if (currentAnimationListRef.current !== animations) {
|
|
98
|
+
// 动画列表变了,重置索引
|
|
99
|
+
currentAnimationListRef.current = animations
|
|
100
|
+
currentAnimationIndexRef.current = 0
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
animationId = animations[currentAnimationIndexRef.current]
|
|
104
|
+
currentAnimationIndexRef.current = (currentAnimationIndexRef.current + 1) % animations.length
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// 播放动画
|
|
108
|
+
playAnimation(animationId, { loop, fadeIn, fadeOut })
|
|
109
|
+
|
|
110
|
+
// 如果是 welcome 状态,监听动画结束事件
|
|
111
|
+
if (isWelcome && mixer) {
|
|
112
|
+
const handleWelcomeFinished = (e: any) => {
|
|
113
|
+
console.log('[StateMachine] Welcome animation finished, transition to:', targetStateAfterWelcomeRef.current)
|
|
114
|
+
setState(targetStateAfterWelcomeRef.current, mood)
|
|
115
|
+
mixer.removeEventListener('finished', handleWelcomeFinished)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
mixer.addEventListener('finished', handleWelcomeFinished)
|
|
119
|
+
} else {
|
|
120
|
+
// 如果设置了 duration,自动播放下一个
|
|
121
|
+
if (duration && duration > 0) {
|
|
122
|
+
if (nextAnimationTimeoutRef.current) {
|
|
123
|
+
clearTimeout(nextAnimationTimeoutRef.current)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
nextAnimationTimeoutRef.current = setTimeout(() => {
|
|
127
|
+
playNextAnimation(state, mood, false)
|
|
128
|
+
}, duration) as any
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}, [animationStates, playAnimation, mixer])
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* 设置状态(和可选的情绪)
|
|
135
|
+
*/
|
|
136
|
+
const setState = useCallback((state: string, mood?: string) => {
|
|
137
|
+
if (!animationStates || !animationStates[state]) {
|
|
138
|
+
console.warn(`[StateMachine] State not found: ${state}`)
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const newMood = mood || currentMood
|
|
143
|
+
setCurrentStateInternal(state)
|
|
144
|
+
setCurrentMoodInternal(newMood)
|
|
145
|
+
|
|
146
|
+
// 清理之前的定时器
|
|
147
|
+
if (nextAnimationTimeoutRef.current) {
|
|
148
|
+
clearTimeout(nextAnimationTimeoutRef.current)
|
|
149
|
+
nextAnimationTimeoutRef.current = null
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// 应用情绪表情
|
|
153
|
+
if (applyEmotion) {
|
|
154
|
+
applyEmotion(newMood)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// 播放对应的动画(welcome 状态特殊处理)
|
|
158
|
+
const isWelcome = state === 'welcome'
|
|
159
|
+
playNextAnimation(state, newMood, isWelcome)
|
|
160
|
+
|
|
161
|
+
// 触发状态变化回调
|
|
162
|
+
if (onStateChange) {
|
|
163
|
+
onStateChange(state, newMood)
|
|
164
|
+
}
|
|
165
|
+
}, [animationStates, currentState, currentMood, playNextAnimation, applyEmotion, onStateChange])
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* 设置情绪(保持当前状态)
|
|
169
|
+
*/
|
|
170
|
+
const setMood = useCallback((mood: string) => {
|
|
171
|
+
// mood 现在只影响表情,不影响动画选择
|
|
172
|
+
setCurrentMoodInternal(mood)
|
|
173
|
+
|
|
174
|
+
// 应用情绪表情(morphTargets)
|
|
175
|
+
if (applyEmotion) {
|
|
176
|
+
applyEmotion(mood)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// 触发状态变化回调
|
|
180
|
+
if (onStateChange) {
|
|
181
|
+
onStateChange(currentState, mood)
|
|
182
|
+
}
|
|
183
|
+
}, [currentState, applyEmotion, onStateChange])
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* 获取当前状态和情绪
|
|
187
|
+
*/
|
|
188
|
+
const getCurrentState = useCallback(() => {
|
|
189
|
+
return { state: currentState, mood: currentMood }
|
|
190
|
+
}, [currentState, currentMood])
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* 初始化状态机(播放初始动画)
|
|
194
|
+
*/
|
|
195
|
+
const initialize = useCallback(() => {
|
|
196
|
+
// 只初始化一次
|
|
197
|
+
if (initializedRef.current || !animationStates) return
|
|
198
|
+
|
|
199
|
+
initializedRef.current = true
|
|
200
|
+
|
|
201
|
+
// 应用初始情绪表情
|
|
202
|
+
if (applyEmotion) {
|
|
203
|
+
applyEmotion(initialMood)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// currentState 已在 useState 时设置,这里只需要播放动画
|
|
207
|
+
if (currentState === 'welcome') {
|
|
208
|
+
targetStateAfterWelcomeRef.current = initialState
|
|
209
|
+
playNextAnimation('welcome', initialMood, true)
|
|
210
|
+
} else {
|
|
211
|
+
playNextAnimation(currentState, initialMood, false)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// 触发状态变化回调
|
|
215
|
+
if (onStateChange) {
|
|
216
|
+
onStateChange(currentState, initialMood)
|
|
217
|
+
}
|
|
218
|
+
}, [animationStates, currentState, initialMood, initialState, playNextAnimation, applyEmotion, onStateChange])
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
currentState,
|
|
222
|
+
currentMood,
|
|
223
|
+
setState,
|
|
224
|
+
setMood,
|
|
225
|
+
getCurrentState,
|
|
226
|
+
initialize,
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|