@memori.ai/memori-react 7.19.2 → 7.21.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/CHANGELOG.md +45 -0
- package/dist/components/Avatar/AvatarView/AvatarComponent/Shadow/DynamicShadow.d.ts +3 -2
- package/dist/components/Avatar/AvatarView/AvatarComponent/Shadow/DynamicShadow.js +13 -6
- package/dist/components/Avatar/AvatarView/AvatarComponent/Shadow/DynamicShadow.js.map +1 -1
- package/dist/components/Avatar/AvatarView/AvatarComponent/avatarComponent.d.ts +14 -18
- package/dist/components/Avatar/AvatarView/AvatarComponent/avatarComponent.js +19 -77
- package/dist/components/Avatar/AvatarView/AvatarComponent/avatarComponent.js.map +1 -1
- package/dist/components/Avatar/AvatarView/AvatarComponent/components/FullbodyAvatar/fullbodyAvatar.d.ts +17 -2
- package/dist/components/Avatar/AvatarView/AvatarComponent/components/FullbodyAvatar/fullbodyAvatar.js +95 -70
- package/dist/components/Avatar/AvatarView/AvatarComponent/components/FullbodyAvatar/fullbodyAvatar.js.map +1 -1
- package/dist/components/Avatar/AvatarView/AvatarComponent/components/controllers/AvatarAnimator.d.ts +65 -0
- package/dist/components/Avatar/AvatarView/AvatarComponent/components/controllers/AvatarAnimator.js +747 -0
- package/dist/components/Avatar/AvatarView/AvatarComponent/components/controllers/AvatarAnimator.js.map +1 -0
- package/dist/components/Avatar/AvatarView/AvatarComponent/components/controllers/MorphTargetController.d.ts +9 -2
- package/dist/components/Avatar/AvatarView/AvatarComponent/components/controllers/MorphTargetController.js +60 -2
- package/dist/components/Avatar/AvatarView/AvatarComponent/components/controllers/MorphTargetController.js.map +1 -1
- package/dist/components/Avatar/AvatarView/AvatarComponent/components/halfbodyAvatar.d.ts +3 -4
- package/dist/components/Avatar/AvatarView/AvatarComponent/components/halfbodyAvatar.js +5 -11
- package/dist/components/Avatar/AvatarView/AvatarComponent/components/halfbodyAvatar.js.map +1 -1
- package/dist/components/Avatar/AvatarView/AvatarComponent/constants.d.ts +13 -52
- package/dist/components/Avatar/AvatarView/AvatarComponent/constants.js +68 -70
- package/dist/components/Avatar/AvatarView/AvatarComponent/constants.js.map +1 -1
- package/dist/components/Avatar/AvatarView/index.d.ts +1 -1
- package/dist/components/Avatar/AvatarView/index.js +2 -2
- package/dist/components/Avatar/AvatarView/index.js.map +1 -1
- package/dist/components/ChatBubble/ChatBubble.js +7 -1
- package/dist/components/ChatBubble/ChatBubble.js.map +1 -1
- package/dist/components/MemoriWidget/MemoriWidget.js +130 -62
- package/dist/components/MemoriWidget/MemoriWidget.js.map +1 -1
- package/dist/components/UploadButton/UploadButton.js +2 -2
- package/dist/components/UploadButton/UploadButton.js.map +1 -1
- package/dist/components/WhyThisAnswer/WhyThisAnswer.css +43 -0
- package/dist/components/WhyThisAnswer/WhyThisAnswer.js +2 -1
- package/dist/components/WhyThisAnswer/WhyThisAnswer.js.map +1 -1
- package/dist/context/visemeContext.js +0 -39
- package/dist/context/visemeContext.js.map +1 -1
- package/dist/locales/de.json +1 -0
- package/dist/locales/en.json +1 -0
- package/dist/locales/es.json +1 -0
- package/dist/locales/fr.json +1 -0
- package/dist/locales/it.json +1 -0
- package/esm/components/Avatar/AvatarView/AvatarComponent/Shadow/DynamicShadow.d.ts +3 -2
- package/esm/components/Avatar/AvatarView/AvatarComponent/Shadow/DynamicShadow.js +13 -6
- package/esm/components/Avatar/AvatarView/AvatarComponent/Shadow/DynamicShadow.js.map +1 -1
- package/esm/components/Avatar/AvatarView/AvatarComponent/avatarComponent.d.ts +14 -18
- package/esm/components/Avatar/AvatarView/AvatarComponent/avatarComponent.js +20 -78
- package/esm/components/Avatar/AvatarView/AvatarComponent/avatarComponent.js.map +1 -1
- package/esm/components/Avatar/AvatarView/AvatarComponent/components/FullbodyAvatar/fullbodyAvatar.d.ts +17 -2
- package/esm/components/Avatar/AvatarView/AvatarComponent/components/FullbodyAvatar/fullbodyAvatar.js +99 -74
- package/esm/components/Avatar/AvatarView/AvatarComponent/components/FullbodyAvatar/fullbodyAvatar.js.map +1 -1
- package/esm/components/Avatar/AvatarView/AvatarComponent/components/controllers/AvatarAnimator.d.ts +65 -0
- package/esm/components/Avatar/AvatarView/AvatarComponent/components/controllers/AvatarAnimator.js +743 -0
- package/esm/components/Avatar/AvatarView/AvatarComponent/components/controllers/AvatarAnimator.js.map +1 -0
- package/esm/components/Avatar/AvatarView/AvatarComponent/components/controllers/MorphTargetController.d.ts +9 -2
- package/esm/components/Avatar/AvatarView/AvatarComponent/components/controllers/MorphTargetController.js +61 -3
- package/esm/components/Avatar/AvatarView/AvatarComponent/components/controllers/MorphTargetController.js.map +1 -1
- package/esm/components/Avatar/AvatarView/AvatarComponent/components/halfbodyAvatar.d.ts +3 -4
- package/esm/components/Avatar/AvatarView/AvatarComponent/components/halfbodyAvatar.js +5 -11
- package/esm/components/Avatar/AvatarView/AvatarComponent/components/halfbodyAvatar.js.map +1 -1
- package/esm/components/Avatar/AvatarView/AvatarComponent/constants.d.ts +13 -52
- package/esm/components/Avatar/AvatarView/AvatarComponent/constants.js +67 -69
- package/esm/components/Avatar/AvatarView/AvatarComponent/constants.js.map +1 -1
- package/esm/components/Avatar/AvatarView/index.d.ts +1 -1
- package/esm/components/Avatar/AvatarView/index.js +2 -2
- package/esm/components/Avatar/AvatarView/index.js.map +1 -1
- package/esm/components/ChatBubble/ChatBubble.js +7 -1
- package/esm/components/ChatBubble/ChatBubble.js.map +1 -1
- package/esm/components/MemoriWidget/MemoriWidget.js +130 -62
- package/esm/components/MemoriWidget/MemoriWidget.js.map +1 -1
- package/esm/components/UploadButton/UploadButton.js +2 -2
- package/esm/components/UploadButton/UploadButton.js.map +1 -1
- package/esm/components/WhyThisAnswer/WhyThisAnswer.css +43 -0
- package/esm/components/WhyThisAnswer/WhyThisAnswer.js +2 -1
- package/esm/components/WhyThisAnswer/WhyThisAnswer.js.map +1 -1
- package/esm/context/visemeContext.js +0 -39
- package/esm/context/visemeContext.js.map +1 -1
- package/esm/locales/de.json +1 -0
- package/esm/locales/en.json +1 -0
- package/esm/locales/es.json +1 -0
- package/esm/locales/fr.json +1 -0
- package/esm/locales/it.json +1 -0
- package/package.json +2 -2
- package/src/components/Avatar/AvatarView/AvatarComponent/Shadow/DynamicShadow.tsx +15 -8
- package/src/components/Avatar/AvatarView/AvatarComponent/avatarComponent.tsx +64 -219
- package/src/components/Avatar/AvatarView/AvatarComponent/components/FullbodyAvatar/fullbodyAvatar.tsx +221 -124
- package/src/components/Avatar/AvatarView/AvatarComponent/components/controllers/AvatarAnimator.ts +1250 -0
- package/src/components/Avatar/AvatarView/AvatarComponent/components/controllers/MorphTargetController.ts +164 -8
- package/src/components/Avatar/AvatarView/AvatarComponent/components/halfbodyAvatar.tsx +19 -17
- package/src/components/Avatar/AvatarView/AvatarComponent/constants.ts +80 -79
- package/src/components/Avatar/AvatarView/index.tsx +1 -7
- package/src/components/ChatBubble/ChatBubble.tsx +14 -2
- package/src/components/MemoriWidget/MemoriWidget.tsx +168 -76
- package/src/components/UploadButton/UploadButton.tsx +4 -4
- package/src/components/UploadButton/__snapshots__/UploadButton.test.tsx.snap +1 -1
- package/src/components/WhyThisAnswer/WhyThisAnswer.css +43 -0
- package/src/components/WhyThisAnswer/WhyThisAnswer.stories.tsx +44 -3
- package/src/components/WhyThisAnswer/WhyThisAnswer.test.tsx +128 -8
- package/src/components/WhyThisAnswer/WhyThisAnswer.tsx +28 -3
- package/src/components/WhyThisAnswer/__snapshots__/WhyThisAnswer.test.tsx.snap +15 -1
- package/src/components/layouts/layouts.stories.tsx +0 -8
- package/src/context/visemeContext.tsx +40 -41
- package/src/index.stories.tsx +63 -65
- package/src/locales/de.json +1 -0
- package/src/locales/en.json +1 -0
- package/src/locales/es.json +1 -0
- package/src/locales/fr.json +1 -0
- package/src/locales/it.json +1 -0
- package/src/components/Avatar/AvatarView/AvatarComponent/components/controllers/AnimationController.ts +0 -308
package/src/components/Avatar/AvatarView/AvatarComponent/components/controllers/AvatarAnimator.ts
ADDED
|
@@ -0,0 +1,1250 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AnimationAction,
|
|
3
|
+
AnimationClip,
|
|
4
|
+
AnimationMixer,
|
|
5
|
+
LoopOnce,
|
|
6
|
+
Scene,
|
|
7
|
+
} from 'three';
|
|
8
|
+
import { AnimationState } from '../FullbodyAvatar/types';
|
|
9
|
+
import { MAPPING_EMOTIONS_ITALIAN_TO_ENGLISH } from '../../constants';
|
|
10
|
+
|
|
11
|
+
// Animation categories
|
|
12
|
+
export type AnimationCategory = 'IDLE' | 'LOADING' | 'ACTION';
|
|
13
|
+
|
|
14
|
+
// Animation metadata with essential properties
|
|
15
|
+
export interface AnimationInfo {
|
|
16
|
+
name: string;
|
|
17
|
+
category: AnimationCategory;
|
|
18
|
+
duration: number;
|
|
19
|
+
canLoop: boolean;
|
|
20
|
+
defaultLoopCount: number; // 0 for infinite
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Animation play options
|
|
24
|
+
export interface AnimationPlayOptions {
|
|
25
|
+
fadeInDuration?: number;
|
|
26
|
+
fadeOutDuration?: number;
|
|
27
|
+
timeScale?: number;
|
|
28
|
+
loopCount?: number;
|
|
29
|
+
fallbackToIdle?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Enhanced AvatarAnimator class
|
|
34
|
+
* A system for managing 3D avatar animations with improved sequence parsing and generalized emotion handling
|
|
35
|
+
*/
|
|
36
|
+
export class AvatarAnimator {
|
|
37
|
+
// Animation system
|
|
38
|
+
private mixer: AnimationMixer | null = null;
|
|
39
|
+
private actions: Record<string, AnimationAction> = {};
|
|
40
|
+
private animations: Map<string, AnimationInfo> = new Map();
|
|
41
|
+
|
|
42
|
+
// Current state tracking
|
|
43
|
+
private currentAnimation: string | null = null;
|
|
44
|
+
private currentSequence: string[] | null = null;
|
|
45
|
+
private sequenceIndex: number = 0;
|
|
46
|
+
private isTransitioning: boolean = false;
|
|
47
|
+
|
|
48
|
+
// Configuration
|
|
49
|
+
private timeScale: number = 1.0;
|
|
50
|
+
private fadeInDuration: number = 0.8;
|
|
51
|
+
private fadeOutDuration: number = 0.8;
|
|
52
|
+
private avatarType: 'RPM' | 'CUSTOM_GLB' = 'CUSTOM_GLB';
|
|
53
|
+
|
|
54
|
+
// Event system
|
|
55
|
+
private eventListeners: Record<string, Array<(data: any) => void>> = {
|
|
56
|
+
start: [],
|
|
57
|
+
complete: [],
|
|
58
|
+
loop: [],
|
|
59
|
+
transition: [],
|
|
60
|
+
error: [],
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// Initialization state
|
|
64
|
+
private initialized: boolean = false;
|
|
65
|
+
|
|
66
|
+
// Track idle rotations
|
|
67
|
+
private idleRotationCount = 0;
|
|
68
|
+
private currentIdleAnimation: string | null = null;
|
|
69
|
+
private idleRotationLimit = 5; // Number of loops before changing idle animation
|
|
70
|
+
private lastAnimationTime: number | null = null;
|
|
71
|
+
/**
|
|
72
|
+
* Initialize the animator with pre-loaded animations
|
|
73
|
+
* Added protection against multiple initializations
|
|
74
|
+
*/
|
|
75
|
+
async initialize(
|
|
76
|
+
scene: Scene,
|
|
77
|
+
preloadedActions: Record<string, AnimationAction>,
|
|
78
|
+
animations: AnimationClip[] = [],
|
|
79
|
+
avatarType: 'RPM' | 'CUSTOM_GLB' = 'CUSTOM_GLB'
|
|
80
|
+
): Promise<void> {
|
|
81
|
+
// Guard against multiple initializations
|
|
82
|
+
if (this.initialized || this.mixer) {
|
|
83
|
+
console.warn(
|
|
84
|
+
'[AvatarAnimator] Already initialized, ignoring duplicate initialization'
|
|
85
|
+
);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Create a new mixer for the scene
|
|
90
|
+
this.mixer = new AnimationMixer(scene);
|
|
91
|
+
|
|
92
|
+
// Store avatar type
|
|
93
|
+
this.avatarType = avatarType;
|
|
94
|
+
|
|
95
|
+
// Start with empty actions to avoid duplicates
|
|
96
|
+
this.actions = {};
|
|
97
|
+
|
|
98
|
+
// First register animations directly from the model
|
|
99
|
+
this.registerClipsDirectly(animations);
|
|
100
|
+
|
|
101
|
+
// Then register any additional preloaded actions that don't conflict
|
|
102
|
+
// This ensures avatar animations take precedence over fallback animations
|
|
103
|
+
Object.entries(preloadedActions).forEach(([name, action]) => {
|
|
104
|
+
if (!this.actions[name]) {
|
|
105
|
+
this.actions[name] = action;
|
|
106
|
+
this.registerAnimation(name, action);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
// Setup mixer event listeners
|
|
110
|
+
this.setupMixerEvents();
|
|
111
|
+
|
|
112
|
+
// Use direct approach to start animation (bypassing play method initially)
|
|
113
|
+
const idleAnimations = ['Idle1', 'Idle2', 'Idle3', 'Idle4', 'Idle5'];
|
|
114
|
+
let startedSuccessfully = false;
|
|
115
|
+
|
|
116
|
+
for (const idleName of idleAnimations) {
|
|
117
|
+
if (this.actions[idleName]) {
|
|
118
|
+
try {
|
|
119
|
+
const idleAction = this.actions[idleName];
|
|
120
|
+
idleAction.reset();
|
|
121
|
+
idleAction.setEffectiveTimeScale(1);
|
|
122
|
+
idleAction.setEffectiveWeight(1);
|
|
123
|
+
idleAction.setLoop(Infinity, Infinity);
|
|
124
|
+
idleAction.play();
|
|
125
|
+
|
|
126
|
+
// Update state
|
|
127
|
+
this.currentAnimation = idleName;
|
|
128
|
+
this.currentIdleAnimation = idleName;
|
|
129
|
+
this.idleRotationCount = 0;
|
|
130
|
+
|
|
131
|
+
startedSuccessfully = true;
|
|
132
|
+
break;
|
|
133
|
+
} catch (error) {
|
|
134
|
+
console.error(`Error starting ${idleName}:`, error);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (!startedSuccessfully) {
|
|
140
|
+
console.warn(
|
|
141
|
+
'[AvatarAnimator] Could not start any idle animation directly'
|
|
142
|
+
);
|
|
143
|
+
// Don't try fallback methods here to avoid loops
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Mark as initialized AFTER everything is set up
|
|
147
|
+
this.initialized = true;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Register animation clips directly from the model
|
|
152
|
+
* This ensures model animations take priority
|
|
153
|
+
*/
|
|
154
|
+
private registerClipsDirectly(clips: AnimationClip[]): void {
|
|
155
|
+
if (!this.mixer) return;
|
|
156
|
+
|
|
157
|
+
clips.forEach(clip => {
|
|
158
|
+
// Create a new action for each clip
|
|
159
|
+
const action = this.mixer?.clipAction(clip);
|
|
160
|
+
if (!action) {
|
|
161
|
+
console.warn(
|
|
162
|
+
`[AvatarAnimator] Failed to create action for clip: ${clip.name}`
|
|
163
|
+
);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
// Store the action with its name
|
|
167
|
+
this.actions[clip.name] = action;
|
|
168
|
+
// Register the animation metadata
|
|
169
|
+
this.registerAnimation(clip.name, action);
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Register animations with basic metadata inference
|
|
175
|
+
* This version has been modified to avoid duplications and prioritize model animations
|
|
176
|
+
*/
|
|
177
|
+
private registerAnimations(
|
|
178
|
+
actions: Record<string, AnimationAction>,
|
|
179
|
+
clips: AnimationClip[] = []
|
|
180
|
+
): void {
|
|
181
|
+
// console.log('[AvatarAnimator] Registering animations:');
|
|
182
|
+
// console.log(
|
|
183
|
+
// `- Actions from preloaded sources: ${Object.keys(actions).length}`
|
|
184
|
+
// );
|
|
185
|
+
// console.log(`- Clips directly from model: ${clips.length}`);
|
|
186
|
+
|
|
187
|
+
// First identify all animation names to check for duplicates
|
|
188
|
+
const allAnimationNames = new Set<string>();
|
|
189
|
+
|
|
190
|
+
// Add clip names first (higher priority)
|
|
191
|
+
clips.forEach(clip => allAnimationNames.add(clip.name));
|
|
192
|
+
|
|
193
|
+
// Add action names second (lower priority)
|
|
194
|
+
Object.keys(actions).forEach(name => allAnimationNames.add(name));
|
|
195
|
+
|
|
196
|
+
// console.log(`- Total unique animation names: ${allAnimationNames.size}`);
|
|
197
|
+
|
|
198
|
+
// Process the clips first (they have priority)
|
|
199
|
+
clips.forEach(clip => {
|
|
200
|
+
if (this.mixer) {
|
|
201
|
+
// Create action from clip
|
|
202
|
+
const action = this.mixer.clipAction(clip);
|
|
203
|
+
// Store and register
|
|
204
|
+
this.actions[clip.name] = action;
|
|
205
|
+
this.registerAnimation(clip.name, action);
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// Then process any remaining actions that don't conflict with clip names
|
|
210
|
+
Object.entries(actions).forEach(([name, action]) => {
|
|
211
|
+
if (!this.actions[name]) {
|
|
212
|
+
this.actions[name] = action;
|
|
213
|
+
this.registerAnimation(name, action);
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// console.log(
|
|
218
|
+
// `- Final registered animations: ${Object.keys(this.actions).length}`
|
|
219
|
+
// );
|
|
220
|
+
|
|
221
|
+
// Log all registered animations by category
|
|
222
|
+
const idleAnimations = this.getAnimationsByCategory('IDLE');
|
|
223
|
+
const loadingAnimations = this.getAnimationsByCategory('LOADING');
|
|
224
|
+
const actionAnimations = this.getAnimationsByCategory('ACTION');
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Register a single animation with inferred metadata
|
|
229
|
+
*/
|
|
230
|
+
private registerAnimation(name: string, action: AnimationAction): void {
|
|
231
|
+
const duration = action.getClip().duration;
|
|
232
|
+
|
|
233
|
+
// Infer category and loop settings from name
|
|
234
|
+
let category: AnimationCategory = 'ACTION';
|
|
235
|
+
let defaultLoopCount = 1;
|
|
236
|
+
let canLoop = false;
|
|
237
|
+
|
|
238
|
+
// Categorize animations based on name patterns
|
|
239
|
+
const lowerName = name.toLowerCase();
|
|
240
|
+
if (lowerName.includes('idle')) {
|
|
241
|
+
category = 'IDLE';
|
|
242
|
+
defaultLoopCount = 0; // infinite
|
|
243
|
+
canLoop = true;
|
|
244
|
+
} else if (lowerName.includes('loading') || lowerName.includes('wait')) {
|
|
245
|
+
category = 'LOADING';
|
|
246
|
+
defaultLoopCount = 0; // infinite
|
|
247
|
+
canLoop = true;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Store animation info
|
|
251
|
+
this.animations.set(name, {
|
|
252
|
+
name,
|
|
253
|
+
category,
|
|
254
|
+
duration,
|
|
255
|
+
canLoop,
|
|
256
|
+
defaultLoopCount,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Play a specific animation with improved transition handling
|
|
262
|
+
*/
|
|
263
|
+
play(animationName: string, options: AnimationPlayOptions = {}): void {
|
|
264
|
+
try {
|
|
265
|
+
if (!this.initialized || !this.mixer) {
|
|
266
|
+
console.warn(
|
|
267
|
+
`[AvatarAnimator] Cannot play ${animationName} - not initialized`
|
|
268
|
+
);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Check if animation exists
|
|
273
|
+
const nextAction = this.actions[animationName];
|
|
274
|
+
|
|
275
|
+
if (!nextAction) {
|
|
276
|
+
console.warn(`[AvatarAnimator] Animation not found: ${animationName}`);
|
|
277
|
+
if (options.fallbackToIdle !== false) {
|
|
278
|
+
const fallbackAnim = Object.keys(this.actions)[0];
|
|
279
|
+
if (fallbackAnim) {
|
|
280
|
+
this.play(fallbackAnim, { ...options, fallbackToIdle: false });
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Get animation info
|
|
287
|
+
const animInfo = this.getAnimationInfo(animationName);
|
|
288
|
+
if (!animInfo) {
|
|
289
|
+
console.warn(
|
|
290
|
+
`[AvatarAnimator] Animation info not found: ${animationName}`
|
|
291
|
+
);
|
|
292
|
+
if (options.fallbackToIdle !== false) {
|
|
293
|
+
this.idle();
|
|
294
|
+
}
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Skip if the same animation is already playing and is the same category
|
|
299
|
+
if (
|
|
300
|
+
this.currentAnimation === animationName &&
|
|
301
|
+
!this.isTransitioning &&
|
|
302
|
+
options.loopCount === undefined // Only skip if not explicitly changing loop count
|
|
303
|
+
) {
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Prevent overlapping transitions
|
|
308
|
+
if (this.isTransitioning) {
|
|
309
|
+
// Complete any ongoing transition first
|
|
310
|
+
if (this.currentAnimation) {
|
|
311
|
+
const currentAction = this.actions[this.currentAnimation];
|
|
312
|
+
if (currentAction) {
|
|
313
|
+
currentAction.fadeOut(0.1); // Quick fade out of current transition
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Set up transition parameters
|
|
319
|
+
const fadeIn = options.fadeInDuration ?? this.fadeInDuration;
|
|
320
|
+
const fadeOut = options.fadeOutDuration ?? this.fadeOutDuration;
|
|
321
|
+
const loopCount = options.loopCount ?? animInfo.defaultLoopCount;
|
|
322
|
+
const timeScale = options.timeScale ?? this.timeScale;
|
|
323
|
+
|
|
324
|
+
// Check if this is an idle animation
|
|
325
|
+
const isIdleAnimation = animInfo.category === 'IDLE';
|
|
326
|
+
if (isIdleAnimation) {
|
|
327
|
+
this.currentIdleAnimation = animationName;
|
|
328
|
+
this.idleRotationCount = 0;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Emit transition event
|
|
332
|
+
this.emit('transition', {
|
|
333
|
+
from: this.currentAnimation,
|
|
334
|
+
to: animationName,
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
// Fade out current animation if exists
|
|
338
|
+
if (this.currentAnimation) {
|
|
339
|
+
const currentAction = this.actions[this.currentAnimation];
|
|
340
|
+
if (currentAction) {
|
|
341
|
+
// Important: We must stop the action if we're resetting it to the same animation
|
|
342
|
+
if (this.currentAnimation === animationName) {
|
|
343
|
+
currentAction.stop();
|
|
344
|
+
} else {
|
|
345
|
+
currentAction.fadeOut(fadeOut);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Configure next animation
|
|
351
|
+
nextAction.reset();
|
|
352
|
+
nextAction.fadeIn(fadeIn);
|
|
353
|
+
nextAction.timeScale = timeScale;
|
|
354
|
+
|
|
355
|
+
// Ensure action is enabled and weight is reset
|
|
356
|
+
nextAction.enabled = true;
|
|
357
|
+
// nextAction.setEffectiveWeight(0); // Start from zero weight for smooth fade in
|
|
358
|
+
|
|
359
|
+
// Set loop behavior
|
|
360
|
+
if (loopCount === 0) {
|
|
361
|
+
// Infinite looping
|
|
362
|
+
nextAction.setLoop(Infinity, Infinity);
|
|
363
|
+
} else {
|
|
364
|
+
// Limited loops or single play
|
|
365
|
+
nextAction.setLoop(
|
|
366
|
+
loopCount > 1 ? loopCount : LoopOnce,
|
|
367
|
+
loopCount > 1 ? loopCount : 1
|
|
368
|
+
);
|
|
369
|
+
nextAction.clampWhenFinished = true;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Play the animation
|
|
373
|
+
nextAction.play();
|
|
374
|
+
|
|
375
|
+
// Update state
|
|
376
|
+
this.currentAnimation = animationName;
|
|
377
|
+
this.isTransitioning = true;
|
|
378
|
+
|
|
379
|
+
// Clear transition state after the longer of the fade durations
|
|
380
|
+
const transitionDuration = Math.max(fadeIn, fadeOut) * 1000;
|
|
381
|
+
setTimeout(() => {
|
|
382
|
+
this.isTransitioning = false;
|
|
383
|
+
}, transitionDuration);
|
|
384
|
+
|
|
385
|
+
// Emit start event
|
|
386
|
+
this.emit('start', {
|
|
387
|
+
animation: animationName,
|
|
388
|
+
category: animInfo.category,
|
|
389
|
+
loopCount,
|
|
390
|
+
});
|
|
391
|
+
} catch (error) {
|
|
392
|
+
console.error(
|
|
393
|
+
`[AvatarAnimator] Error in play method for ${animationName}:`,
|
|
394
|
+
error
|
|
395
|
+
);
|
|
396
|
+
// Try to recover
|
|
397
|
+
if (options.fallbackToIdle !== false) {
|
|
398
|
+
try {
|
|
399
|
+
this.idle();
|
|
400
|
+
} catch (recoveryError) {
|
|
401
|
+
console.error(
|
|
402
|
+
'[AvatarAnimator] Failed to recover with idle animation:',
|
|
403
|
+
recoveryError
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Execute an animation command (single animation or sequence)
|
|
412
|
+
*/
|
|
413
|
+
execute(command: string): void {
|
|
414
|
+
if (!this.initialized) {
|
|
415
|
+
console.warn('[AvatarAnimator] Cannot execute - not initialized');
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
try {
|
|
420
|
+
// Parse for loop count if specified
|
|
421
|
+
let loopCount: number | undefined;
|
|
422
|
+
const loopMatch = command.match(/\[loop=(\d+)\]/);
|
|
423
|
+
if (loopMatch) {
|
|
424
|
+
loopCount = parseInt(loopMatch[1], 10);
|
|
425
|
+
command = command.replace(loopMatch[0], '').trim();
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Simple sequence parsing with -> operator
|
|
429
|
+
if (command.includes('->')) {
|
|
430
|
+
const sequence = command.split('->').map(s => s.trim());
|
|
431
|
+
this.playSequence(sequence, { loopCount });
|
|
432
|
+
} else {
|
|
433
|
+
// Single animation play
|
|
434
|
+
this.play(command, { loopCount });
|
|
435
|
+
}
|
|
436
|
+
} catch (error) {
|
|
437
|
+
console.error(
|
|
438
|
+
'[AvatarAnimator] Error executing animation command:',
|
|
439
|
+
error
|
|
440
|
+
);
|
|
441
|
+
this.emit('error', { error, command });
|
|
442
|
+
this.idle();
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Enhanced processChatEmission method with improved transition handling
|
|
448
|
+
*/
|
|
449
|
+
processChatEmission(
|
|
450
|
+
chatEmission: string | null | undefined,
|
|
451
|
+
isLoading: boolean
|
|
452
|
+
): void {
|
|
453
|
+
if (!this.initialized) {
|
|
454
|
+
console.warn(
|
|
455
|
+
'[AvatarAnimator] Cannot process chat emission - not initialized'
|
|
456
|
+
);
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// Track whether we're transitioning from loading state
|
|
461
|
+
const wasInLoadingState = this.getAnimationCategory() === 'LOADING';
|
|
462
|
+
|
|
463
|
+
// Handle loading state
|
|
464
|
+
if (isLoading) {
|
|
465
|
+
if (wasInLoadingState) {
|
|
466
|
+
// Already in loading state
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
this.loading();
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// Default to idle if no chat emission
|
|
474
|
+
if (!chatEmission) {
|
|
475
|
+
if (this.getAnimationCategory() === 'IDLE') {
|
|
476
|
+
// Already idle
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
// Use longer transition when coming from loading state
|
|
480
|
+
this.idle(
|
|
481
|
+
wasInLoadingState
|
|
482
|
+
? { fadeInDuration: 1.2, fadeOutDuration: 1.0 }
|
|
483
|
+
: undefined
|
|
484
|
+
);
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Look for animation instructions in various formats
|
|
489
|
+
|
|
490
|
+
// 1. Look for sequence format with specific tag
|
|
491
|
+
const sequenceMatch = chatEmission.match(
|
|
492
|
+
/<output class="animation-sequence">(.*?)<\/output>/
|
|
493
|
+
);
|
|
494
|
+
|
|
495
|
+
// 2. Look for animation tag with optional loop count
|
|
496
|
+
const animationMatch = chatEmission.match(
|
|
497
|
+
/<output class="animation">(.*?)(\[loop=(\d+)\])?<\/output>/
|
|
498
|
+
);
|
|
499
|
+
|
|
500
|
+
// 3. Look for legacy emotion format (for backward compatibility)
|
|
501
|
+
const emotionMatch = chatEmission.match(
|
|
502
|
+
/<output class="memori-emotion">(.*?)<\/output>/
|
|
503
|
+
);
|
|
504
|
+
|
|
505
|
+
// Calculate transition parameters based on current state
|
|
506
|
+
const transitionOptions = this.calculateTransitionOptions();
|
|
507
|
+
|
|
508
|
+
// Process matches in order of priority
|
|
509
|
+
//ex. <output class="animation-sequence">Anger->Sadness->Surprise</output>
|
|
510
|
+
if (sequenceMatch && sequenceMatch[1]) {
|
|
511
|
+
const sequence = sequenceMatch[1].trim();
|
|
512
|
+
|
|
513
|
+
// Check if already playing this sequence
|
|
514
|
+
if (
|
|
515
|
+
this.currentSequence &&
|
|
516
|
+
this.currentSequence.join('->') === sequence &&
|
|
517
|
+
this.sequenceIndex < this.currentSequence.length
|
|
518
|
+
) {
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// Execute sequence with enhanced transition options
|
|
523
|
+
this.executeWithTransition(sequence, transitionOptions);
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
//ex. <output class="animation">Anger</output> OR <output class="animation">[loop=2]Anger</output>
|
|
528
|
+
if (animationMatch && animationMatch[1]) {
|
|
529
|
+
const animation = animationMatch[1].trim();
|
|
530
|
+
let loopCount: number | undefined;
|
|
531
|
+
|
|
532
|
+
// Check for loop count
|
|
533
|
+
if (animationMatch[3]) {
|
|
534
|
+
loopCount = parseInt(animationMatch[3], 10);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// Play with enhanced transition options
|
|
538
|
+
this.play(animation, {
|
|
539
|
+
...transitionOptions,
|
|
540
|
+
loopCount,
|
|
541
|
+
});
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
//ex. <output class="memori-emotion">Anger</output>
|
|
546
|
+
if (emotionMatch && emotionMatch[1]) {
|
|
547
|
+
const emotion = emotionMatch[1].trim();
|
|
548
|
+
console.log('[AvatarAnimator] Processing emotion:', emotion);
|
|
549
|
+
|
|
550
|
+
let matchingAnimations: string[] = [];
|
|
551
|
+
//If the name of the emotion is in english, we can use the emotion mapping to find the corresponding animation
|
|
552
|
+
if (
|
|
553
|
+
MAPPING_EMOTIONS_ITALIAN_TO_ENGLISH.find(
|
|
554
|
+
item => item.english === emotion
|
|
555
|
+
)
|
|
556
|
+
) {
|
|
557
|
+
console.log('[AvatarAnimator] Found emotion in English mapping');
|
|
558
|
+
let matchingEmotions = MAPPING_EMOTIONS_ITALIAN_TO_ENGLISH.filter(
|
|
559
|
+
item => item.english === emotion
|
|
560
|
+
);
|
|
561
|
+
console.log('[AvatarAnimator] Matching emotions:', matchingEmotions);
|
|
562
|
+
matchingAnimations = this.getAllAnimationNames().filter(name =>
|
|
563
|
+
matchingEmotions.some(emotion =>
|
|
564
|
+
name.toLowerCase().startsWith(emotion.italian.toLowerCase())
|
|
565
|
+
)
|
|
566
|
+
);
|
|
567
|
+
} else {
|
|
568
|
+
console.log(
|
|
569
|
+
'[AvatarAnimator] Using generalized emotion matching approach'
|
|
570
|
+
);
|
|
571
|
+
// More generalized approach - try to find any animation that starts with this emotion
|
|
572
|
+
matchingAnimations = this.getAllAnimationNames().filter(name =>
|
|
573
|
+
name.toLowerCase().startsWith(emotion.toLowerCase())
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
console.log(
|
|
578
|
+
'[AvatarAnimator] Found matching animations:',
|
|
579
|
+
matchingAnimations
|
|
580
|
+
);
|
|
581
|
+
|
|
582
|
+
if (matchingAnimations.length > 0) {
|
|
583
|
+
const randomIndex = Math.floor(
|
|
584
|
+
Math.random() * matchingAnimations.length
|
|
585
|
+
);
|
|
586
|
+
const animationToPlay = matchingAnimations[randomIndex];
|
|
587
|
+
console.log(
|
|
588
|
+
'[AvatarAnimator] Selected animation to play:',
|
|
589
|
+
animationToPlay
|
|
590
|
+
);
|
|
591
|
+
|
|
592
|
+
// Play with enhanced transition options
|
|
593
|
+
this.play(animationToPlay, transitionOptions);
|
|
594
|
+
return;
|
|
595
|
+
} else {
|
|
596
|
+
console.log(
|
|
597
|
+
'[AvatarAnimator] No matching animations found for emotion:',
|
|
598
|
+
emotion
|
|
599
|
+
);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// Default to idle if current state is not already idle
|
|
604
|
+
if (this.getAnimationCategory() !== 'IDLE') {
|
|
605
|
+
this.idle(transitionOptions);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* Calculate optimal transition parameters based on current state
|
|
611
|
+
*/
|
|
612
|
+
private calculateTransitionOptions(): AnimationPlayOptions {
|
|
613
|
+
// Start with base transition parameters
|
|
614
|
+
const options: AnimationPlayOptions = {
|
|
615
|
+
fadeInDuration: this.fadeInDuration,
|
|
616
|
+
fadeOutDuration: this.fadeOutDuration,
|
|
617
|
+
timeScale: this.timeScale,
|
|
618
|
+
};
|
|
619
|
+
|
|
620
|
+
// Get current animation state
|
|
621
|
+
const currentCategory = this.getAnimationCategory();
|
|
622
|
+
const currentAction = this.currentAnimation
|
|
623
|
+
? this.actions[this.currentAnimation]
|
|
624
|
+
: null;
|
|
625
|
+
|
|
626
|
+
options.fadeOutDuration = 0.8;
|
|
627
|
+
options.fadeInDuration = 0.8;
|
|
628
|
+
|
|
629
|
+
// Further adjust based on current animation progress
|
|
630
|
+
if (currentAction) {
|
|
631
|
+
const clip = currentAction.getClip();
|
|
632
|
+
const progress = currentAction.time / clip.duration;
|
|
633
|
+
|
|
634
|
+
// If we're near the end of the animation (>75%), slightly faster fade out
|
|
635
|
+
if (progress > 0.75) {
|
|
636
|
+
options.fadeOutDuration = Math.max(
|
|
637
|
+
0.4,
|
|
638
|
+
(options.fadeOutDuration ?? 0.8) * 0.8
|
|
639
|
+
);
|
|
640
|
+
}
|
|
641
|
+
// If we're near the beginning (<25%), slightly faster fade in for new animation
|
|
642
|
+
else if (progress < 0.25) {
|
|
643
|
+
options.fadeInDuration = Math.max(
|
|
644
|
+
0.4,
|
|
645
|
+
(options.fadeInDuration ?? 0.8) * 0.8
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
return options;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* Execute an animation command with enhanced transition handling
|
|
655
|
+
*/
|
|
656
|
+
private executeWithTransition(
|
|
657
|
+
command: string,
|
|
658
|
+
options: AnimationPlayOptions = {}
|
|
659
|
+
): void {
|
|
660
|
+
if (!this.initialized) {
|
|
661
|
+
console.warn('[AvatarAnimator] Cannot execute - not initialized');
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
try {
|
|
666
|
+
// Parse for loop count if specified
|
|
667
|
+
let loopCount: number | undefined;
|
|
668
|
+
const loopMatch = command.match(/\[loop=(\d+)\]/);
|
|
669
|
+
if (loopMatch) {
|
|
670
|
+
loopCount = parseInt(loopMatch[1], 10);
|
|
671
|
+
command = command.replace(loopMatch[0], '').trim();
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// Simple sequence parsing with -> operator
|
|
675
|
+
if (command.includes('->')) {
|
|
676
|
+
const sequence = command.split('->').map(s => s.trim());
|
|
677
|
+
|
|
678
|
+
// Enhanced sequence options
|
|
679
|
+
const sequenceOptions = {
|
|
680
|
+
...options,
|
|
681
|
+
loopCount: loopCount || 1,
|
|
682
|
+
};
|
|
683
|
+
|
|
684
|
+
this.playSequence(sequence, sequenceOptions);
|
|
685
|
+
} else {
|
|
686
|
+
// Single animation play with enhanced options
|
|
687
|
+
this.play(command, {
|
|
688
|
+
...options,
|
|
689
|
+
loopCount,
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
} catch (error) {
|
|
693
|
+
console.error(
|
|
694
|
+
'[AvatarAnimator] Error executing animation command:',
|
|
695
|
+
error
|
|
696
|
+
);
|
|
697
|
+
this.emit('error', { error, command });
|
|
698
|
+
this.idle();
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* Improved idle transition with better animation selection and transitions
|
|
704
|
+
*/
|
|
705
|
+
idle(options: AnimationPlayOptions = {}): void {
|
|
706
|
+
// Get all idle animations
|
|
707
|
+
const idleAnimations = this.getAnimationsByCategory('IDLE');
|
|
708
|
+
|
|
709
|
+
if (idleAnimations.length > 0) {
|
|
710
|
+
// If already in an idle animation, ensure we pick a different one
|
|
711
|
+
let availableIdles = idleAnimations;
|
|
712
|
+
|
|
713
|
+
if (this.getAnimationCategory() === 'IDLE') {
|
|
714
|
+
// Filter out current idle to ensure variety
|
|
715
|
+
availableIdles = idleAnimations.filter(
|
|
716
|
+
info => info.name !== this.currentAnimation
|
|
717
|
+
);
|
|
718
|
+
|
|
719
|
+
// If we've filtered out all options, reset to full list
|
|
720
|
+
if (availableIdles.length === 0) {
|
|
721
|
+
availableIdles = idleAnimations;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// Choose a random idle from available options
|
|
726
|
+
const randomIndex = Math.floor(Math.random() * availableIdles.length);
|
|
727
|
+
const selectedIdle = availableIdles[randomIndex].name;
|
|
728
|
+
|
|
729
|
+
// Apply smooth transition options
|
|
730
|
+
const transitionOptions: AnimationPlayOptions = {
|
|
731
|
+
fadeInDuration: options.fadeInDuration ?? 0.7, // Slower fade for natural idle transition
|
|
732
|
+
fadeOutDuration: options.fadeOutDuration ?? 0.7, // Slower fade for natural idle transition
|
|
733
|
+
timeScale: options.timeScale ?? 0.9, // Slightly slower for more natural movement
|
|
734
|
+
loopCount: 0, // Always infinite for idle
|
|
735
|
+
...options,
|
|
736
|
+
};
|
|
737
|
+
|
|
738
|
+
// Always ensure loopCount is 0 (infinite) for idle animations
|
|
739
|
+
transitionOptions.loopCount = 0;
|
|
740
|
+
|
|
741
|
+
// Play new idle with optimized transition parameters
|
|
742
|
+
this.play(selectedIdle, transitionOptions);
|
|
743
|
+
|
|
744
|
+
// Update idle tracking state
|
|
745
|
+
this.currentIdleAnimation = selectedIdle;
|
|
746
|
+
this.idleRotationCount = 0;
|
|
747
|
+
|
|
748
|
+
return;
|
|
749
|
+
} else {
|
|
750
|
+
// Fallback for avatars without idle animations
|
|
751
|
+
console.warn(
|
|
752
|
+
'[AvatarAnimator] No idle animations available, checking fallback options'
|
|
753
|
+
);
|
|
754
|
+
|
|
755
|
+
// For custom GLB, use any loopable animation
|
|
756
|
+
const loopableAnimations = Array.from(this.animations.values())
|
|
757
|
+
.filter(info => info.canLoop)
|
|
758
|
+
.map(info => info.name);
|
|
759
|
+
|
|
760
|
+
if (loopableAnimations.length > 0) {
|
|
761
|
+
const randomIndex = Math.floor(
|
|
762
|
+
Math.random() * loopableAnimations.length
|
|
763
|
+
);
|
|
764
|
+
const fallbackAnimation = loopableAnimations[randomIndex];
|
|
765
|
+
|
|
766
|
+
// Use smooth transition parameters
|
|
767
|
+
this.play(fallbackAnimation, {
|
|
768
|
+
loopCount: 0,
|
|
769
|
+
fadeInDuration: options.fadeInDuration ?? 0.7,
|
|
770
|
+
fadeOutDuration: options.fadeOutDuration ?? 0.7,
|
|
771
|
+
timeScale: options.timeScale ?? 0.9,
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
this.currentIdleAnimation = fallbackAnimation;
|
|
775
|
+
this.idleRotationCount = 0;
|
|
776
|
+
} else if (Object.keys(this.actions).length > 0) {
|
|
777
|
+
// Last resort: use any available animation
|
|
778
|
+
const firstAnimation = Object.keys(this.actions)[0];
|
|
779
|
+
|
|
780
|
+
this.play(firstAnimation, {
|
|
781
|
+
loopCount: 0,
|
|
782
|
+
fadeInDuration: options.fadeInDuration ?? 0.7,
|
|
783
|
+
fadeOutDuration: options.fadeOutDuration ?? 0.7,
|
|
784
|
+
timeScale: options.timeScale ?? 0.9,
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
this.currentIdleAnimation = firstAnimation;
|
|
788
|
+
this.idleRotationCount = 0;
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
/**
|
|
794
|
+
* Improved loading animation with transition parameters
|
|
795
|
+
*/
|
|
796
|
+
loading(options: AnimationPlayOptions = {}): void {
|
|
797
|
+
const randomLoading = this.getRandomAnimation('LOADING');
|
|
798
|
+
if (randomLoading) {
|
|
799
|
+
// Default transition parameters
|
|
800
|
+
const transitionOptions: AnimationPlayOptions = {
|
|
801
|
+
loopCount: 0, // Always infinite for loading
|
|
802
|
+
fadeInDuration: options.fadeInDuration ?? 0.8,
|
|
803
|
+
fadeOutDuration: options.fadeOutDuration ?? 0.8,
|
|
804
|
+
timeScale: options.timeScale ?? this.timeScale,
|
|
805
|
+
...options,
|
|
806
|
+
};
|
|
807
|
+
|
|
808
|
+
// Always ensure loopCount is 0 (infinite) for loading animations
|
|
809
|
+
transitionOptions.loopCount = 0;
|
|
810
|
+
|
|
811
|
+
this.play(randomLoading, transitionOptions);
|
|
812
|
+
} else {
|
|
813
|
+
console.warn(
|
|
814
|
+
'[AvatarAnimator] No loading animations available, using idle instead'
|
|
815
|
+
);
|
|
816
|
+
this.idle(options);
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* Play a sequence of animations with improved transition handling
|
|
822
|
+
*/
|
|
823
|
+
playSequence(sequence: string[], options: AnimationPlayOptions = {}): void {
|
|
824
|
+
if (!sequence || sequence.length === 0) {
|
|
825
|
+
console.warn('[AvatarAnimator] Empty animation sequence provided');
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
// Limit sequence length to prevent performance issues
|
|
830
|
+
if (sequence.length > 5) {
|
|
831
|
+
console.warn(
|
|
832
|
+
`[AvatarAnimator] Sequence too long (${sequence.length}), limiting to 5 animations`
|
|
833
|
+
);
|
|
834
|
+
sequence = sequence.slice(0, 5);
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// Validate all animations exist
|
|
838
|
+
const validSequence = sequence.filter(name => this.actions[name]);
|
|
839
|
+
|
|
840
|
+
if (validSequence.length === 0) {
|
|
841
|
+
console.error(
|
|
842
|
+
'[AvatarAnimator] No valid animations in sequence, defaulting to idle'
|
|
843
|
+
);
|
|
844
|
+
this.idle();
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// Complete any ongoing transition first
|
|
849
|
+
if (this.isTransitioning) {
|
|
850
|
+
// If we're already transitioning, let's make sure it completes
|
|
851
|
+
// before starting the sequence
|
|
852
|
+
setTimeout(() => {
|
|
853
|
+
this.playSequence(sequence, options);
|
|
854
|
+
}, 100);
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// Store sequence info
|
|
859
|
+
this.currentSequence = [...validSequence];
|
|
860
|
+
this.sequenceIndex = 0;
|
|
861
|
+
|
|
862
|
+
// Use optimized transition parameters for first animation in sequence
|
|
863
|
+
const firstAnimationOptions = {
|
|
864
|
+
fadeInDuration: 0.6, // Smoother entry into sequence
|
|
865
|
+
fadeOutDuration: 0.6, // Smoother exit from current animation
|
|
866
|
+
loopCount: 1,
|
|
867
|
+
timeScale: options.timeScale ?? this.timeScale,
|
|
868
|
+
};
|
|
869
|
+
|
|
870
|
+
// Play first animation
|
|
871
|
+
const firstAnimation = validSequence[0];
|
|
872
|
+
this.play(firstAnimation, firstAnimationOptions);
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
/**
|
|
876
|
+
* FIXED: Added a method to force transition to idle, useful for debugging
|
|
877
|
+
*/
|
|
878
|
+
forceIdle(): void {
|
|
879
|
+
// console.log('[AvatarAnimator] Force transitioning to idle');
|
|
880
|
+
|
|
881
|
+
const idleAnimations = this.getAnimationsByCategory('IDLE');
|
|
882
|
+
if (idleAnimations.length > 0) {
|
|
883
|
+
// Just pick the first idle animation
|
|
884
|
+
const forcedIdle = idleAnimations[0].name;
|
|
885
|
+
|
|
886
|
+
// Force play with immediate transition
|
|
887
|
+
this.play(forcedIdle, {
|
|
888
|
+
loopCount: 0,
|
|
889
|
+
fadeInDuration: 0.8,
|
|
890
|
+
fadeOutDuration: 0.8,
|
|
891
|
+
});
|
|
892
|
+
|
|
893
|
+
// Update state
|
|
894
|
+
this.currentIdleAnimation = forcedIdle;
|
|
895
|
+
this.idleRotationCount = 0;
|
|
896
|
+
|
|
897
|
+
// console.log(`[AvatarAnimator] Forced idle transition to: ${forcedIdle}`);
|
|
898
|
+
} else {
|
|
899
|
+
console.error(
|
|
900
|
+
'[AvatarAnimator] No idle animations available for forced transition'
|
|
901
|
+
);
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
/**
|
|
906
|
+
* Updates animation system with better transition handling
|
|
907
|
+
*/
|
|
908
|
+
update(delta: number): void {
|
|
909
|
+
if (!this.initialized || !this.mixer) return;
|
|
910
|
+
|
|
911
|
+
// Clamp delta to prevent extreme time jumps which cause jerky transitions
|
|
912
|
+
const clampedDelta = Math.min(delta, 0.1);
|
|
913
|
+
|
|
914
|
+
// Update the mixer with clamped delta
|
|
915
|
+
this.mixer.update(clampedDelta);
|
|
916
|
+
|
|
917
|
+
// Skip other processing during active transitions
|
|
918
|
+
if (this.isTransitioning) {
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// Handle sequence progression
|
|
923
|
+
if (this.currentSequence && this.currentAnimation) {
|
|
924
|
+
const currentAction = this.actions[this.currentAnimation];
|
|
925
|
+
if (currentAction) {
|
|
926
|
+
const clipDuration = currentAction.getClip().duration;
|
|
927
|
+
const progress = currentAction.time / clipDuration;
|
|
928
|
+
|
|
929
|
+
// If near end of animation and not already transitioning to next
|
|
930
|
+
if (progress > 0.85 && !this.isTransitioning) {
|
|
931
|
+
if (this.sequenceIndex < this.currentSequence.length - 1) {
|
|
932
|
+
// Move to next animation in sequence with smooth transition
|
|
933
|
+
this.sequenceIndex++;
|
|
934
|
+
const nextAnimation = this.currentSequence[this.sequenceIndex];
|
|
935
|
+
this.play(nextAnimation, {
|
|
936
|
+
fadeInDuration: 0.5, // Longer fade for smoother transitions
|
|
937
|
+
fadeOutDuration: 0.5, // Longer fade for smoother transitions
|
|
938
|
+
loopCount: 1,
|
|
939
|
+
});
|
|
940
|
+
} else {
|
|
941
|
+
// End of sequence - return to idle with smooth transition
|
|
942
|
+
this.currentSequence = null;
|
|
943
|
+
this.sequenceIndex = 0;
|
|
944
|
+
this.idle({
|
|
945
|
+
fadeInDuration: 0.7,
|
|
946
|
+
fadeOutDuration: 0.7,
|
|
947
|
+
});
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
// Handle idle rotation
|
|
954
|
+
if (
|
|
955
|
+
this.currentAnimation &&
|
|
956
|
+
this.currentIdleAnimation &&
|
|
957
|
+
this.getAnimationCategory() === 'IDLE'
|
|
958
|
+
) {
|
|
959
|
+
const currentAction = this.actions[this.currentAnimation];
|
|
960
|
+
if (currentAction) {
|
|
961
|
+
const clipDuration = currentAction.getClip().duration;
|
|
962
|
+
const currentTime = currentAction.time % clipDuration;
|
|
963
|
+
const previousTime = this.lastAnimationTime || 0;
|
|
964
|
+
|
|
965
|
+
// Detect loop completion (time wraps around)
|
|
966
|
+
if (previousTime > currentTime + 0.1) {
|
|
967
|
+
this.idleRotationCount++;
|
|
968
|
+
|
|
969
|
+
// Change idle animation after certain number of loops
|
|
970
|
+
if (this.idleRotationCount >= this.idleRotationLimit) {
|
|
971
|
+
this.idleRotationCount = 0;
|
|
972
|
+
this.idle({
|
|
973
|
+
fadeInDuration: 0.6, // Smooth transition between idles
|
|
974
|
+
fadeOutDuration: 0.6, // Smooth transition between idles
|
|
975
|
+
});
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
// Store time for next comparison
|
|
980
|
+
this.lastAnimationTime = currentTime;
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
// Add this helper method to check if an animation is actually playing
|
|
986
|
+
private isAnimationPlaying(animationName: string): boolean {
|
|
987
|
+
if (!this.actions[animationName]) return false;
|
|
988
|
+
|
|
989
|
+
const action = this.actions[animationName];
|
|
990
|
+
return action.isRunning() && action.getEffectiveWeight() > 0.1;
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
/**
|
|
994
|
+
* Set up event listeners for the animation mixer with improved transition handling
|
|
995
|
+
*/
|
|
996
|
+
private setupMixerEvents(): void {
|
|
997
|
+
if (!this.mixer) {
|
|
998
|
+
console.warn(
|
|
999
|
+
'[AvatarAnimator] Cannot setup mixer events - mixer not initialized'
|
|
1000
|
+
);
|
|
1001
|
+
return;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
// Listen for animation loops
|
|
1005
|
+
this.mixer.addEventListener('loop', event => {
|
|
1006
|
+
const action = event.action as AnimationAction;
|
|
1007
|
+
if (!action || !this.currentAnimation) return;
|
|
1008
|
+
|
|
1009
|
+
if (action === this.actions[this.currentAnimation]) {
|
|
1010
|
+
this.emit('loop', { animation: this.currentAnimation });
|
|
1011
|
+
}
|
|
1012
|
+
});
|
|
1013
|
+
|
|
1014
|
+
// Listen for animation completion with improved transition handling
|
|
1015
|
+
this.mixer.addEventListener('finished', event => {
|
|
1016
|
+
const action = event.action as AnimationAction;
|
|
1017
|
+
if (!action || !this.currentAnimation) return;
|
|
1018
|
+
|
|
1019
|
+
if (action === this.actions[this.currentAnimation]) {
|
|
1020
|
+
// Prevent multiple completion handlers during transition
|
|
1021
|
+
if (this.isTransitioning) {
|
|
1022
|
+
return;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
this.emit('complete', { animation: this.currentAnimation });
|
|
1026
|
+
|
|
1027
|
+
// Add a small delay to ensure clean transition
|
|
1028
|
+
setTimeout(() => {
|
|
1029
|
+
// Handle sequence progression
|
|
1030
|
+
if (
|
|
1031
|
+
this.currentSequence &&
|
|
1032
|
+
this.sequenceIndex < this.currentSequence.length - 1
|
|
1033
|
+
) {
|
|
1034
|
+
// Proceed to next animation in sequence with smooth transition
|
|
1035
|
+
this.sequenceIndex++;
|
|
1036
|
+
this.play(this.currentSequence[this.sequenceIndex], {
|
|
1037
|
+
fadeInDuration: 0.5, // Increased for smoother transitions
|
|
1038
|
+
fadeOutDuration: 0.5, // Increased for smoother transitions
|
|
1039
|
+
loopCount: 1,
|
|
1040
|
+
});
|
|
1041
|
+
} else if (
|
|
1042
|
+
this.currentSequence &&
|
|
1043
|
+
this.sequenceIndex >= this.currentSequence.length - 1
|
|
1044
|
+
) {
|
|
1045
|
+
// End of sequence - return to idle with smooth transition
|
|
1046
|
+
this.currentSequence = null;
|
|
1047
|
+
this.sequenceIndex = 0;
|
|
1048
|
+
this.idle({
|
|
1049
|
+
fadeInDuration: 0.7,
|
|
1050
|
+
fadeOutDuration: 0.7,
|
|
1051
|
+
});
|
|
1052
|
+
} else if (
|
|
1053
|
+
this.currentAnimation &&
|
|
1054
|
+
this.getAnimationInfo(this.currentAnimation)?.category !== 'IDLE'
|
|
1055
|
+
) {
|
|
1056
|
+
// Non-idle animation completed - return to idle with smooth transition
|
|
1057
|
+
this.idle({
|
|
1058
|
+
fadeInDuration: 0.7,
|
|
1059
|
+
fadeOutDuration: 0.7,
|
|
1060
|
+
});
|
|
1061
|
+
}
|
|
1062
|
+
}, 50); // Small delay to ensure smooth timing
|
|
1063
|
+
}
|
|
1064
|
+
});
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
/**
|
|
1068
|
+
* Handle sequence progression if needed
|
|
1069
|
+
*/
|
|
1070
|
+
private handleSequenceProgressionIfNeeded(): void {
|
|
1071
|
+
if (
|
|
1072
|
+
!this.currentSequence ||
|
|
1073
|
+
!this.currentAnimation ||
|
|
1074
|
+
this.sequenceIndex >= this.currentSequence.length - 1
|
|
1075
|
+
) {
|
|
1076
|
+
return;
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
const currentAction = this.actions[this.currentAnimation];
|
|
1080
|
+
if (!currentAction) return;
|
|
1081
|
+
|
|
1082
|
+
const clipDuration = currentAction.getClip().duration;
|
|
1083
|
+
const progress = currentAction.time / clipDuration;
|
|
1084
|
+
|
|
1085
|
+
// If animation is near completion and not already transitioning
|
|
1086
|
+
if (progress > 0.9 && !this.isTransitioning) {
|
|
1087
|
+
this.sequenceIndex++;
|
|
1088
|
+
|
|
1089
|
+
if (this.sequenceIndex < this.currentSequence.length) {
|
|
1090
|
+
// Move to next animation in sequence
|
|
1091
|
+
const nextAnimation = this.currentSequence[this.sequenceIndex];
|
|
1092
|
+
this.play(nextAnimation, {
|
|
1093
|
+
fadeInDuration: 0.3, // Use consistent short fade times for sequences
|
|
1094
|
+
fadeOutDuration: 0.3,
|
|
1095
|
+
loopCount: 1,
|
|
1096
|
+
});
|
|
1097
|
+
} else {
|
|
1098
|
+
// End of sequence
|
|
1099
|
+
this.currentSequence = null;
|
|
1100
|
+
this.sequenceIndex = 0;
|
|
1101
|
+
this.idle();
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
/**
|
|
1107
|
+
* Handle idle rotation if needed
|
|
1108
|
+
*/
|
|
1109
|
+
private handleIdleRotationIfNeeded(): void {
|
|
1110
|
+
// Only apply to idle animations
|
|
1111
|
+
if (
|
|
1112
|
+
!this.currentAnimation ||
|
|
1113
|
+
!this.currentIdleAnimation ||
|
|
1114
|
+
this.getAnimationCategory() !== 'IDLE'
|
|
1115
|
+
) {
|
|
1116
|
+
return;
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
const currentAction = this.actions[this.currentAnimation];
|
|
1120
|
+
if (!currentAction) return;
|
|
1121
|
+
|
|
1122
|
+
// Detect loops in idle animations
|
|
1123
|
+
const clipDuration = currentAction.getClip().duration;
|
|
1124
|
+
const currentTime = currentAction.time % clipDuration;
|
|
1125
|
+
const previousTime = this.lastAnimationTime || 0;
|
|
1126
|
+
|
|
1127
|
+
// Loop detected if time resets (goes from high to low)
|
|
1128
|
+
if (previousTime > currentTime + 0.1) {
|
|
1129
|
+
// Add small buffer to handle precision issues
|
|
1130
|
+
this.idleRotationCount++;
|
|
1131
|
+
|
|
1132
|
+
// Change idle animation after certain number of loops
|
|
1133
|
+
if (this.idleRotationCount >= this.idleRotationLimit) {
|
|
1134
|
+
this.idleRotationCount = 0;
|
|
1135
|
+
this.idle();
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
// Store time for next comparison
|
|
1140
|
+
this.lastAnimationTime = currentTime;
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
/**
|
|
1144
|
+
* Set the animation time scale
|
|
1145
|
+
*/
|
|
1146
|
+
setTimeScale(timeScale: number): void {
|
|
1147
|
+
// console.log('[AvatarAnimator] Setting time scale:', timeScale);
|
|
1148
|
+
this.timeScale = timeScale;
|
|
1149
|
+
|
|
1150
|
+
// Update current animation if exists
|
|
1151
|
+
if (this.currentAnimation) {
|
|
1152
|
+
const currentAction = this.actions[this.currentAnimation];
|
|
1153
|
+
if (currentAction) {
|
|
1154
|
+
currentAction.timeScale = timeScale;
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
/**
|
|
1160
|
+
* Get a random animation by category
|
|
1161
|
+
*/
|
|
1162
|
+
private getRandomAnimation(
|
|
1163
|
+
category: AnimationCategory,
|
|
1164
|
+
exclude: (string | null)[] = []
|
|
1165
|
+
): string | null {
|
|
1166
|
+
const filteredAnimations = Array.from(this.animations.values()).filter(
|
|
1167
|
+
info => info.category === category && !exclude.includes(info.name)
|
|
1168
|
+
);
|
|
1169
|
+
|
|
1170
|
+
if (filteredAnimations.length === 0) return null;
|
|
1171
|
+
|
|
1172
|
+
const randomIndex = Math.floor(Math.random() * filteredAnimations.length);
|
|
1173
|
+
return filteredAnimations[randomIndex].name;
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
/**
|
|
1177
|
+
* Get animation info by name
|
|
1178
|
+
*/
|
|
1179
|
+
private getAnimationInfo(name: string): AnimationInfo | null {
|
|
1180
|
+
return this.animations.get(name) || null;
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
/**
|
|
1184
|
+
* Get the current animation category
|
|
1185
|
+
*/
|
|
1186
|
+
private getAnimationCategory(): AnimationCategory | null {
|
|
1187
|
+
if (!this.currentAnimation) return null;
|
|
1188
|
+
return this.getAnimationInfo(this.currentAnimation)?.category || null;
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
/**
|
|
1192
|
+
* Event system methods
|
|
1193
|
+
*/
|
|
1194
|
+
on(
|
|
1195
|
+
event: 'start' | 'complete' | 'loop' | 'transition' | 'error',
|
|
1196
|
+
callback: (data: any) => void
|
|
1197
|
+
): void {
|
|
1198
|
+
if (this.eventListeners[event]) {
|
|
1199
|
+
this.eventListeners[event].push(callback);
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
off(
|
|
1204
|
+
event: 'start' | 'complete' | 'loop' | 'transition' | 'error',
|
|
1205
|
+
callback: (data: any) => void
|
|
1206
|
+
): void {
|
|
1207
|
+
if (this.eventListeners[event]) {
|
|
1208
|
+
this.eventListeners[event] = this.eventListeners[event].filter(
|
|
1209
|
+
cb => cb !== callback
|
|
1210
|
+
);
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
private emit(event: string, data: any): void {
|
|
1215
|
+
if (this.eventListeners[event]) {
|
|
1216
|
+
this.eventListeners[event].forEach(callback => {
|
|
1217
|
+
try {
|
|
1218
|
+
callback(data);
|
|
1219
|
+
} catch (error) {
|
|
1220
|
+
console.error(`Error in ${event} event handler:`, error);
|
|
1221
|
+
}
|
|
1222
|
+
});
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
/**
|
|
1227
|
+
* Utility getters
|
|
1228
|
+
*/
|
|
1229
|
+
getCurrentAnimationName(): string | null {
|
|
1230
|
+
return this.currentAnimation;
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
getAvatarType(): 'RPM' | 'CUSTOM_GLB' {
|
|
1234
|
+
return this.avatarType;
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
isInitialized(): boolean {
|
|
1238
|
+
return this.initialized;
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
getAllAnimationNames(): string[] {
|
|
1242
|
+
return Array.from(this.animations.keys());
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
getAnimationsByCategory(category: AnimationCategory): AnimationInfo[] {
|
|
1246
|
+
return Array.from(this.animations.values()).filter(
|
|
1247
|
+
info => info.category === category
|
|
1248
|
+
);
|
|
1249
|
+
}
|
|
1250
|
+
}
|