diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 26e2d732..0e43bdad 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -24,6 +24,14 @@ - 验证方式:执行 `npm run test -- src\services\input-devices\runtimeDragInputController.test.ts`、`npm run test -- src\components\puzzle-runtime\PuzzleRuntimeShell.test.tsx`、`npm run typecheck` 和编码检查。 - 关联文档:`docs/technical/RUNTIME_INPUT_DEVICE_ABSTRACTION_2026-05-10.md`、`docs/technical/PUZZLE_RUNTIME_FRONTEND_LOGIC_REHOME_2026-05-02.md`。 +## 2026-05-10 儿童动作热身关直接消费 mocap 手势流 + +- 背景:儿童动作 Demo 的挥手、左右手挥动和跳跃阶段不能只依赖键鼠调试输入,否则真实硬件接入后会出现“能看到画面但动作不推进”的卡点。 +- 决策:热身关在 gesture 阶段直接接入 `useMocapInput`,通过本地 mocap WebSocket `/stream` 消费 `actions/action/gesture/gestures/event/name/type` 动作名,以及 `hands[]`、`leftHand/rightHand`、`left_hand/right_hand` 手部坐标;`wave_greeting`、`wave_left_hand`、`wave_right_hand` 和 `jump_once` 都可以由 mocap 包推进,同时保留键鼠作为本地调试兜底。 +- 影响范围:`src/services/useMocapInput.ts`、`src/components/child-motion-demo/ChildMotionWarmupDemo.tsx`、对应单测与热身关技术文档。 +- 验证方式:执行 `npx vitest run src/components/child-motion-demo/ChildMotionWarmupDemo.test.tsx src/components/child-motion-demo/childMotionWarmupModel.test.ts src/services/child-motion-demo/childMotionDebugInput.test.ts src/routing/appRoutes.test.ts`、`npx eslint ...`、`npm run typecheck`、`npm run check:encoding`,并确认 `http://127.0.0.1:3000/child-motion-demo` 与 `http://127.0.0.1:3100/healthz` 可访问。 +- 关联文档:`docs/technical/CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md`。 + ## 2026-05-09 GPT-image-2 图片生成统一迁移到 VectorEngine - 背景:仓库内 RPG、拼图、方洞和本地模板脚本的 GPT-image-2 生图此前依赖 APIMart 图片网关;团队要求参考 VectorEngine Apifox `api-448710071`,后续不再使用 APIMart 执行 GPT-image-2 图片生成。 diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index 0ae4aa1f..3f351408 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -43,6 +43,14 @@ - 验证:提交前检查 `git diff -- .hermes`,确认没有密钥、会话记录或个人路径敏感信息。 - 关联:`.hermes/README.md`。 +## 儿童动作 Demo 挥手阶段不推进先查 mocap 消费链路 + +- 现象:`/child-motion-demo` 能打开摄像头画面,但到“打个招呼”或左右手挥动阶段时,真实硬件动作无法检测通过,只能用鼠标拖拽或键盘调试继续。 +- 原因:摄像头视频流只是舞台背景;如果热身关没有消费 `useMocapInput` 的动作名和手部坐标,就不会把硬件动作转换成热身状态机完成事件。 +- 处理:确认 `src/components/child-motion-demo/ChildMotionWarmupDemo.tsx` 在 `step.kind === 'gesture'` 时启用 `useMocapInput`;确认 `src/services/useMocapInput.ts` 能解析 `/stream` 包里的 `actions/action/gesture/gestures/event/name/type`、`hands[]`、`leftHand/rightHand`、`left_hand/right_hand`、左右手标记和 `open_palm/grab` 状态。热身关应由 mocap 推进,键鼠只作为本地调试兜底。 +- 验证:运行 `npx vitest run src\components\child-motion-demo\ChildMotionWarmupDemo.test.tsx`,并在本地硬件服务启动后进入 `/child-motion-demo` 实测招手、左右手挥动和跳跃阶段。 +- 关联:`src/services/useMocapInput.ts`、`src/components/child-motion-demo/ChildMotionWarmupDemo.tsx`、`docs/technical/CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md`。 + ## GPT-image-2 不再读 APIMart 图片配置 - 现象:配置了 `APIMART_BASE_URL` / `APIMART_API_KEY` 后,RPG、拼图或方洞的 GPT-image-2 生图仍返回缺配置,或请求体里还出现 `official_fallback` / `image_urls`。 diff --git a/docs/technical/CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md b/docs/technical/CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md index 6f10423e..bc4cac9b 100644 --- a/docs/technical/CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md +++ b/docs/technical/CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md @@ -21,7 +21,7 @@ 9. 后续关卡使用热身记录的边界进行安全提醒和暂停恢复。 10. 热身结束后进入关卡选择。 -当前阶段先落浏览器本地 Demo。浏览器摄像头视频流已接入舞台背景;摄像头硬件动作识别 SDK、正式动作识别接口和正式语音播报接口继续预留适配层,不阻塞前端热身流程、调试输入和页面表现骨架落地。 +当前阶段先落浏览器本地 Demo。浏览器摄像头视频流已接入舞台背景;热身动作阶段已接入本地 mocap 动作检测接口,通过 `useMocapInput` 连接 `http://127.0.0.1:8876/stream` 对应的 WebSocket 流,消费手势、左右手坐标和跳跃事件推进招手、左右手挥动与原地跳跃步骤。正式语音播报接口继续预留适配层,不阻塞前端热身流程、调试输入和页面表现骨架落地。 ## 2. 非目标范围 @@ -654,12 +654,21 @@ 4. 鼠标右键按下并拖动映射右手轨迹。 5. 空格键映射原地跳跃。 +当前硬件和动作检测接口接入: + +1. 浏览器摄像头视频流已接入舞台背景。 +2. 热身关手势阶段已通过 `src/services/useMocapInput.ts` 接入本地 mocap WebSocket `/stream`。 +3. mocap 包支持从 `actions/action/gesture/gestures/event/name/type` 读取动作名,并支持 `hands[]`、`leftHand/rightHand`、`left_hand/right_hand` 读取左右手坐标。 +4. `wave_greeting` 可由 `wave/wave_greeting/hand_wave/open_palm` 等动作或 open palm 手势完成。 +5. `wave_left_hand` 和 `wave_right_hand` 优先消费对应左右手动作名;当硬件只持续输出手部坐标时,也可以根据连续手部横向轨迹完成挥手检测。 +6. `jump_once` 消费 `jump/jump_once/hop` 等跳跃动作事件完成。 +7. 键盘 `A/D/Space` 与鼠标左右键拖拽仍保留为本地 Demo 调试兜底,不代表正式硬件口径。 + 当前未接入但已保留边界: -1. 浏览器摄像头视频流已接入;硬件动作识别 SDK 和正式动作识别接口暂不接入,后续通过动作输入适配层替换或并行接入调试输入。 -2. 正式语音播报接口暂不接入,当前先展示热身文案。 -3. 正式 gpt-image-2 视觉资源暂不接入,当前使用 CSS 占位表达相同位置和状态。 -4. 后续关卡安全边界暂停逻辑暂未落地,当前只完成热身记录和下一关按钮占位。 +1. 正式语音播报接口暂不接入,当前先展示热身文案。 +2. 正式 gpt-image-2 视觉资源暂不接入,当前使用 CSS 占位表达相同位置和状态。 +3. 后续关卡安全边界暂停逻辑暂未落地,当前只完成热身记录和下一关按钮占位。 已执行的定向验证命令: diff --git a/src/components/child-motion-demo/ChildMotionWarmupDemo.test.tsx b/src/components/child-motion-demo/ChildMotionWarmupDemo.test.tsx index e1912ba5..3b1bfd76 100644 --- a/src/components/child-motion-demo/ChildMotionWarmupDemo.test.tsx +++ b/src/components/child-motion-demo/ChildMotionWarmupDemo.test.tsx @@ -1,6 +1,6 @@ /* @vitest-environment jsdom */ -import { fireEvent, render, screen } from '@testing-library/react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; import { afterEach, beforeEach, expect, test, vi } from 'vitest'; import { ChildMotionWarmupDemo } from './ChildMotionWarmupDemo'; @@ -9,9 +9,40 @@ import { resetChildMotionWarmupRuntimeSession, } from './childMotionWarmupModel'; +const mocapMock = vi.hoisted(() => ({ + status: 'connected' as 'idle' | 'connecting' | 'connected' | 'error', + command: null as null | { + actions: string[]; + hands?: Array<{ x: number; y: number; state: string; side: string }>; + primaryHand?: { x: number; y: number; state: string; side: string } | null; + leftHand?: { x: number; y: number; state: string; side: string } | null; + rightHand?: { x: number; y: number; state: string; side: string } | null; + bodyCenter?: { x: number; y: number } | null; + }, + receivedAtMs: 1, +})); + +vi.mock('../../services/useMocapInput', () => ({ + useMocapInput: ({ enabled }: { enabled: boolean }) => ({ + status: enabled ? mocapMock.status : 'idle', + latestCommand: enabled ? mocapMock.command : null, + rawPacketPreview: + enabled && mocapMock.command + ? { + text: JSON.stringify(mocapMock.command), + receivedAtMs: mocapMock.receivedAtMs, + } + : null, + error: null, + }), +})); + beforeEach(() => { resetChildMotionWarmupRuntimeSession(); vi.restoreAllMocks(); + mocapMock.status = 'connected'; + mocapMock.command = null; + mocapMock.receivedAtMs = 1; Object.defineProperty(navigator, 'mediaDevices', { configurable: true, value: undefined, @@ -19,6 +50,7 @@ beforeEach(() => { }); afterEach(() => { + vi.useRealTimers(); vi.restoreAllMocks(); }); @@ -57,6 +89,241 @@ test('developer keyboard input moves the avatar and triggers jump state', () => expect(avatar.className).toContain('child-motion-avatar--jumping'); }); +test('mocap body center keeps the warmup flow on the motion data source', async () => { + vi.useFakeTimers(); + mocapMock.command = { + actions: [], + bodyCenter: { x: 0.5, y: 0.6 }, + hands: [], + primaryHand: null, + leftHand: null, + rightHand: null, + }; + const { rerender, unmount } = render(); + + expect(screen.queryByText('摄像头暂不可用,已切换到本地演示')).toBeNull(); + expect(screen.queryByText('动作数据已连接,等待识别')).toBeNull(); + expect(screen.getByTestId('child-motion-avatar').getAttribute('style')).toContain( + 'left: 50%', + ); + + await act(async () => { + vi.advanceTimersByTime(2100); + await vi.runOnlyPendingTimersAsync(); + }); + await vi.waitFor(() => { + expect(screen.getByText('打个招呼')).toBeTruthy(); + }); + + mocapMock.command = { + actions: ['open_palm'], + bodyCenter: { x: 0.5, y: 0.6 }, + hands: [{ x: 0.48, y: 0.34, state: 'open_palm', side: 'left' }], + primaryHand: { x: 0.48, y: 0.34, state: 'open_palm', side: 'left' }, + leftHand: { x: 0.48, y: 0.34, state: 'open_palm', side: 'left' }, + rightHand: null, + }; + mocapMock.receivedAtMs += 1; + await act(async () => { + rerender(); + vi.advanceTimersByTime(1000); + await vi.runOnlyPendingTimersAsync(); + }); + + await vi.waitFor(() => { + expect(screen.getByText('准备热身')).toBeTruthy(); + }); + + await act(async () => { + vi.advanceTimersByTime(1000); + await vi.runOnlyPendingTimersAsync(); + }); + + await vi.waitFor(() => { + expect(screen.getByRole('heading', { name: '向左一步' })).toBeTruthy(); + }); + + mocapMock.command = { + actions: [], + bodyCenter: { x: 0.34, y: 0.6 }, + hands: [], + primaryHand: null, + leftHand: null, + rightHand: null, + }; + mocapMock.receivedAtMs += 1; + await act(async () => { + rerender(); + }); + await vi.waitFor(() => { + expect(screen.getByTestId('child-motion-avatar').getAttribute('style')).toContain( + 'left: 34%', + ); + }); + await act(async () => { + vi.advanceTimersByTime(2100); + await vi.runOnlyPendingTimersAsync(); + }); + + await vi.waitFor(() => { + expect(screen.getByRole('heading', { name: '回到中间来' })).toBeTruthy(); + }); + + await act(async () => { + unmount(); + }); + vi.useRealTimers(); +}); + +test('mocap open palm completes the greeting wave step', async () => { + vi.useFakeTimers(); + const { rerender, unmount } = render(); + + await act(async () => { + vi.advanceTimersByTime(2100); + await vi.runOnlyPendingTimersAsync(); + }); + await vi.waitFor(() => { + expect(screen.getByText('打个招呼')).toBeTruthy(); + }); + + mocapMock.command = { + actions: ['open_palm'], + hands: [{ x: 0.46, y: 0.34, state: 'open_palm', side: 'left' }], + primaryHand: { x: 0.46, y: 0.34, state: 'open_palm', side: 'left' }, + leftHand: { x: 0.46, y: 0.34, state: 'open_palm', side: 'left' }, + rightHand: null, + }; + mocapMock.receivedAtMs += 1; + await act(async () => { + rerender(); + }); + + await vi.waitFor(() => { + expect(screen.getByText('准备热身')).toBeTruthy(); + }); + await act(async () => { + unmount(); + }); + vi.useRealTimers(); +}); + +test('mocap hand tracks complete left and right wave steps only after movement is visible', async () => { + vi.useFakeTimers(); + const { rerender, unmount } = render(); + + const advancePositionStep = async (key: string, code: string) => { + await act(async () => { + fireEvent.keyDown(window, { key, code }); + }); + await act(async () => { + vi.advanceTimersByTime(2100); + await vi.runOnlyPendingTimersAsync(); + }); + await act(async () => { + fireEvent.keyUp(window, { key, code }); + }); + }; + + await act(async () => { + vi.advanceTimersByTime(2100); + await vi.runOnlyPendingTimersAsync(); + }); + await vi.waitFor(() => { + expect(screen.getByText('打个招呼')).toBeTruthy(); + }); + + mocapMock.command = { + actions: ['open_palm'], + hands: [{ x: 0.48, y: 0.34, state: 'open_palm', side: 'left' }], + primaryHand: { x: 0.48, y: 0.34, state: 'open_palm', side: 'left' }, + leftHand: { x: 0.48, y: 0.34, state: 'open_palm', side: 'left' }, + rightHand: null, + }; + mocapMock.receivedAtMs += 1; + await act(async () => { + rerender(); + }); + + await act(async () => { + vi.advanceTimersByTime(1000); + await vi.runOnlyPendingTimersAsync(); + }); + await advancePositionStep('a', 'KeyA'); + await act(async () => { + vi.advanceTimersByTime(120); + await vi.runOnlyPendingTimersAsync(); + vi.advanceTimersByTime(2100); + await vi.runOnlyPendingTimersAsync(); + }); + await advancePositionStep('d', 'KeyD'); + await act(async () => { + vi.advanceTimersByTime(120); + await vi.runOnlyPendingTimersAsync(); + vi.advanceTimersByTime(2100); + await vi.runOnlyPendingTimersAsync(); + }); + await vi.waitFor(() => { + expect(screen.getByRole('heading', { name: '挥动左手' })).toBeTruthy(); + }); + + mocapMock.command = { + actions: [], + leftHand: { x: 0.3, y: 0.38, state: 'unknown', side: 'left' }, + primaryHand: { x: 0.3, y: 0.38, state: 'unknown', side: 'left' }, + rightHand: null, + }; + mocapMock.receivedAtMs += 1; + await act(async () => { + rerender(); + }); + mocapMock.command = { + actions: [], + leftHand: { x: 0.39, y: 0.36, state: 'unknown', side: 'left' }, + primaryHand: { x: 0.39, y: 0.36, state: 'unknown', side: 'left' }, + rightHand: null, + }; + mocapMock.receivedAtMs += 1; + await act(async () => { + rerender(); + }); + mocapMock.command = { + actions: [], + leftHand: { x: 0.31, y: 0.34, state: 'unknown', side: 'left' }, + primaryHand: { x: 0.31, y: 0.34, state: 'unknown', side: 'left' }, + rightHand: null, + }; + mocapMock.receivedAtMs += 1; + await act(async () => { + rerender(); + }); + + await vi.waitFor(() => { + expect(screen.getByRole('heading', { name: '挥动右手' })).toBeTruthy(); + }); + + mocapMock.command = { + actions: ['right_hand_wave'], + leftHand: null, + primaryHand: { x: 0.64, y: 0.35, state: 'unknown', side: 'right' }, + rightHand: { x: 0.64, y: 0.35, state: 'unknown', side: 'right' }, + }; + mocapMock.receivedAtMs += 1; + await act(async () => { + rerender(); + }); + + await vi.waitFor(() => { + expect(screen.getByRole('heading', { name: '原地跳一下' })).toBeTruthy(); + }); + await act(async () => { + vi.advanceTimersByTime(720); + await vi.runOnlyPendingTimersAsync(); + unmount(); + }); + vi.useRealTimers(); +}); + test('connects camera stream and releases it on unmount', async () => { const stopTrack = vi.fn(); const stream = { @@ -79,7 +346,8 @@ test('connects camera stream and releases it on unmount', async () => { const { unmount } = render(); - expect(await screen.findByText('正在连接摄像头')).toBeTruthy(); + expect(screen.queryByText('摄像头暂不可用,已切换到本地演示')).toBeNull(); + expect(screen.getByText('动作数据已连接,等待识别')).toBeTruthy(); await vi.waitFor(() => { expect(getUserMedia).toHaveBeenCalledWith({ audio: false, diff --git a/src/components/child-motion-demo/ChildMotionWarmupDemo.tsx b/src/components/child-motion-demo/ChildMotionWarmupDemo.tsx index a68a2dfc..031b5367 100644 --- a/src/components/child-motion-demo/ChildMotionWarmupDemo.tsx +++ b/src/components/child-motion-demo/ChildMotionWarmupDemo.tsx @@ -4,6 +4,12 @@ import type { } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { + MocapConnectionStatus, + MocapHandInput, + MocapInputCommand, +} from '../../services/useMocapInput'; +import { useMocapInput } from '../../services/useMocapInput'; import { applyChildMotionWarmupCompletion, CHILD_MOTION_CENTER_X, @@ -24,6 +30,11 @@ import { type DragHand = 'left' | 'right'; type CameraAccessState = 'idle' | 'requesting' | 'ready' | 'blocked'; +type MotionSourceState = 'connecting' | 'ready' | 'waiting' | 'offline'; +type WarmupMocapGestureIntent = 'greeting' | 'left-hand' | 'right-hand' | 'jump'; + +const WARMUP_MOCAP_WAVE_MIN_POINTS = 3; +const WARMUP_MOCAP_WAVE_MIN_X_RANGE = 0.055; function clampMotionUnit(value: number) { return Math.max(0, Math.min(1, value)); @@ -50,6 +61,163 @@ function formatPercent(value: number | null) { return `${Math.round(value * 100)}%`; } +function mocapHandToChildMotionPoint( + hand: MocapHandInput | null | undefined, +): ChildMotionPoint | null { + if (!hand) { + return null; + } + + return { + x: clampMotionUnit(hand.x), + y: clampMotionUnit(hand.y), + }; +} + +function appendWarmupMocapPoint( + points: ChildMotionPoint[], + point: ChildMotionPoint, +) { + return [...points, point].slice(-16); +} + +function getMotionSourceState( + mocapStatus: MocapConnectionStatus, + latestCommand: MocapInputCommand | null, +): MotionSourceState { + if (mocapStatus === 'connecting' || mocapStatus === 'idle') { + return 'connecting'; + } + + if (mocapStatus === 'connected') { + return latestCommand && + (Boolean(latestCommand.bodyCenter) || + Boolean(latestCommand.hands?.length) || + latestCommand.actions.length > 0) + ? 'ready' + : 'waiting'; + } + + return 'offline'; +} + +function getMotionSourceText(state: MotionSourceState) { + if (state === 'ready') { + return '动作数据已连接'; + } + + if (state === 'waiting') { + return '动作数据已连接,等待识别'; + } + + if (state === 'offline') { + return '动作数据暂不可用,已保留本地调试'; + } + + return '正在连接动作数据'; +} + +function hasWarmupMocapAction( + command: MocapInputCommand, + expectedActions: string[], +) { + return command.actions.some((action) => expectedActions.includes(action)); +} + +function hasWarmupMocapWavePath(points: ChildMotionPoint[]) { + if (points.length < WARMUP_MOCAP_WAVE_MIN_POINTS) { + return false; + } + + const xValues = points.map((point) => point.x); + return ( + Math.max(...xValues) - Math.min(...xValues) >= + WARMUP_MOCAP_WAVE_MIN_X_RANGE + ); +} + +function resolveAvatarXFromMocap(command: MocapInputCommand) { + return command.bodyCenter?.x ?? null; +} + +function resolveWarmupMocapGestureIntent( + stepId: ChildMotionWarmupStepId, + command: MocapInputCommand, + paths: { + leftHandPath: ChildMotionPoint[]; + rightHandPath: ChildMotionPoint[]; + primaryHandPath: ChildMotionPoint[]; + }, +): WarmupMocapGestureIntent | null { + if (stepId === 'wave_greeting') { + if ( + hasWarmupMocapAction(command, [ + 'wave', + 'wave_greeting', + 'hand_wave', + 'hello', + 'greeting', + 'open_palm', + 'handwave', + 'wavehand', + '招手', + '挥手', + ]) || + command.hands?.some((hand) => hand.state === 'open_palm') || + hasWarmupMocapWavePath(paths.leftHandPath) || + hasWarmupMocapWavePath(paths.rightHandPath) || + hasWarmupMocapWavePath(paths.primaryHandPath) + ) { + return 'greeting'; + } + } + + if ( + stepId === 'wave_left_hand' && + (hasWarmupMocapAction(command, [ + 'left_wave', + 'wave_left', + 'left_hand_wave', + 'wave_left_hand', + 'left_handwave', + 'lefthand_wave', + 'lefthandwave', + '左手挥手', + '挥动左手', + ]) || + hasWarmupMocapWavePath(paths.leftHandPath)) + ) { + return 'left-hand'; + } + + if ( + stepId === 'wave_right_hand' && + (hasWarmupMocapAction(command, [ + 'right_wave', + 'wave_right', + 'right_hand_wave', + 'wave_right_hand', + 'right_handwave', + 'righthand_wave', + 'righthandwave', + '右手挥手', + '挥动右手', + ]) || + hasWarmupMocapWavePath(paths.rightHandPath)) + ) { + return 'right-hand'; + } + + if ( + stepId === 'jump_once' && + hasWarmupMocapAction(command, ['jump', 'jump_once', 'hop', '跳跃', '原地跳']) + ) { + return 'jump'; + } + + return null; +} + function getHoldProgress( stepId: ChildMotionWarmupStepId, avatarX: number, @@ -230,12 +398,25 @@ export function ChildMotionWarmupDemo() { const holdCompletionRef = useRef(false); const cameraVideoRef = useRef(null); const cameraStreamRef = useRef(null); + const handledMocapPacketKeyRef = useRef(null); const step = getChildMotionWarmupStep(stepId); + const mocapInput = useMocapInput({ + enabled: + step.kind === 'position' || + step.kind === 'gesture' || + step.kind === 'narration' || + step.kind === 'finish', + }); const stepIndex = getStepIndex(stepId); const progressPercent = Math.round((stepIndex / 12) * 100); const holdProgress = getHoldProgress(stepId, avatarX, holdStartedAt, nowMs); const targetX = step.target ? getChildMotionTargetX(step.target) : null; + const motionSourceState = getMotionSourceState( + mocapInput.status, + mocapInput.latestCommand, + ); + const motionSourceText = getMotionSourceText(motionSourceState); const completeStep = useCallback( (completion: Parameters[2]) => { @@ -382,6 +563,115 @@ export function ChildMotionWarmupDemo() { return () => window.clearTimeout(timeout); }, [completeStep, step.kind]); + useEffect(() => { + if (step.kind !== 'gesture' || !mocapInput.latestCommand) { + return; + } + + const command = mocapInput.latestCommand; + const packetKey = + mocapInput.rawPacketPreview?.receivedAtMs !== undefined + ? `${mocapInput.rawPacketPreview.receivedAtMs}:${mocapInput.rawPacketPreview.text}` + : JSON.stringify(command); + if (handledMocapPacketKeyRef.current === packetKey) { + return; + } + + const primaryPoint = mocapHandToChildMotionPoint(command.primaryHand); + const primaryHandSide = command.primaryHand?.side ?? 'unknown'; + const fallbackPrimaryToLeft = + Boolean(primaryPoint) && + !command.leftHand && + (primaryHandSide === 'left' || + primaryHandSide === 'unknown' || + stepId === 'wave_left_hand' || + stepId === 'wave_greeting'); + const fallbackPrimaryToRight = + Boolean(primaryPoint) && + !command.rightHand && + (primaryHandSide === 'right' || + stepId === 'wave_right_hand'); + const leftPoint = + mocapHandToChildMotionPoint(command.leftHand) ?? + (fallbackPrimaryToLeft ? primaryPoint : null); + const rightPoint = + mocapHandToChildMotionPoint(command.rightHand) ?? + (fallbackPrimaryToRight ? primaryPoint : null); + const nextLeftHandPath = leftPoint + ? appendWarmupMocapPoint(leftHandPath, leftPoint) + : leftHandPath; + const nextRightHandPath = rightPoint + ? appendWarmupMocapPoint(rightHandPath, rightPoint) + : rightHandPath; + const nextPrimaryHandPath = primaryPoint + ? command.primaryHand?.side === 'right' + ? nextRightHandPath + : nextLeftHandPath + : []; + handledMocapPacketKeyRef.current = packetKey; + if (leftPoint) { + setLeftHandPath(nextLeftHandPath); + } + if (rightPoint) { + setRightHandPath(nextRightHandPath); + } + + const intent = resolveWarmupMocapGestureIntent(stepId, command, { + leftHandPath: nextLeftHandPath, + rightHandPath: nextRightHandPath, + primaryHandPath: nextPrimaryHandPath, + }); + if (!intent) { + return; + } + + if (intent === 'jump') { + setIsJumping(true); + window.setTimeout(() => setIsJumping(false), 360); + completeStep({ type: 'jump', jumpSpace: 0.14 }); + return; + } + + if (intent === 'right-hand') { + const path = [...nextRightHandPath, rightPoint ?? primaryPoint].filter( + (point): point is ChildMotionPoint => Boolean(point), + ); + completeStep({ type: 'right-hand', path: path.slice(-16) }); + return; + } + + const path = [...nextLeftHandPath, leftPoint ?? primaryPoint].filter( + (point): point is ChildMotionPoint => Boolean(point), + ); + completeStep({ type: 'left-hand', path: path.slice(-16) }); + }, [ + completeStep, + leftHandPath, + mocapInput.latestCommand, + mocapInput.rawPacketPreview?.receivedAtMs, + mocapInput.rawPacketPreview?.text, + rightHandPath, + step.kind, + stepId, + ]); + + useEffect(() => { + if (!mocapInput.latestCommand) { + return; + } + + const nextAvatarX = resolveAvatarXFromMocap(mocapInput.latestCommand); + if (nextAvatarX === null) { + return; + } + + setAvatarX(nextAvatarX); + }, [ + mocapInput.latestCommand, + mocapInput.rawPacketPreview?.receivedAtMs, + mocapInput.rawPacketPreview?.text, + ]); + useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (event.repeat) { @@ -521,14 +811,12 @@ export function ChildMotionWarmupDemo() { muted playsInline /> - {cameraAccessState === 'requesting' ? ( -
- 正在连接摄像头 -
- ) : null} - {cameraAccessState === 'blocked' ? ( -
- 摄像头暂不可用,已切换到本地演示 + {motionSourceState !== 'ready' ? ( +
+ {motionSourceText}
) : null}