feat: add baby object match edutainment flow
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-05-12 16:08:59 +08:00
parent cf074837a4
commit d41f260a2a
58 changed files with 5628 additions and 466 deletions

View File

@@ -37,6 +37,18 @@ vi.mock('../../services/useMocapInput', () => ({
}),
}));
vi.mock('../ResolvedAssetImage', () => ({
ResolvedAssetImage: ({
src,
alt,
className,
}: {
src?: string | null;
alt?: string;
className?: string;
}) => (src ? <img src={src} alt={alt} className={className} /> : null),
}));
beforeEach(() => {
resetChildMotionWarmupRuntimeSession();
vi.restoreAllMocks();
@@ -71,6 +83,18 @@ test('re-entering within the same runtime session opens the start button', () =>
expect(screen.getByRole('button', { name: '开始游戏' })).toBeTruthy();
});
test('start button opens the baby object match level', () => {
markChildMotionWarmupCompletedInRuntime();
render(<ChildMotionWarmupDemo />);
fireEvent.click(screen.getByRole('button', { name: '开始游戏' }));
expect(screen.getByTestId('baby-object-match-runtime')).toBeTruthy();
expect(screen.getByText('将物品放入对应的篮子里')).toBeTruthy();
expect(screen.queryByText('下一关正在设计中')).toBeNull();
});
test('developer keyboard input moves the avatar and triggers jump state', () => {
render(<ChildMotionWarmupDemo />);

View File

@@ -4,12 +4,19 @@ import type {
} from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import {
BABY_OBJECT_MATCH_EDUTAINMENT_TAG,
BABY_OBJECT_MATCH_TEMPLATE_ID,
BABY_OBJECT_MATCH_TEMPLATE_NAME,
} from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import type {
MocapConnectionStatus,
MocapHandInput,
MocapInputCommand,
} from '../../services/useMocapInput';
import { useMocapInput } from '../../services/useMocapInput';
import { BabyObjectMatchRuntimeShell } from '../edutainment-runtime/BabyObjectMatchRuntimeShell';
import {
applyChildMotionWarmupCompletion,
CHILD_MOTION_CENTER_X,
@@ -33,6 +40,41 @@ type CameraAccessState = 'idle' | 'requesting' | 'ready' | 'blocked';
type MotionSourceState = 'connecting' | 'ready' | 'waiting' | 'offline';
type WarmupMocapGestureIntent = 'greeting' | 'left-hand' | 'right-hand' | 'jump';
const CHILD_MOTION_BABY_OBJECT_DEMO_DRAFT: BabyObjectMatchDraft = {
draftId: 'child-motion-demo-baby-object-draft',
profileId: 'child-motion-demo-baby-object-profile',
templateId: BABY_OBJECT_MATCH_TEMPLATE_ID,
templateName: BABY_OBJECT_MATCH_TEMPLATE_NAME,
workTitle: '宝贝识物',
workDescription: '苹果和香蕉识物分类',
itemNames: ['苹果', '香蕉'],
itemAssets: [
{
itemId: 'child-motion-demo-baby-object-apple',
itemName: '苹果',
imageSrc:
'data:image/svg+xml;utf8,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 512 512%22%3E%3Crect width=%22512%22 height=%22512%22 rx=%2296%22 fill=%22%23fff1d6%22/%3E%3Ccircle cx=%22256%22 cy=%22266%22 r=%22122%22 fill=%22%23ef5b5b%22/%3E%3Cpath d=%22M250 148c20-50 58-66 102-54-18 45-52 70-102 54Z%22 fill=%22%2351a45f%22/%3E%3Cpath d=%22M256 150c-8-34 2-62 28-84%22 stroke=%22%23734822%22 stroke-width=%2218%22 stroke-linecap=%22round%22 fill=%22none%22/%3E%3Ccircle cx=%22216%22 cy=%22226%22 r=%2218%22 fill=%22%23fff%22 opacity=%22.65%22/%3E%3C/svg%3E',
assetObjectId: null,
generationProvider: 'placeholder',
prompt: '苹果',
},
{
itemId: 'child-motion-demo-baby-object-banana',
itemName: '香蕉',
imageSrc:
'data:image/svg+xml;utf8,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 512 512%22%3E%3Crect width=%22512%22 height=%22512%22 rx=%2296%22 fill=%22%23e9f7ff%22/%3E%3Cpath d=%22M142 302c128 74 228 38 278-122 14 144-84 244-226 220-52-9-84-38-52-98Z%22 fill=%22%23ffd75d%22/%3E%3Cpath d=%22M406 180c6-20 18-34 38-44%22 stroke=%22%238b5b22%22 stroke-width=%2218%22 stroke-linecap=%22round%22/%3E%3Cpath d=%22M158 310c70 40 152 42 218-38%22 stroke=%22%23fff2a7%22 stroke-width=%2220%22 stroke-linecap=%22round%22 fill=%22none%22 opacity=%22.72%22/%3E%3C/svg%3E',
assetObjectId: null,
generationProvider: 'placeholder',
prompt: '香蕉',
},
],
themeTags: [BABY_OBJECT_MATCH_EDUTAINMENT_TAG, '宝贝识物'],
publicationStatus: 'published',
createdAt: '2026-05-11T00:00:00.000Z',
updatedAt: '2026-05-11T00:00:00.000Z',
publishedAt: '2026-05-11T00:00:00.000Z',
};
const WARMUP_MOCAP_WAVE_MIN_POINTS = 3;
const WARMUP_MOCAP_WAVE_MIN_X_RANGE = 0.055;
@@ -246,7 +288,6 @@ function getStepIndex(stepId: ChildMotionWarmupStepId) {
'jump_once',
'warmup_finish',
'level_select',
'play_placeholder',
];
return Math.max(0, order.indexOf(stepId));
}
@@ -377,6 +418,7 @@ export function ChildMotionWarmupDemo() {
const [stepId, setStepId] = useState<ChildMotionWarmupStepId>(() =>
hasCompletedChildMotionWarmupInRuntime() ? 'level_select' : 'center_arrive',
);
const [isBabyObjectRuntimeOpen, setIsBabyObjectRuntimeOpen] = useState(false);
const [avatarX, setAvatarX] = useState(CHILD_MOTION_CENTER_X);
const [calibration, setCalibration] = useState(
createEmptyChildMotionCalibration,
@@ -778,16 +820,24 @@ export function ChildMotionWarmupDemo() {
}
};
const handleStartPlaceholderLevel = () => {
setStepId('play_placeholder');
};
const handleReturnToStart = () => {
setStepId('level_select');
const handleStartBabyObjectLevel = () => {
setIsBabyObjectRuntimeOpen(true);
};
const lineText = useMemo(() => step.spokenLines.join(''), [step.spokenLines]);
if (isBabyObjectRuntimeOpen) {
return (
<BabyObjectMatchRuntimeShell
draft={CHILD_MOTION_BABY_OBJECT_DEMO_DRAFT}
onBack={() => {
setIsBabyObjectRuntimeOpen(false);
setStepId('level_select');
}}
/>
);
}
return (
<main className="child-motion-demo" data-testid="child-motion-demo">
<div className="child-motion-orientation-tip" role="status">
@@ -846,21 +896,12 @@ export function ChildMotionWarmupDemo() {
{step.kind === 'levelSelect' ? (
<div className="child-motion-start-panel">
<button type="button" onClick={handleStartPlaceholderLevel}>
<button type="button" onClick={handleStartBabyObjectLevel}>
</button>
</div>
) : null}
{step.kind === 'placeholder' ? (
<div className="child-motion-start-panel">
<span></span>
<button type="button" onClick={handleReturnToStart}>
</button>
</div>
) : null}
<ChildMotionCalibrationPanel calibration={calibration} />
</section>
</main>

View File

@@ -25,14 +25,11 @@ describe('childMotionWarmupModel', () => {
'jump_once',
'warmup_finish',
'level_select',
'play_placeholder',
]);
expect(resolveNextChildMotionWarmupStep('center_arrive')).toBe(
'wave_greeting',
);
expect(resolveNextChildMotionWarmupStep('level_select')).toBe(
'play_placeholder',
);
expect(resolveNextChildMotionWarmupStep('level_select')).toBe('level_select');
});
it('checks position completion against the active green ring target', () => {

View File

@@ -10,8 +10,7 @@ export type ChildMotionWarmupStepId =
| 'wave_right_hand'
| 'jump_once'
| 'warmup_finish'
| 'level_select'
| 'play_placeholder';
| 'level_select';
export type ChildMotionWarmupTarget = 'center' | 'left' | 'right';
@@ -20,8 +19,7 @@ export type ChildMotionWarmupStepKind =
| 'gesture'
| 'narration'
| 'finish'
| 'levelSelect'
| 'placeholder';
| 'levelSelect';
export type ChildMotionWarmupStep = {
id: ChildMotionWarmupStepId;
@@ -151,12 +149,6 @@ export const CHILD_MOTION_WARMUP_STEPS: ChildMotionWarmupStep[] = [
title: '准备开始',
spokenLines: ['现在开始我们的游戏吧'],
},
{
id: 'play_placeholder',
kind: 'placeholder',
title: '下一关',
spokenLines: ['游戏关卡正在准备中'],
},
];
const STEP_BY_ID = new Map(