feat: add edutainment drawing and visual package flows

This commit is contained in:
2026-05-14 14:17:10 +08:00
parent 10e8beea80
commit e444266e1e
109 changed files with 8788 additions and 996 deletions

View File

@@ -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();

View File

@@ -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">

View File

@@ -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);
});
});

View File

@@ -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),
};
}