fix: /friend motion — interrupt hold on new action, drain queue, fix emotion mapping
Browse files- MotionController: interrupt hold when new playAction arrives instead of
queueing, so LLM text-message actions are not blocked by stale 10s hold
- MotionController: drain action queue in hold timer callback to prevent
silent drop of queued actions
- App.tsx: remove hold=true from emotion-only messages — action plays once,
emotion timer handles duration independently
- App.tsx: remove awkward emotion→action mappings (neutral→salute,
relaxed→salute, sad→shy) that caused unnatural gestures
Co-Authored-By: Claude Big Pickle <noreply@anthropic.com>
src/components/friend/frontend/App.tsx
CHANGED
|
@@ -14,6 +14,8 @@ import { Menu, Move, Rotate3D, EyeOff, Settings, RefreshCw, Pin } from 'lucide-r
|
|
| 14 |
const DEFAULT_MODEL = '/friend/model1.vrm'
|
| 15 |
|
| 16 |
// 情绪 → 动作映射
|
|
|
|
|
|
|
| 17 |
const emotionActionMap: Record<string, string> = {
|
| 18 |
think: 'scratchHead',
|
| 19 |
question: 'point',
|
|
@@ -22,12 +24,12 @@ const emotionActionMap: Record<string, string> = {
|
|
| 22 |
surprised: 'excited',
|
| 23 |
angry: 'angry',
|
| 24 |
awkward: 'playFingers',
|
| 25 |
-
sad: 'shy',
|
| 26 |
love: 'shy',
|
| 27 |
flirty: 'shy',
|
| 28 |
greeting: 'greeting',
|
| 29 |
-
|
| 30 |
-
|
|
|
|
| 31 |
}
|
| 32 |
|
| 33 |
const btnStyle: React.CSSProperties = {
|
|
@@ -122,7 +124,10 @@ export default function App() {
|
|
| 122 |
if (action) sceneRef.current.playAction(action)
|
| 123 |
} else {
|
| 124 |
sceneRef.current.setEmotionWithReset(msg.emotion, msg.emotionDuration ?? 10000, msg.emotionIntensity)
|
| 125 |
-
|
|
|
|
|
|
|
|
|
|
| 126 |
}
|
| 127 |
}
|
| 128 |
}, [])
|
|
|
|
| 14 |
const DEFAULT_MODEL = '/friend/model1.vrm'
|
| 15 |
|
| 16 |
// 情绪 → 动作映射
|
| 17 |
+
// 只有有明确肢体动作关联的情绪才映射;neutral/relaxed 不绑动作,
|
| 18 |
+
// 避免角色在无明确意图时做出违和姿势。
|
| 19 |
const emotionActionMap: Record<string, string> = {
|
| 20 |
think: 'scratchHead',
|
| 21 |
question: 'point',
|
|
|
|
| 24 |
surprised: 'excited',
|
| 25 |
angry: 'angry',
|
| 26 |
awkward: 'playFingers',
|
|
|
|
| 27 |
love: 'shy',
|
| 28 |
flirty: 'shy',
|
| 29 |
greeting: 'greeting',
|
| 30 |
+
sad: '',
|
| 31 |
+
relaxed: '',
|
| 32 |
+
neutral: '',
|
| 33 |
}
|
| 34 |
|
| 35 |
const btnStyle: React.CSSProperties = {
|
|
|
|
| 124 |
if (action) sceneRef.current.playAction(action)
|
| 125 |
} else {
|
| 126 |
sceneRef.current.setEmotionWithReset(msg.emotion, msg.emotionDuration ?? 10000, msg.emotionIntensity)
|
| 127 |
+
// No hold — action plays once, emotion timer handles duration.
|
| 128 |
+
// Hold would lock _actionPlaying for 10s, blocking text-message actions
|
| 129 |
+
// when friend_emotion tool fires before broadcastResponse.
|
| 130 |
+
if (action) sceneRef.current.playAction(action)
|
| 131 |
}
|
| 132 |
}
|
| 133 |
}, [])
|
src/components/friend/frontend/motion-controller.ts
CHANGED
|
@@ -187,9 +187,16 @@ export class MotionController {
|
|
| 187 |
const preset = actionPresets[name]
|
| 188 |
if (!preset) { console.warn('[Motion] unknown action:', name); return }
|
| 189 |
|
| 190 |
-
// If
|
| 191 |
-
|
| 192 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 193 |
const last = this.actionQueue[this.actionQueue.length - 1]
|
| 194 |
if (!last || last.name !== name) {
|
| 195 |
this.actionQueue.push({ name, hold })
|
|
@@ -247,6 +254,15 @@ export class MotionController {
|
|
| 247 |
if (hold) {
|
| 248 |
this.holdTimer = setTimeout(() => {
|
| 249 |
if (gen !== this._actionGeneration) return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 250 |
this._actionPlaying = false
|
| 251 |
this.startIdle()
|
| 252 |
}, 10000)
|
|
|
|
| 187 |
const preset = actionPresets[name]
|
| 188 |
if (!preset) { console.warn('[Motion] unknown action:', name); return }
|
| 189 |
|
| 190 |
+
// If currently holding (pose frozen, not actively animating), interrupt hold
|
| 191 |
+
// and play the new action immediately. This prevents LLM text-message actions
|
| 192 |
+
// from being blocked by a stale 10s hold timer.
|
| 193 |
+
if (this._actionPlaying && this.holdTimer !== null) {
|
| 194 |
+
this.clearTimers()
|
| 195 |
+
this._actionPlaying = false
|
| 196 |
+
// fall through to play new action
|
| 197 |
+
} else if (this._actionPlaying) {
|
| 198 |
+
// Normal queue: only when an action is actively animating (e.g. two rapid
|
| 199 |
+
// LLM tool calls). Hold does NOT queue — it interrupts.
|
| 200 |
const last = this.actionQueue[this.actionQueue.length - 1]
|
| 201 |
if (!last || last.name !== name) {
|
| 202 |
this.actionQueue.push({ name, hold })
|
|
|
|
| 254 |
if (hold) {
|
| 255 |
this.holdTimer = setTimeout(() => {
|
| 256 |
if (gen !== this._actionGeneration) return
|
| 257 |
+
this.clearTimers()
|
| 258 |
+
// Drain any actions queued during hold before going idle —
|
| 259 |
+
// prevents the queue leak where LLM actions are silently dropped.
|
| 260 |
+
const queued = this.actionQueue.shift()
|
| 261 |
+
if (queued) {
|
| 262 |
+
this._actionPlaying = false
|
| 263 |
+
this.playAction(queued.name, queued.hold)
|
| 264 |
+
return
|
| 265 |
+
}
|
| 266 |
this._actionPlaying = false
|
| 267 |
this.startIdle()
|
| 268 |
}, 10000)
|