feat: add edutainment drawing and visual package flows
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import type { ReactElement } from 'react';
|
||||
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import { ChildMotionWarmupDemo } from './ChildMotionWarmupDemo';
|
||||
@@ -13,11 +14,41 @@ 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;
|
||||
hands?: Array<{
|
||||
x: number;
|
||||
y: number;
|
||||
state: string;
|
||||
side: string;
|
||||
wrist?: { x: number; y: number } | null;
|
||||
}>;
|
||||
primaryHand?: {
|
||||
x: number;
|
||||
y: number;
|
||||
state: string;
|
||||
side: string;
|
||||
wrist?: { x: number; y: number } | null;
|
||||
} | null;
|
||||
leftHand?: {
|
||||
x: number;
|
||||
y: number;
|
||||
state: string;
|
||||
side: string;
|
||||
wrist?: { x: number; y: number } | null;
|
||||
} | null;
|
||||
rightHand?: {
|
||||
x: number;
|
||||
y: number;
|
||||
state: string;
|
||||
side: string;
|
||||
wrist?: { x: number; y: number } | null;
|
||||
} | null;
|
||||
bodyCenter?: { x: number; y: number } | null;
|
||||
bodyJoints?: {
|
||||
leftShoulder?: { x: number; y: number } | null;
|
||||
rightShoulder?: { x: number; y: number } | null;
|
||||
leftElbow?: { x: number; y: number } | null;
|
||||
rightElbow?: { x: number; y: number } | null;
|
||||
};
|
||||
},
|
||||
receivedAtMs: 1,
|
||||
}));
|
||||
@@ -66,15 +97,170 @@ afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function setMocapBodyCenter(x: number) {
|
||||
mocapMock.command = {
|
||||
actions: [],
|
||||
bodyCenter: { x, y: 0.6 },
|
||||
hands: [],
|
||||
primaryHand: null,
|
||||
leftHand: null,
|
||||
rightHand: null,
|
||||
};
|
||||
mocapMock.receivedAtMs += 1;
|
||||
}
|
||||
|
||||
async function advanceWarmupTime(ms: number) {
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(ms);
|
||||
});
|
||||
}
|
||||
|
||||
async function revealCurrentStepCue() {
|
||||
await advanceWarmupTime(1100);
|
||||
}
|
||||
|
||||
async function completeCurrentPositionStepByHold() {
|
||||
await advanceWarmupTime(2200);
|
||||
await advanceWarmupTime(900);
|
||||
}
|
||||
|
||||
async function completeCurrentNarrationStep() {
|
||||
await revealCurrentStepCue();
|
||||
await advanceWarmupTime(1000);
|
||||
await advanceWarmupTime(900);
|
||||
}
|
||||
|
||||
async function sendMocapLeftHandTrack(
|
||||
rerender: (ui: ReactElement) => void,
|
||||
points: number[],
|
||||
options: { raised?: boolean } = {},
|
||||
) {
|
||||
for (const x of points) {
|
||||
const y = options.raised ? 0.34 : 0.72;
|
||||
const wrist = { x, y };
|
||||
mocapMock.command = {
|
||||
actions: [],
|
||||
bodyCenter: { x: 0.5, y: 0.7 },
|
||||
bodyJoints: {
|
||||
leftShoulder: { x: 0.4, y: 0.42 },
|
||||
leftElbow: { x: 0.36, y: 0.5 },
|
||||
},
|
||||
hands: [{ x, y, state: 'unknown', side: 'left', wrist }],
|
||||
primaryHand: { x, y, state: 'unknown', side: 'left', wrist },
|
||||
leftHand: { x, y, state: 'unknown', side: 'left', wrist },
|
||||
rightHand: null,
|
||||
};
|
||||
mocapMock.receivedAtMs += 1;
|
||||
await act(async () => {
|
||||
rerender(<ChildMotionWarmupDemo />);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function setMocapCameraHandTrackPoint({
|
||||
cameraSide,
|
||||
x,
|
||||
y,
|
||||
}: {
|
||||
cameraSide: 'left' | 'right';
|
||||
x: number;
|
||||
y: number;
|
||||
}) {
|
||||
const wrist = { x, y };
|
||||
const hand = { x, y, state: 'unknown', side: cameraSide, wrist };
|
||||
const command = {
|
||||
actions: [],
|
||||
bodyCenter: { x: 0.5, y: 0.7 },
|
||||
bodyJoints: {
|
||||
leftShoulder: { x: 0.62, y: 0.48 },
|
||||
leftElbow: { x: 0.7, y: 0.5 },
|
||||
rightShoulder: { x: 0.38, y: 0.48 },
|
||||
rightElbow: { x: 0.3, y: 0.5 },
|
||||
},
|
||||
hands: [hand],
|
||||
primaryHand: hand,
|
||||
leftHand: null as null | typeof hand,
|
||||
rightHand: null as null | typeof hand,
|
||||
};
|
||||
|
||||
if (cameraSide === 'left') {
|
||||
command.leftHand = hand;
|
||||
} else {
|
||||
command.rightHand = hand;
|
||||
}
|
||||
|
||||
mocapMock.command = command;
|
||||
mocapMock.receivedAtMs += 1;
|
||||
}
|
||||
|
||||
async function sendMocapCameraHandTrack(
|
||||
rerender: (ui: ReactElement) => void,
|
||||
cameraSide: 'left' | 'right',
|
||||
points: Array<{ x: number; y: number }>,
|
||||
) {
|
||||
for (const point of points) {
|
||||
setMocapCameraHandTrackPoint({ cameraSide, ...point });
|
||||
await act(async () => {
|
||||
rerender(<ChildMotionWarmupDemo />);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function sendPlayerLeftArmSwingTrack(
|
||||
rerender: (ui: ReactElement) => void,
|
||||
) {
|
||||
await sendMocapCameraHandTrack(rerender, 'right', [
|
||||
{ x: 0.2, y: 0.5 },
|
||||
{ x: 0.16, y: 0.42 },
|
||||
{ x: 0.13, y: 0.34 },
|
||||
{ x: 0.15, y: 0.43 },
|
||||
{ x: 0.19, y: 0.51 },
|
||||
]);
|
||||
}
|
||||
|
||||
async function sendPlayerRightArmSwingTrack(
|
||||
rerender: (ui: ReactElement) => void,
|
||||
) {
|
||||
await sendMocapCameraHandTrack(rerender, 'left', [
|
||||
{ x: 0.8, y: 0.5 },
|
||||
{ x: 0.84, y: 0.42 },
|
||||
{ x: 0.87, y: 0.34 },
|
||||
{ x: 0.85, y: 0.43 },
|
||||
{ x: 0.81, y: 0.51 },
|
||||
]);
|
||||
}
|
||||
|
||||
async function completeGreetingByWaveTrack(
|
||||
rerender: (ui: ReactElement) => void,
|
||||
) {
|
||||
await sendMocapLeftHandTrack(rerender, [0.42, 0.51, 0.58, 0.49, 0.43], {
|
||||
raised: true,
|
||||
});
|
||||
}
|
||||
|
||||
test('renders the warmup stage and starts with the center ring step', () => {
|
||||
render(<ChildMotionWarmupDemo />);
|
||||
|
||||
expect(screen.getByTestId('child-motion-demo')).toBeTruthy();
|
||||
expect(screen.getByText('来到圆圈这里')).toBeTruthy();
|
||||
expect(screen.getByLabelText('绿色圆环')).toBeTruthy();
|
||||
expect(screen.queryByLabelText('绿色圆环')).toBeNull();
|
||||
expect(screen.getByText('请横屏体验')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('shows narration first before revealing the step cue', async () => {
|
||||
vi.useFakeTimers();
|
||||
render(<ChildMotionWarmupDemo />);
|
||||
|
||||
expect(screen.getByText('来到圆圈这里')).toBeTruthy();
|
||||
expect(screen.queryByLabelText('绿色圆环')).toBeNull();
|
||||
expect(screen.getByTestId('child-motion-stage').dataset.stepPhase).toBe('intro');
|
||||
|
||||
await advanceWarmupTime(1000);
|
||||
|
||||
expect(screen.getByLabelText('绿色圆环')).toBeTruthy();
|
||||
expect(screen.getByTestId('child-motion-stage').dataset.stepPhase).toBe('active');
|
||||
});
|
||||
|
||||
test('re-entering within the same runtime session opens the start button', () => {
|
||||
markChildMotionWarmupCompletedInRuntime();
|
||||
|
||||
@@ -113,16 +299,35 @@ test('developer keyboard input moves the avatar and triggers jump state', () =>
|
||||
expect(avatar.className).toContain('child-motion-avatar--jumping');
|
||||
});
|
||||
|
||||
test('mocap body center dampens small jitter before moving the avatar', async () => {
|
||||
setMocapBodyCenter(0.5);
|
||||
const { rerender } = render(<ChildMotionWarmupDemo />);
|
||||
|
||||
expect(screen.getByTestId('child-motion-avatar').getAttribute('style')).toContain(
|
||||
'left: 50%',
|
||||
);
|
||||
|
||||
setMocapBodyCenter(0.508);
|
||||
await act(async () => {
|
||||
rerender(<ChildMotionWarmupDemo />);
|
||||
});
|
||||
expect(screen.getByTestId('child-motion-avatar').getAttribute('style')).toContain(
|
||||
'left: 50%',
|
||||
);
|
||||
|
||||
setMocapBodyCenter(0.34);
|
||||
await act(async () => {
|
||||
rerender(<ChildMotionWarmupDemo />);
|
||||
});
|
||||
|
||||
const style = screen.getByTestId('child-motion-avatar').getAttribute('style');
|
||||
expect(style).toContain('left: 46.5%');
|
||||
expect(style).not.toContain('left: 34%');
|
||||
});
|
||||
|
||||
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,
|
||||
};
|
||||
setMocapBodyCenter(0.5);
|
||||
const { rerender, unmount } = render(<ChildMotionWarmupDemo />);
|
||||
|
||||
expect(screen.queryByText('摄像头暂不可用,已切换到本地演示')).toBeNull();
|
||||
@@ -131,63 +336,39 @@ test('mocap body center keeps the warmup flow on the motion data source', async
|
||||
'left: 50%',
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(2100);
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
});
|
||||
await revealCurrentStepCue();
|
||||
await completeCurrentPositionStepByHold();
|
||||
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(<ChildMotionWarmupDemo />);
|
||||
vi.advanceTimersByTime(1000);
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
});
|
||||
await revealCurrentStepCue();
|
||||
await completeGreetingByWaveTrack(rerender);
|
||||
await advanceWarmupTime(900);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByText('准备热身')).toBeTruthy();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
});
|
||||
await completeCurrentNarrationStep();
|
||||
|
||||
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(<ChildMotionWarmupDemo />);
|
||||
});
|
||||
await revealCurrentStepCue();
|
||||
for (const targetX of [0.34, 0.34, 0.34, 0.34, 0.34]) {
|
||||
setMocapBodyCenter(targetX);
|
||||
await act(async () => {
|
||||
rerender(<ChildMotionWarmupDemo />);
|
||||
});
|
||||
}
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByTestId('child-motion-avatar').getAttribute('style')).toContain(
|
||||
'left: 34%',
|
||||
'left: 37',
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(2100);
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
});
|
||||
await completeCurrentPositionStepByHold();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: '回到中间来' })).toBeTruthy();
|
||||
@@ -199,18 +380,17 @@ test('mocap body center keeps the warmup flow on the motion data source', async
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('mocap open palm completes the greeting wave step', async () => {
|
||||
test('mocap greeting requires a real horizontal wave track', async () => {
|
||||
vi.useFakeTimers();
|
||||
const { rerender, unmount } = render(<ChildMotionWarmupDemo />);
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(2100);
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
});
|
||||
await revealCurrentStepCue();
|
||||
await completeCurrentPositionStepByHold();
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByText('打个招呼')).toBeTruthy();
|
||||
});
|
||||
|
||||
await revealCurrentStepCue();
|
||||
mocapMock.command = {
|
||||
actions: ['open_palm'],
|
||||
hands: [{ x: 0.46, y: 0.34, state: 'open_palm', side: 'left' }],
|
||||
@@ -222,7 +402,35 @@ test('mocap open palm completes the greeting wave step', async () => {
|
||||
await act(async () => {
|
||||
rerender(<ChildMotionWarmupDemo />);
|
||||
});
|
||||
await advanceWarmupTime(900);
|
||||
expect(screen.getByRole('heading', { name: '打个招呼' })).toBeTruthy();
|
||||
|
||||
await sendMocapLeftHandTrack(rerender, [0.42, 0.51, 0.58, 0.49, 0.43], {
|
||||
raised: false,
|
||||
});
|
||||
await advanceWarmupTime(900);
|
||||
expect(screen.getByRole('heading', { name: '打个招呼' })).toBeTruthy();
|
||||
|
||||
for (const x of [0.42, 0.51, 0.58, 0.49, 0.43]) {
|
||||
const wrist = { x, y: 0.34 };
|
||||
mocapMock.command = {
|
||||
actions: [],
|
||||
bodyCenter: { x: 0.5, y: 0.7 },
|
||||
hands: [{ x, y: 0.34, state: 'unknown', side: 'left', wrist }],
|
||||
primaryHand: { x, y: 0.34, state: 'unknown', side: 'left', wrist },
|
||||
leftHand: { x, y: 0.34, state: 'unknown', side: 'left', wrist },
|
||||
rightHand: null,
|
||||
};
|
||||
mocapMock.receivedAtMs += 1;
|
||||
await act(async () => {
|
||||
rerender(<ChildMotionWarmupDemo />);
|
||||
});
|
||||
}
|
||||
await advanceWarmupTime(900);
|
||||
expect(screen.getByRole('heading', { name: '打个招呼' })).toBeTruthy();
|
||||
|
||||
await completeGreetingByWaveTrack(rerender);
|
||||
await advanceWarmupTime(900);
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByText('准备热身')).toBeTruthy();
|
||||
});
|
||||
@@ -232,117 +440,89 @@ test('mocap open palm completes the greeting wave step', async () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('mocap hand tracks complete left and right wave steps only after movement is visible', async () => {
|
||||
test('mocap arm swing steps require body-side mapping and vertical open arm motion', async () => {
|
||||
vi.useFakeTimers();
|
||||
const { rerender, unmount } = render(<ChildMotionWarmupDemo />);
|
||||
|
||||
const advancePositionStep = async (key: string, code: string) => {
|
||||
await revealCurrentStepCue();
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(window, { key, code });
|
||||
});
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(2100);
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
});
|
||||
await completeCurrentPositionStepByHold();
|
||||
await act(async () => {
|
||||
fireEvent.keyUp(window, { key, code });
|
||||
});
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(2100);
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
});
|
||||
await revealCurrentStepCue();
|
||||
await completeCurrentPositionStepByHold();
|
||||
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(<ChildMotionWarmupDemo />);
|
||||
});
|
||||
await revealCurrentStepCue();
|
||||
await completeGreetingByWaveTrack(rerender);
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
});
|
||||
await advanceWarmupTime(900);
|
||||
await completeCurrentNarrationStep();
|
||||
await advancePositionStep('a', 'KeyA');
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(120);
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
vi.advanceTimersByTime(2100);
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
});
|
||||
await revealCurrentStepCue();
|
||||
await completeCurrentPositionStepByHold();
|
||||
await advancePositionStep('d', 'KeyD');
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(120);
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
vi.advanceTimersByTime(2100);
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
});
|
||||
await revealCurrentStepCue();
|
||||
await completeCurrentPositionStepByHold();
|
||||
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(<ChildMotionWarmupDemo />);
|
||||
});
|
||||
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(<ChildMotionWarmupDemo />);
|
||||
});
|
||||
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(<ChildMotionWarmupDemo />);
|
||||
});
|
||||
await revealCurrentStepCue();
|
||||
await sendMocapCameraHandTrack(rerender, 'left', [
|
||||
{ x: 0.78, y: 0.5 },
|
||||
{ x: 0.86, y: 0.5 },
|
||||
{ x: 0.79, y: 0.5 },
|
||||
{ x: 0.87, y: 0.5 },
|
||||
{ x: 0.8, y: 0.5 },
|
||||
]);
|
||||
await advanceWarmupTime(900);
|
||||
expect(screen.getByRole('heading', { name: '挥动左手' })).toBeTruthy();
|
||||
|
||||
await sendMocapCameraHandTrack(rerender, 'right', [
|
||||
{ x: 0.32, y: 0.74 },
|
||||
{ x: 0.24, y: 0.74 },
|
||||
{ x: 0.31, y: 0.74 },
|
||||
{ x: 0.23, y: 0.74 },
|
||||
{ x: 0.3, y: 0.74 },
|
||||
]);
|
||||
await advanceWarmupTime(900);
|
||||
expect(screen.getByRole('heading', { name: '挥动左手' })).toBeTruthy();
|
||||
|
||||
await sendPlayerLeftArmSwingTrack(rerender);
|
||||
|
||||
await advanceWarmupTime(900);
|
||||
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(<ChildMotionWarmupDemo />);
|
||||
});
|
||||
await revealCurrentStepCue();
|
||||
await sendMocapCameraHandTrack(rerender, 'right', [
|
||||
{ x: 0.2, y: 0.5 },
|
||||
{ x: 0.16, y: 0.42 },
|
||||
{ x: 0.13, y: 0.34 },
|
||||
{ x: 0.15, y: 0.43 },
|
||||
{ x: 0.19, y: 0.51 },
|
||||
]);
|
||||
await advanceWarmupTime(900);
|
||||
expect(screen.getByRole('heading', { name: '挥动右手' })).toBeTruthy();
|
||||
|
||||
await sendPlayerRightArmSwingTrack(rerender);
|
||||
|
||||
await advanceWarmupTime(900);
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: '原地跳一下' })).toBeTruthy();
|
||||
});
|
||||
await advanceWarmupTime(720);
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(720);
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
unmount();
|
||||
});
|
||||
vi.useRealTimers();
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import type {
|
||||
CSSProperties,
|
||||
PointerEvent as ReactPointerEvent,
|
||||
} from 'react';
|
||||
import type { CSSProperties, PointerEvent as ReactPointerEvent } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
@@ -14,6 +11,7 @@ import type {
|
||||
MocapConnectionStatus,
|
||||
MocapHandInput,
|
||||
MocapInputCommand,
|
||||
MocapPointInput,
|
||||
} from '../../services/useMocapInput';
|
||||
import { useMocapInput } from '../../services/useMocapInput';
|
||||
import { BabyObjectMatchRuntimeShell } from '../edutainment-runtime/BabyObjectMatchRuntimeShell';
|
||||
@@ -38,7 +36,13 @@ 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';
|
||||
type WarmupStepPhase = 'intro' | 'active' | 'complete';
|
||||
type WarmupMocapGestureIntent =
|
||||
| 'greeting'
|
||||
| 'left-hand'
|
||||
| 'right-hand'
|
||||
| 'jump';
|
||||
type WarmupBodyHandSide = 'left' | 'right';
|
||||
|
||||
const CHILD_MOTION_BABY_OBJECT_DEMO_DRAFT: BabyObjectMatchDraft = {
|
||||
draftId: 'child-motion-demo-baby-object-draft',
|
||||
@@ -68,6 +72,7 @@ const CHILD_MOTION_BABY_OBJECT_DEMO_DRAFT: BabyObjectMatchDraft = {
|
||||
prompt: '香蕉',
|
||||
},
|
||||
],
|
||||
visualPackage: null,
|
||||
themeTags: [BABY_OBJECT_MATCH_EDUTAINMENT_TAG, '宝贝识物'],
|
||||
publicationStatus: 'published',
|
||||
createdAt: '2026-05-11T00:00:00.000Z',
|
||||
@@ -75,8 +80,24 @@ const CHILD_MOTION_BABY_OBJECT_DEMO_DRAFT: BabyObjectMatchDraft = {
|
||||
publishedAt: '2026-05-11T00:00:00.000Z',
|
||||
};
|
||||
|
||||
const WARMUP_MOCAP_WAVE_MIN_POINTS = 3;
|
||||
const WARMUP_MOCAP_WAVE_MIN_X_RANGE = 0.055;
|
||||
const WARMUP_ARM_SWING_MIN_POINTS = 5;
|
||||
const WARMUP_ARM_SWING_MIN_VERTICAL_RANGE = 0.08;
|
||||
const WARMUP_ARM_SWING_MIN_ANGLE_RANGE_DEG = 28;
|
||||
const WARMUP_ARM_SWING_MIN_REACH = 0.12;
|
||||
const WARMUP_ARM_SWING_MIN_OUTWARD_X = 0.1;
|
||||
const WARMUP_ARM_SWING_DIRECTION_EPSILON = 0.012;
|
||||
const WARMUP_GREETING_WAVE_MIN_POINTS = 5;
|
||||
const WARMUP_GREETING_WAVE_MIN_X_RANGE = 0.075;
|
||||
const WARMUP_GREETING_WAVE_MIN_DIRECTION_CHANGES = 1;
|
||||
const WARMUP_GREETING_WAVE_DIRECTION_EPSILON = 0.008;
|
||||
const WARMUP_GREETING_WRIST_ABOVE_ELBOW_MARGIN = 0.04;
|
||||
const WARMUP_GREETING_WRIST_ABOVE_SHOULDER_MARGIN = 0.08;
|
||||
const WARMUP_STEP_INTRO_DELAY_MS = 1000;
|
||||
const WARMUP_STEP_COMPLETE_PAUSE_MS = 820;
|
||||
const AVATAR_MOCAP_DEAD_ZONE = 0.012;
|
||||
const AVATAR_MOCAP_SMOOTHING = 0.28;
|
||||
const AVATAR_MOCAP_MAX_STEP = 0.035;
|
||||
|
||||
function clampMotionUnit(value: number) {
|
||||
return Math.max(0, Math.min(1, value));
|
||||
@@ -103,16 +124,54 @@ function formatPercent(value: number | null) {
|
||||
return `${Math.round(value * 100)}%`;
|
||||
}
|
||||
|
||||
function formatAvatarLeftPercent(value: number) {
|
||||
return `${Math.round(clampMotionUnit(value) * 1000) / 10}%`;
|
||||
}
|
||||
|
||||
function resolveMocapHandWithBodySide(
|
||||
command: MocapInputCommand,
|
||||
side: WarmupBodyHandSide,
|
||||
) {
|
||||
// 本地 mocap 的 handedness 目前是摄像头视角:画面右侧手对应用户身体左手。
|
||||
return side === 'left' ? command.rightHand : command.leftHand;
|
||||
}
|
||||
|
||||
function resolveMocapJointWithBodySide(
|
||||
command: MocapInputCommand,
|
||||
side: WarmupBodyHandSide,
|
||||
joint: 'shoulder' | 'elbow',
|
||||
) {
|
||||
const joints = command.bodyJoints;
|
||||
if (side === 'left') {
|
||||
return joint === 'shoulder' ? joints?.rightShoulder : joints?.rightElbow;
|
||||
}
|
||||
|
||||
return joint === 'shoulder' ? joints?.leftShoulder : joints?.leftElbow;
|
||||
}
|
||||
|
||||
function mocapHandToChildMotionPoint(
|
||||
hand: MocapHandInput | null | undefined,
|
||||
command?: MocapInputCommand,
|
||||
bodySide?: WarmupBodyHandSide,
|
||||
): ChildMotionPoint | null {
|
||||
if (!hand) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const armMetrics =
|
||||
command && bodySide
|
||||
? resolveWarmupArmMetrics(hand, command, bodySide)
|
||||
: null;
|
||||
|
||||
return {
|
||||
x: clampMotionUnit(hand.x),
|
||||
y: clampMotionUnit(hand.y),
|
||||
isRaised: command
|
||||
? isWarmupGreetingHandRaised(hand, command, bodySide)
|
||||
: undefined,
|
||||
isArmExtended: armMetrics?.isExtended,
|
||||
armAngleDeg: armMetrics?.angleDeg,
|
||||
armReach: armMetrics?.reach,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -166,20 +225,180 @@ function hasWarmupMocapAction(
|
||||
return command.actions.some((action) => expectedActions.includes(action));
|
||||
}
|
||||
|
||||
function hasWarmupMocapWavePath(points: ChildMotionPoint[]) {
|
||||
if (points.length < WARMUP_MOCAP_WAVE_MIN_POINTS) {
|
||||
function countWarmupVerticalDirectionChanges(points: ChildMotionPoint[]) {
|
||||
let previousDirection = 0;
|
||||
let directionChanges = 0;
|
||||
|
||||
for (let index = 1; index < points.length; index += 1) {
|
||||
const delta = points[index]!.y - points[index - 1]!.y;
|
||||
if (Math.abs(delta) < WARMUP_ARM_SWING_DIRECTION_EPSILON) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const direction = Math.sign(delta);
|
||||
if (previousDirection !== 0 && direction !== previousDirection) {
|
||||
directionChanges += 1;
|
||||
}
|
||||
previousDirection = direction;
|
||||
}
|
||||
|
||||
return directionChanges;
|
||||
}
|
||||
|
||||
function hasWarmupArmSwingPath(points: ChildMotionPoint[]) {
|
||||
const extendedPoints = points.filter((point) => point.isArmExtended);
|
||||
if (extendedPoints.length < WARMUP_ARM_SWING_MIN_POINTS) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const xValues = points.map((point) => point.x);
|
||||
const xValues = extendedPoints.map((point) => point.x);
|
||||
const yValues = extendedPoints.map((point) => point.y);
|
||||
const angleValues = extendedPoints
|
||||
.map((point) => point.armAngleDeg)
|
||||
.filter((angle): angle is number => typeof angle === 'number');
|
||||
const xRange = Math.max(...xValues) - Math.min(...xValues);
|
||||
const yRange = Math.max(...yValues) - Math.min(...yValues);
|
||||
const angleRange =
|
||||
angleValues.length > 0
|
||||
? Math.max(...angleValues) - Math.min(...angleValues)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
Math.max(...xValues) - Math.min(...xValues) >=
|
||||
WARMUP_MOCAP_WAVE_MIN_X_RANGE
|
||||
xRange >= WARMUP_MOCAP_WAVE_MIN_X_RANGE &&
|
||||
yRange >= WARMUP_ARM_SWING_MIN_VERTICAL_RANGE &&
|
||||
angleRange >= WARMUP_ARM_SWING_MIN_ANGLE_RANGE_DEG &&
|
||||
countWarmupVerticalDirectionChanges(extendedPoints) >= 1
|
||||
);
|
||||
}
|
||||
|
||||
function countWarmupHorizontalDirectionChanges(points: ChildMotionPoint[]) {
|
||||
let previousDirection = 0;
|
||||
let directionChanges = 0;
|
||||
|
||||
for (let index = 1; index < points.length; index += 1) {
|
||||
const delta = points[index]!.x - points[index - 1]!.x;
|
||||
if (Math.abs(delta) < WARMUP_GREETING_WAVE_DIRECTION_EPSILON) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const direction = Math.sign(delta);
|
||||
if (previousDirection !== 0 && direction !== previousDirection) {
|
||||
directionChanges += 1;
|
||||
}
|
||||
previousDirection = direction;
|
||||
}
|
||||
|
||||
return directionChanges;
|
||||
}
|
||||
|
||||
function hasWarmupGreetingWavePath(points: ChildMotionPoint[]) {
|
||||
const raisedPoints = points.filter((point) => point.isRaised);
|
||||
if (raisedPoints.length < WARMUP_GREETING_WAVE_MIN_POINTS) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const xValues = raisedPoints.map((point) => point.x);
|
||||
const xRange = Math.max(...xValues) - Math.min(...xValues);
|
||||
return (
|
||||
xRange >= WARMUP_GREETING_WAVE_MIN_X_RANGE &&
|
||||
countWarmupHorizontalDirectionChanges(raisedPoints) >=
|
||||
WARMUP_GREETING_WAVE_MIN_DIRECTION_CHANGES
|
||||
);
|
||||
}
|
||||
|
||||
function isWarmupGreetingHandRaised(
|
||||
hand: MocapHandInput,
|
||||
command: MocapInputCommand,
|
||||
bodySide?: WarmupBodyHandSide,
|
||||
) {
|
||||
const wrist = hand.wrist ?? { x: hand.x, y: hand.y };
|
||||
const elbow = bodySide
|
||||
? resolveMocapJointWithBodySide(command, bodySide, 'elbow')
|
||||
: hand.side === 'left'
|
||||
? command.bodyJoints?.leftElbow
|
||||
: hand.side === 'right'
|
||||
? command.bodyJoints?.rightElbow
|
||||
: null;
|
||||
if (elbow) {
|
||||
return wrist.y <= elbow.y + WARMUP_GREETING_WRIST_ABOVE_ELBOW_MARGIN;
|
||||
}
|
||||
|
||||
const shoulder = bodySide
|
||||
? resolveMocapJointWithBodySide(command, bodySide, 'shoulder')
|
||||
: hand.side === 'left'
|
||||
? command.bodyJoints?.leftShoulder
|
||||
: hand.side === 'right'
|
||||
? command.bodyJoints?.rightShoulder
|
||||
: null;
|
||||
if (shoulder) {
|
||||
return wrist.y <= shoulder.y + WARMUP_GREETING_WRIST_ABOVE_SHOULDER_MARGIN;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function getWarmupPointDistance(left: MocapPointInput, right: MocapPointInput) {
|
||||
return Math.hypot(left.x - right.x, left.y - right.y);
|
||||
}
|
||||
|
||||
function resolveWarmupArmMetrics(
|
||||
hand: MocapHandInput,
|
||||
command: MocapInputCommand,
|
||||
bodySide: WarmupBodyHandSide,
|
||||
) {
|
||||
const wrist = hand.wrist ?? { x: hand.x, y: hand.y };
|
||||
const shoulder = resolveMocapJointWithBodySide(command, bodySide, 'shoulder');
|
||||
if (!shoulder) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const elbow = resolveMocapJointWithBodySide(command, bodySide, 'elbow');
|
||||
const reach = getWarmupPointDistance(shoulder, wrist);
|
||||
const outwardX =
|
||||
bodySide === 'left' ? shoulder.x - wrist.x : wrist.x - shoulder.x;
|
||||
const upperArmReach = elbow ? getWarmupPointDistance(shoulder, elbow) : null;
|
||||
const angleDeg =
|
||||
(Math.atan2(shoulder.y - wrist.y, Math.abs(wrist.x - shoulder.x)) * 180) /
|
||||
Math.PI;
|
||||
const isNotDrooping = elbow
|
||||
? wrist.y <= elbow.y + WARMUP_GREETING_WRIST_ABOVE_ELBOW_MARGIN
|
||||
: wrist.y <= shoulder.y + WARMUP_GREETING_WRIST_ABOVE_SHOULDER_MARGIN;
|
||||
const isExtended =
|
||||
outwardX >= WARMUP_ARM_SWING_MIN_OUTWARD_X &&
|
||||
reach >= WARMUP_ARM_SWING_MIN_REACH &&
|
||||
(!upperArmReach || reach >= upperArmReach * 1.2) &&
|
||||
isNotDrooping;
|
||||
|
||||
return {
|
||||
angleDeg,
|
||||
reach,
|
||||
isExtended,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveAvatarXFromMocap(command: MocapInputCommand) {
|
||||
return command.bodyCenter?.x ?? null;
|
||||
const bodyCenterX = command.bodyCenter?.x;
|
||||
if (typeof bodyCenterX !== 'number' || !Number.isFinite(bodyCenterX)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return clampMotionUnit(bodyCenterX);
|
||||
}
|
||||
|
||||
function resolveDampedAvatarX(current: number, target: number) {
|
||||
const clampedCurrent = clampMotionUnit(current);
|
||||
const clampedTarget = clampMotionUnit(target);
|
||||
const delta = clampedTarget - clampedCurrent;
|
||||
if (Math.abs(delta) <= AVATAR_MOCAP_DEAD_ZONE) {
|
||||
return clampedCurrent;
|
||||
}
|
||||
|
||||
const smoothedDelta = delta * AVATAR_MOCAP_SMOOTHING;
|
||||
const limitedDelta =
|
||||
Math.sign(smoothedDelta) *
|
||||
Math.min(Math.abs(smoothedDelta), AVATAR_MOCAP_MAX_STEP);
|
||||
|
||||
return clampMotionUnit(clampedCurrent + limitedDelta);
|
||||
}
|
||||
|
||||
function resolveWarmupMocapGestureIntent(
|
||||
@@ -193,22 +412,9 @@ function resolveWarmupMocapGestureIntent(
|
||||
): 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)
|
||||
hasWarmupGreetingWavePath(paths.leftHandPath) ||
|
||||
hasWarmupGreetingWavePath(paths.rightHandPath) ||
|
||||
hasWarmupGreetingWavePath(paths.primaryHandPath)
|
||||
) {
|
||||
return 'greeting';
|
||||
}
|
||||
@@ -216,43 +422,27 @@ function resolveWarmupMocapGestureIntent(
|
||||
|
||||
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))
|
||||
hasWarmupArmSwingPath(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))
|
||||
hasWarmupArmSwingPath(paths.rightHandPath)
|
||||
) {
|
||||
return 'right-hand';
|
||||
}
|
||||
|
||||
if (
|
||||
stepId === 'jump_once' &&
|
||||
hasWarmupMocapAction(command, ['jump', 'jump_once', 'hop', '跳跃', '原地跳'])
|
||||
hasWarmupMocapAction(command, [
|
||||
'jump',
|
||||
'jump_once',
|
||||
'hop',
|
||||
'跳跃',
|
||||
'原地跳',
|
||||
])
|
||||
) {
|
||||
return 'jump';
|
||||
}
|
||||
@@ -304,16 +494,18 @@ function ChildMotionAvatar({
|
||||
className={`child-motion-avatar ${isJumping ? 'child-motion-avatar--jumping' : ''}`}
|
||||
data-testid="child-motion-avatar"
|
||||
style={{
|
||||
left: `${avatarX * 100}%`,
|
||||
left: formatAvatarLeftPercent(avatarX),
|
||||
}}
|
||||
aria-label="用户角色剪影"
|
||||
>
|
||||
<span className="child-motion-avatar__head" />
|
||||
<span className="child-motion-avatar__body" />
|
||||
<span className="child-motion-avatar__arm child-motion-avatar__arm--left" />
|
||||
<span className="child-motion-avatar__arm child-motion-avatar__arm--right" />
|
||||
<span className="child-motion-avatar__leg child-motion-avatar__leg--left" />
|
||||
<span className="child-motion-avatar__leg child-motion-avatar__leg--right" />
|
||||
<span className="child-motion-avatar__sprite" aria-hidden="true">
|
||||
<span className="child-motion-avatar__head" />
|
||||
<span className="child-motion-avatar__body" />
|
||||
<span className="child-motion-avatar__arm child-motion-avatar__arm--left" />
|
||||
<span className="child-motion-avatar__arm child-motion-avatar__arm--right" />
|
||||
<span className="child-motion-avatar__leg child-motion-avatar__leg--left" />
|
||||
<span className="child-motion-avatar__leg child-motion-avatar__leg--right" />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -329,10 +521,12 @@ function ChildMotionRing({
|
||||
<div
|
||||
className={`child-motion-ring ${progress > 0 ? 'child-motion-ring--active' : ''}`}
|
||||
data-testid="child-motion-ring"
|
||||
style={{
|
||||
left: `${targetX * 100}%`,
|
||||
'--child-motion-ring-progress': `${Math.round(progress * 360)}deg`,
|
||||
} as CSSProperties}
|
||||
style={
|
||||
{
|
||||
left: `${targetX * 100}%`,
|
||||
'--child-motion-ring-progress': `${Math.round(progress * 360)}deg`,
|
||||
} as CSSProperties
|
||||
}
|
||||
aria-label="绿色圆环"
|
||||
>
|
||||
<span className="child-motion-ring__core" />
|
||||
@@ -358,12 +552,16 @@ function ChildMotionGestureGuide({
|
||||
return (
|
||||
<div className="child-motion-gesture-guide" aria-hidden="true">
|
||||
{isGreeting ? (
|
||||
<span className="child-motion-gesture-guide__wave">挥手</span>
|
||||
<span className="child-motion-gesture-guide__wave-cat">
|
||||
<span className="child-motion-gesture-guide__wave-cat-body" />
|
||||
<span className="child-motion-gesture-guide__wave-cat-arm child-motion-gesture-guide__wave-cat-arm--left" />
|
||||
<span className="child-motion-gesture-guide__wave-cat-arm child-motion-gesture-guide__wave-cat-arm--right" />
|
||||
</span>
|
||||
) : null}
|
||||
{isLeft || isRight ? (
|
||||
<>
|
||||
<span
|
||||
className={`child-motion-gesture-guide__hand child-motion-gesture-guide__hand--${isLeft ? 'left' : 'right'}`}
|
||||
className={`child-motion-gesture-guide__arm child-motion-gesture-guide__arm--${isLeft ? 'left' : 'right'}`}
|
||||
/>
|
||||
{activePath.map((point, index) => (
|
||||
<span
|
||||
@@ -378,7 +576,9 @@ function ChildMotionGestureGuide({
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
{isJump ? <span className="child-motion-gesture-guide__jump">跳</span> : null}
|
||||
{isJump ? (
|
||||
<span className="child-motion-gesture-guide__jump">跳</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -418,6 +618,9 @@ export function ChildMotionWarmupDemo() {
|
||||
const [stepId, setStepId] = useState<ChildMotionWarmupStepId>(() =>
|
||||
hasCompletedChildMotionWarmupInRuntime() ? 'level_select' : 'center_arrive',
|
||||
);
|
||||
const [stepPhase, setStepPhase] = useState<WarmupStepPhase>(() =>
|
||||
hasCompletedChildMotionWarmupInRuntime() ? 'active' : 'intro',
|
||||
);
|
||||
const [isBabyObjectRuntimeOpen, setIsBabyObjectRuntimeOpen] = useState(false);
|
||||
const [avatarX, setAvatarX] = useState(CHILD_MOTION_CENTER_X);
|
||||
const [calibration, setCalibration] = useState(
|
||||
@@ -429,18 +632,21 @@ export function ChildMotionWarmupDemo() {
|
||||
const [rightHandPath, setRightHandPath] = useState<ChildMotionPoint[]>([]);
|
||||
const [activeHand, setActiveHand] = useState<DragHand | null>(null);
|
||||
const [isJumping, setIsJumping] = useState(false);
|
||||
const [justCompletedText, setJustCompletedText] = useState<string | null>(null);
|
||||
const [cameraAccessState, setCameraAccessState] =
|
||||
useState<CameraAccessState>(() =>
|
||||
typeof navigator === 'undefined' ||
|
||||
!navigator.mediaDevices?.getUserMedia
|
||||
const [justCompletedText, setJustCompletedText] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [cameraAccessState, setCameraAccessState] = useState<CameraAccessState>(
|
||||
() =>
|
||||
typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia
|
||||
? 'blocked'
|
||||
: 'idle',
|
||||
);
|
||||
);
|
||||
const holdCompletionRef = useRef(false);
|
||||
const cameraVideoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const cameraStreamRef = useRef<MediaStream | null>(null);
|
||||
const handledMocapPacketKeyRef = useRef<string | null>(null);
|
||||
const completionTimeoutRef = useRef<number | null>(null);
|
||||
const feedbackTimeoutRef = useRef<number | null>(null);
|
||||
|
||||
const step = getChildMotionWarmupStep(stepId);
|
||||
const mocapInput = useMocapInput({
|
||||
@@ -453,6 +659,10 @@ export function ChildMotionWarmupDemo() {
|
||||
const stepIndex = getStepIndex(stepId);
|
||||
const progressPercent = Math.round((stepIndex / 12) * 100);
|
||||
const holdProgress = getHoldProgress(stepId, avatarX, holdStartedAt, nowMs);
|
||||
const isStepActive = stepPhase === 'active';
|
||||
const shouldShowStepCues = stepPhase !== 'intro';
|
||||
const displayHoldProgress =
|
||||
stepPhase === 'complete' && step.kind === 'position' ? 1 : holdProgress;
|
||||
const targetX = step.target ? getChildMotionTargetX(step.target) : null;
|
||||
const motionSourceState = getMotionSourceState(
|
||||
mocapInput.status,
|
||||
@@ -462,6 +672,10 @@ export function ChildMotionWarmupDemo() {
|
||||
|
||||
const completeStep = useCallback(
|
||||
(completion: Parameters<typeof applyChildMotionWarmupCompletion>[2]) => {
|
||||
if (stepPhase !== 'active') {
|
||||
return;
|
||||
}
|
||||
|
||||
setCalibration((current) =>
|
||||
applyChildMotionWarmupCompletion(stepId, current, completion),
|
||||
);
|
||||
@@ -471,15 +685,31 @@ export function ChildMotionWarmupDemo() {
|
||||
markChildMotionWarmupCompletedInRuntime();
|
||||
}
|
||||
|
||||
setJustCompletedText(
|
||||
stepId === 'warmup_finish' || stepId === 'jump_once' ? null : '真棒',
|
||||
);
|
||||
window.setTimeout(() => setJustCompletedText(null), 720);
|
||||
setStepId(nextStep);
|
||||
const completionText =
|
||||
stepId === 'warmup_finish' || stepId === 'jump_once' ? null : '真棒';
|
||||
setJustCompletedText(completionText);
|
||||
setStepPhase('complete');
|
||||
setHoldStartedAt(null);
|
||||
holdCompletionRef.current = false;
|
||||
|
||||
if (feedbackTimeoutRef.current !== null) {
|
||||
window.clearTimeout(feedbackTimeoutRef.current);
|
||||
}
|
||||
feedbackTimeoutRef.current = window.setTimeout(() => {
|
||||
feedbackTimeoutRef.current = null;
|
||||
setJustCompletedText(null);
|
||||
}, WARMUP_STEP_COMPLETE_PAUSE_MS);
|
||||
|
||||
if (completionTimeoutRef.current !== null) {
|
||||
window.clearTimeout(completionTimeoutRef.current);
|
||||
}
|
||||
completionTimeoutRef.current = window.setTimeout(() => {
|
||||
completionTimeoutRef.current = null;
|
||||
setStepId(nextStep);
|
||||
setStepPhase(nextStep === 'level_select' ? 'active' : 'intro');
|
||||
}, WARMUP_STEP_COMPLETE_PAUSE_MS);
|
||||
},
|
||||
[stepId],
|
||||
[stepId, stepPhase],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -487,6 +717,18 @@ export function ChildMotionWarmupDemo() {
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (completionTimeoutRef.current !== null) {
|
||||
window.clearTimeout(completionTimeoutRef.current);
|
||||
}
|
||||
if (feedbackTimeoutRef.current !== null) {
|
||||
window.clearTimeout(feedbackTimeoutRef.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const videoElement = cameraVideoRef.current;
|
||||
if (
|
||||
@@ -561,10 +803,24 @@ export function ChildMotionWarmupDemo() {
|
||||
setHoldStartedAt(null);
|
||||
setLeftHandPath([]);
|
||||
setRightHandPath([]);
|
||||
}, [stepId]);
|
||||
handledMocapPacketKeyRef.current = null;
|
||||
|
||||
if (step.kind === 'levelSelect') {
|
||||
setStepPhase('active');
|
||||
return;
|
||||
}
|
||||
|
||||
setStepPhase('intro');
|
||||
const timeout = window.setTimeout(
|
||||
() =>
|
||||
setStepPhase((current) => (current === 'intro' ? 'active' : current)),
|
||||
WARMUP_STEP_INTRO_DELAY_MS,
|
||||
);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [step.kind, stepId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (step.kind !== 'position') {
|
||||
if (step.kind !== 'position' || !isStepActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -575,11 +831,12 @@ export function ChildMotionWarmupDemo() {
|
||||
}
|
||||
|
||||
setHoldStartedAt((current) => current ?? Date.now());
|
||||
}, [avatarX, step]);
|
||||
}, [avatarX, isStepActive, step]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
step.kind !== 'position' ||
|
||||
!isStepActive ||
|
||||
holdStartedAt === null ||
|
||||
holdCompletionRef.current ||
|
||||
nowMs - holdStartedAt < CHILD_MOTION_HOLD_DURATION_MS
|
||||
@@ -589,10 +846,13 @@ export function ChildMotionWarmupDemo() {
|
||||
|
||||
holdCompletionRef.current = true;
|
||||
completeStep({ type: 'position', avatarX });
|
||||
}, [avatarX, completeStep, holdStartedAt, nowMs, step.kind]);
|
||||
}, [avatarX, completeStep, holdStartedAt, isStepActive, nowMs, step.kind]);
|
||||
|
||||
useEffect(() => {
|
||||
if (step.kind !== 'narration' && step.kind !== 'finish') {
|
||||
if (
|
||||
!isStepActive ||
|
||||
(step.kind !== 'narration' && step.kind !== 'finish')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -603,10 +863,10 @@ export function ChildMotionWarmupDemo() {
|
||||
: CHILD_MOTION_NARRATION_DURATION_MS,
|
||||
);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [completeStep, step.kind]);
|
||||
}, [completeStep, isStepActive, step.kind]);
|
||||
|
||||
useEffect(() => {
|
||||
if (step.kind !== 'gesture' || !mocapInput.latestCommand) {
|
||||
if (step.kind !== 'gesture' || !isStepActive || !mocapInput.latestCommand) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -619,25 +879,32 @@ export function ChildMotionWarmupDemo() {
|
||||
return;
|
||||
}
|
||||
|
||||
const primaryPoint = mocapHandToChildMotionPoint(command.primaryHand);
|
||||
const leftBodyHand = resolveMocapHandWithBodySide(command, 'left');
|
||||
const rightBodyHand = resolveMocapHandWithBodySide(command, 'right');
|
||||
const primaryBodySide =
|
||||
command.primaryHand === leftBodyHand
|
||||
? 'left'
|
||||
: command.primaryHand === rightBodyHand
|
||||
? 'right'
|
||||
: undefined;
|
||||
const primaryPoint = mocapHandToChildMotionPoint(
|
||||
command.primaryHand,
|
||||
command,
|
||||
primaryBodySide,
|
||||
);
|
||||
const primaryHandSide = command.primaryHand?.side ?? 'unknown';
|
||||
const fallbackPrimaryToLeft =
|
||||
Boolean(primaryPoint) &&
|
||||
!command.leftHand &&
|
||||
(primaryHandSide === 'left' ||
|
||||
primaryHandSide === 'unknown' ||
|
||||
stepId === 'wave_left_hand' ||
|
||||
stepId === 'wave_greeting');
|
||||
!leftBodyHand &&
|
||||
(primaryBodySide === 'left' ||
|
||||
(primaryHandSide === 'unknown' && stepId === 'wave_greeting'));
|
||||
const fallbackPrimaryToRight =
|
||||
Boolean(primaryPoint) &&
|
||||
!command.rightHand &&
|
||||
(primaryHandSide === 'right' ||
|
||||
stepId === 'wave_right_hand');
|
||||
Boolean(primaryPoint) && !rightBodyHand && primaryBodySide === 'right';
|
||||
const leftPoint =
|
||||
mocapHandToChildMotionPoint(command.leftHand) ??
|
||||
mocapHandToChildMotionPoint(leftBodyHand, command, 'left') ??
|
||||
(fallbackPrimaryToLeft ? primaryPoint : null);
|
||||
const rightPoint =
|
||||
mocapHandToChildMotionPoint(command.rightHand) ??
|
||||
mocapHandToChildMotionPoint(rightBodyHand, command, 'right') ??
|
||||
(fallbackPrimaryToRight ? primaryPoint : null);
|
||||
const nextLeftHandPath = leftPoint
|
||||
? appendWarmupMocapPoint(leftHandPath, leftPoint)
|
||||
@@ -646,7 +913,7 @@ export function ChildMotionWarmupDemo() {
|
||||
? appendWarmupMocapPoint(rightHandPath, rightPoint)
|
||||
: rightHandPath;
|
||||
const nextPrimaryHandPath = primaryPoint
|
||||
? command.primaryHand?.side === 'right'
|
||||
? primaryBodySide === 'right'
|
||||
? nextRightHandPath
|
||||
: nextLeftHandPath
|
||||
: [];
|
||||
@@ -675,14 +942,14 @@ export function ChildMotionWarmupDemo() {
|
||||
}
|
||||
|
||||
if (intent === 'right-hand') {
|
||||
const path = [...nextRightHandPath, rightPoint ?? primaryPoint].filter(
|
||||
const path = [...nextRightHandPath, rightPoint].filter(
|
||||
(point): point is ChildMotionPoint => Boolean(point),
|
||||
);
|
||||
completeStep({ type: 'right-hand', path: path.slice(-16) });
|
||||
return;
|
||||
}
|
||||
|
||||
const path = [...nextLeftHandPath, leftPoint ?? primaryPoint].filter(
|
||||
const path = [...nextLeftHandPath, leftPoint].filter(
|
||||
(point): point is ChildMotionPoint => Boolean(point),
|
||||
);
|
||||
completeStep({ type: 'left-hand', path: path.slice(-16) });
|
||||
@@ -693,12 +960,13 @@ export function ChildMotionWarmupDemo() {
|
||||
mocapInput.rawPacketPreview?.receivedAtMs,
|
||||
mocapInput.rawPacketPreview?.text,
|
||||
rightHandPath,
|
||||
isStepActive,
|
||||
step.kind,
|
||||
stepId,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mocapInput.latestCommand) {
|
||||
if (stepPhase === 'complete' || !mocapInput.latestCommand) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -707,11 +975,12 @@ export function ChildMotionWarmupDemo() {
|
||||
return;
|
||||
}
|
||||
|
||||
setAvatarX(nextAvatarX);
|
||||
setAvatarX((current) => resolveDampedAvatarX(current, nextAvatarX));
|
||||
}, [
|
||||
mocapInput.latestCommand,
|
||||
mocapInput.rawPacketPreview?.receivedAtMs,
|
||||
mocapInput.rawPacketPreview?.text,
|
||||
stepPhase,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -720,6 +989,10 @@ export function ChildMotionWarmupDemo() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (stepPhase === 'complete') {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = event.key.toLowerCase();
|
||||
if (key === 'a') {
|
||||
setAvatarX(0.34);
|
||||
@@ -735,7 +1008,7 @@ export function ChildMotionWarmupDemo() {
|
||||
event.preventDefault();
|
||||
setIsJumping(true);
|
||||
window.setTimeout(() => setIsJumping(false), 360);
|
||||
if (stepId === 'jump_once') {
|
||||
if (stepId === 'jump_once' && isStepActive) {
|
||||
completeStep({ type: 'jump', jumpSpace: 0.14 });
|
||||
}
|
||||
}
|
||||
@@ -743,12 +1016,17 @@ export function ChildMotionWarmupDemo() {
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [completeStep, stepId]);
|
||||
}, [completeStep, isStepActive, stepId, stepPhase]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyUp = (event: KeyboardEvent) => {
|
||||
const key = event.key.toLowerCase();
|
||||
if (key === 'a' || key === 'd' || event.code === 'KeyA' || event.code === 'KeyD') {
|
||||
if (
|
||||
key === 'a' ||
|
||||
key === 'd' ||
|
||||
event.code === 'KeyA' ||
|
||||
event.code === 'KeyD'
|
||||
) {
|
||||
setAvatarX(CHILD_MOTION_CENTER_X);
|
||||
}
|
||||
};
|
||||
@@ -758,6 +1036,10 @@ export function ChildMotionWarmupDemo() {
|
||||
}, []);
|
||||
|
||||
const handleStagePointerDown = (event: ReactPointerEvent<HTMLElement>) => {
|
||||
if (!isStepActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.button !== 0 && event.button !== 2) {
|
||||
return;
|
||||
}
|
||||
@@ -805,6 +1087,10 @@ export function ChildMotionWarmupDemo() {
|
||||
: [...rightHandPath, point].slice(-16);
|
||||
setActiveHand(null);
|
||||
|
||||
if (!isStepActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (stepId === 'wave_greeting') {
|
||||
completeStep({ type: 'left-hand', path: completedPath });
|
||||
return;
|
||||
@@ -824,7 +1110,10 @@ export function ChildMotionWarmupDemo() {
|
||||
setIsBabyObjectRuntimeOpen(true);
|
||||
};
|
||||
|
||||
const lineText = useMemo(() => step.spokenLines.join(','), [step.spokenLines]);
|
||||
const lineText = useMemo(
|
||||
() => step.spokenLines.join(','),
|
||||
[step.spokenLines],
|
||||
);
|
||||
|
||||
if (isBabyObjectRuntimeOpen) {
|
||||
return (
|
||||
@@ -845,8 +1134,9 @@ export function ChildMotionWarmupDemo() {
|
||||
</div>
|
||||
|
||||
<section
|
||||
className="child-motion-stage"
|
||||
className={`child-motion-stage child-motion-stage--${stepPhase}`}
|
||||
data-testid="child-motion-stage"
|
||||
data-step-phase={stepPhase}
|
||||
onPointerDown={handleStagePointerDown}
|
||||
onPointerMove={handleStagePointerMove}
|
||||
onPointerUp={handleStagePointerUp}
|
||||
@@ -870,10 +1160,10 @@ export function ChildMotionWarmupDemo() {
|
||||
</div>
|
||||
) : null}
|
||||
<div className="child-motion-floor" aria-hidden="true" />
|
||||
{targetX !== null && step.kind === 'position' ? (
|
||||
<ChildMotionRing targetX={targetX} progress={holdProgress} />
|
||||
{shouldShowStepCues && targetX !== null && step.kind === 'position' ? (
|
||||
<ChildMotionRing targetX={targetX} progress={displayHoldProgress} />
|
||||
) : null}
|
||||
{step.kind === 'gesture' ? (
|
||||
{shouldShowStepCues && step.kind === 'gesture' ? (
|
||||
<ChildMotionGestureGuide
|
||||
stepId={stepId}
|
||||
leftHandPath={leftHandPath}
|
||||
@@ -882,7 +1172,9 @@ export function ChildMotionWarmupDemo() {
|
||||
) : null}
|
||||
<ChildMotionAvatar avatarX={avatarX} isJumping={isJumping} />
|
||||
{justCompletedText ? (
|
||||
<div className="child-motion-floating-reward">{justCompletedText}</div>
|
||||
<div className="child-motion-floating-reward">
|
||||
{justCompletedText}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="child-motion-hud child-motion-hud--top">
|
||||
|
||||
@@ -60,14 +60,25 @@ describe('childMotionWarmupModel', () => {
|
||||
{
|
||||
type: 'left-hand',
|
||||
path: [
|
||||
{ x: 0.3, y: 0.4 },
|
||||
{ x: 0.34, y: 0.32 },
|
||||
{ x: 0.3, y: 0.4, armAngleDeg: 12, armReach: 0.2 },
|
||||
{ x: 0.34, y: 0.32, armAngleDeg: 44, armReach: 0.28 },
|
||||
],
|
||||
},
|
||||
);
|
||||
const withRightHand = applyChildMotionWarmupCompletion(
|
||||
'wave_right_hand',
|
||||
withLeftHand,
|
||||
{
|
||||
type: 'right-hand',
|
||||
path: [
|
||||
{ x: 0.7, y: 0.42, armAngleDeg: 10, armReach: 0.22 },
|
||||
{ x: 0.82, y: 0.3, armAngleDeg: 46, armReach: 0.31 },
|
||||
],
|
||||
},
|
||||
);
|
||||
const completed = applyChildMotionWarmupCompletion(
|
||||
'jump_once',
|
||||
withLeftHand,
|
||||
withRightHand,
|
||||
{
|
||||
type: 'jump',
|
||||
jumpSpace: 0.14,
|
||||
@@ -77,6 +88,16 @@ describe('childMotionWarmupModel', () => {
|
||||
expect(completed.leftBoundary).toBeCloseTo(0.16);
|
||||
expect(completed.rightBoundary).toBeCloseTo(0.16);
|
||||
expect(completed.leftHandPath).toHaveLength(2);
|
||||
expect(completed.leftHandSpace).toEqual({
|
||||
minX: 0.3,
|
||||
maxX: 0.34,
|
||||
minY: 0.32,
|
||||
maxY: 0.4,
|
||||
minAngleDeg: 12,
|
||||
maxAngleDeg: 44,
|
||||
maxReach: 0.28,
|
||||
});
|
||||
expect(completed.rightHandSpace?.maxReach).toBe(0.31);
|
||||
expect(completed.jumpSpace).toBe(0.14);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,6 +32,20 @@ export type ChildMotionWarmupStep = {
|
||||
export type ChildMotionPoint = {
|
||||
x: number;
|
||||
y: number;
|
||||
isRaised?: boolean;
|
||||
isArmExtended?: boolean;
|
||||
armAngleDeg?: number;
|
||||
armReach?: number;
|
||||
};
|
||||
|
||||
export type ChildMotionHandSpace = {
|
||||
minX: number;
|
||||
maxX: number;
|
||||
minY: number;
|
||||
maxY: number;
|
||||
minAngleDeg: number | null;
|
||||
maxAngleDeg: number | null;
|
||||
maxReach: number | null;
|
||||
};
|
||||
|
||||
export type ChildMotionWarmupCalibration = {
|
||||
@@ -39,6 +53,8 @@ export type ChildMotionWarmupCalibration = {
|
||||
rightBoundary: number | null;
|
||||
leftHandPath: ChildMotionPoint[];
|
||||
rightHandPath: ChildMotionPoint[];
|
||||
leftHandSpace: ChildMotionHandSpace | null;
|
||||
rightHandSpace: ChildMotionHandSpace | null;
|
||||
jumpSpace: number | null;
|
||||
};
|
||||
|
||||
@@ -206,10 +222,39 @@ export function createEmptyChildMotionCalibration(): ChildMotionWarmupCalibratio
|
||||
rightBoundary: null,
|
||||
leftHandPath: [],
|
||||
rightHandPath: [],
|
||||
leftHandSpace: null,
|
||||
rightHandSpace: null,
|
||||
jumpSpace: null,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveChildMotionHandSpace(
|
||||
path: ChildMotionPoint[],
|
||||
): ChildMotionHandSpace | null {
|
||||
if (path.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const xValues = path.map((point) => point.x);
|
||||
const yValues = path.map((point) => point.y);
|
||||
const angleValues = path
|
||||
.map((point) => point.armAngleDeg)
|
||||
.filter((angle): angle is number => typeof angle === 'number');
|
||||
const reachValues = path
|
||||
.map((point) => point.armReach)
|
||||
.filter((reach): reach is number => typeof reach === 'number');
|
||||
|
||||
return {
|
||||
minX: Math.min(...xValues),
|
||||
maxX: Math.max(...xValues),
|
||||
minY: Math.min(...yValues),
|
||||
maxY: Math.max(...yValues),
|
||||
minAngleDeg: angleValues.length > 0 ? Math.min(...angleValues) : null,
|
||||
maxAngleDeg: angleValues.length > 0 ? Math.max(...angleValues) : null,
|
||||
maxReach: reachValues.length > 0 ? Math.max(...reachValues) : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function applyChildMotionWarmupCompletion(
|
||||
stepId: ChildMotionWarmupStepId,
|
||||
calibration: ChildMotionWarmupCalibration,
|
||||
@@ -233,6 +278,7 @@ export function applyChildMotionWarmupCompletion(
|
||||
return {
|
||||
...calibration,
|
||||
leftHandPath: completion.path,
|
||||
leftHandSpace: resolveChildMotionHandSpace(completion.path),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -240,6 +286,7 @@ export function applyChildMotionWarmupCompletion(
|
||||
return {
|
||||
...calibration,
|
||||
rightHandPath: completion.path,
|
||||
rightHandSpace: resolveChildMotionHandSpace(completion.path),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user