feat(edutainment): refresh baby object match flow
This commit is contained in:
@@ -48,6 +48,8 @@ const mocapMock = vi.hoisted(() => ({
|
||||
rightShoulder?: { x: number; y: number } | null;
|
||||
leftElbow?: { x: number; y: number } | null;
|
||||
rightElbow?: { x: number; y: number } | null;
|
||||
leftWrist?: { x: number; y: number } | null;
|
||||
rightWrist?: { x: number; y: number } | null;
|
||||
};
|
||||
},
|
||||
receivedAtMs: 1,
|
||||
@@ -242,16 +244,18 @@ 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.queryByRole('heading', { name: '来到圆圈这里' })).toBeNull();
|
||||
expect(screen.getByText('欢迎你,小朋友,见到你真开心')).toBeTruthy();
|
||||
expect(screen.queryByLabelText('绿色圆环')).toBeNull();
|
||||
expect(screen.getByText('请横屏体验')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('shows narration first before revealing the step cue', async () => {
|
||||
test('shows the first subtitle before revealing the step cue', async () => {
|
||||
vi.useFakeTimers();
|
||||
render(<ChildMotionWarmupDemo />);
|
||||
|
||||
expect(screen.getByText('来到圆圈这里')).toBeTruthy();
|
||||
expect(screen.getByText('欢迎你,小朋友,见到你真开心')).toBeTruthy();
|
||||
expect(screen.queryByText('来圆圈这里和我打个招呼吧')).toBeNull();
|
||||
expect(screen.queryByLabelText('绿色圆环')).toBeNull();
|
||||
expect(screen.getByTestId('child-motion-stage').dataset.stepPhase).toBe('intro');
|
||||
|
||||
@@ -261,6 +265,25 @@ test('shows narration first before revealing the step cue', async () => {
|
||||
expect(screen.getByTestId('child-motion-stage').dataset.stepPhase).toBe('active');
|
||||
});
|
||||
|
||||
test('switches the center step subtitle to the second line after a two second pause', async () => {
|
||||
vi.useFakeTimers();
|
||||
render(<ChildMotionWarmupDemo />);
|
||||
|
||||
expect(screen.queryByRole('heading', { name: '来到圆圈这里' })).toBeNull();
|
||||
expect(screen.getByText('欢迎你,小朋友,见到你真开心')).toBeTruthy();
|
||||
expect(screen.queryByText('来圆圈这里和我打个招呼吧')).toBeNull();
|
||||
|
||||
await advanceWarmupTime(1999);
|
||||
|
||||
expect(screen.getByText('欢迎你,小朋友,见到你真开心')).toBeTruthy();
|
||||
expect(screen.queryByText('来圆圈这里和我打个招呼吧')).toBeNull();
|
||||
|
||||
await advanceWarmupTime(1);
|
||||
|
||||
expect(screen.queryByText('欢迎你,小朋友,见到你真开心')).toBeNull();
|
||||
expect(screen.getByText('来圆圈这里和我打个招呼吧')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('re-entering within the same runtime session opens the start button', () => {
|
||||
markChildMotionWarmupCompletedInRuntime();
|
||||
|
||||
@@ -299,6 +322,50 @@ test('developer keyboard input moves the avatar and triggers jump state', () =>
|
||||
expect(avatar.className).toContain('child-motion-avatar--jumping');
|
||||
});
|
||||
|
||||
test('developer pointer input renders baby object hand indicators in warmup', async () => {
|
||||
vi.useFakeTimers();
|
||||
render(<ChildMotionWarmupDemo />);
|
||||
|
||||
const stage = screen.getByTestId('child-motion-stage');
|
||||
vi.spyOn(stage, 'getBoundingClientRect').mockReturnValue({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 1000,
|
||||
height: 500,
|
||||
top: 0,
|
||||
right: 1000,
|
||||
bottom: 500,
|
||||
left: 0,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
|
||||
await revealCurrentStepCue();
|
||||
const pointerDownEvent = new Event('pointerdown', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
Object.defineProperties(pointerDownEvent, {
|
||||
button: { value: 0 },
|
||||
buttons: { value: 1 },
|
||||
clientX: { value: 250 },
|
||||
clientY: { value: 150 },
|
||||
pointerId: { value: 1 },
|
||||
});
|
||||
await act(async () => {
|
||||
stage.dispatchEvent(pointerDownEvent);
|
||||
});
|
||||
|
||||
const leftHand = screen.getByTestId('child-motion-left-hand-indicator');
|
||||
expect(leftHand.className).toContain('baby-object-runtime__hand--left');
|
||||
expect(leftHand.getAttribute('style')).toContain(
|
||||
'--baby-object-hand-x: 25%',
|
||||
);
|
||||
expect(leftHand.getAttribute('style')).toContain(
|
||||
'--baby-object-hand-y: 30%',
|
||||
);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('mocap body center dampens small jitter before moving the avatar', async () => {
|
||||
setMocapBodyCenter(0.5);
|
||||
const { rerender } = render(<ChildMotionWarmupDemo />);
|
||||
@@ -325,6 +392,68 @@ test('mocap body center dampens small jitter before moving the avatar', async ()
|
||||
expect(style).not.toContain('left: 34%');
|
||||
});
|
||||
|
||||
test('mocap hand positions render with baby object hand indicators in body-side mapping', async () => {
|
||||
setMocapCameraHandTrackPoint({ cameraSide: 'right', x: 0.24, y: 0.36 });
|
||||
const { rerender } = render(<ChildMotionWarmupDemo />);
|
||||
|
||||
await act(async () => {
|
||||
rerender(<ChildMotionWarmupDemo />);
|
||||
});
|
||||
|
||||
const leftHand = await screen.findByTestId(
|
||||
'child-motion-left-hand-indicator',
|
||||
);
|
||||
expect(leftHand.className).toContain('baby-object-runtime__hand--left');
|
||||
expect(leftHand.getAttribute('style')).toContain(
|
||||
'--baby-object-hand-x: 24%',
|
||||
);
|
||||
expect(leftHand.getAttribute('style')).toContain(
|
||||
'--baby-object-hand-y: 36%',
|
||||
);
|
||||
expect(screen.queryByTestId('child-motion-right-hand-indicator')).toBeNull();
|
||||
});
|
||||
|
||||
test('mocap hand indicators prefer skeleton wrist nodes in warmup', async () => {
|
||||
const cameraRightHand = {
|
||||
x: 0.24,
|
||||
y: 0.36,
|
||||
state: 'unknown',
|
||||
side: 'right',
|
||||
wrist: { x: 0.27, y: 0.39 },
|
||||
};
|
||||
mocapMock.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 },
|
||||
rightWrist: { x: 0.64, y: 0.25 },
|
||||
},
|
||||
hands: [cameraRightHand],
|
||||
primaryHand: cameraRightHand,
|
||||
leftHand: null,
|
||||
rightHand: cameraRightHand,
|
||||
};
|
||||
mocapMock.receivedAtMs += 1;
|
||||
const { rerender } = render(<ChildMotionWarmupDemo />);
|
||||
|
||||
await act(async () => {
|
||||
rerender(<ChildMotionWarmupDemo />);
|
||||
});
|
||||
|
||||
const leftHand = await screen.findByTestId(
|
||||
'child-motion-left-hand-indicator',
|
||||
);
|
||||
expect(leftHand.getAttribute('style')).toContain(
|
||||
'--baby-object-hand-x: 64%',
|
||||
);
|
||||
expect(leftHand.getAttribute('style')).toContain(
|
||||
'--baby-object-hand-y: 25%',
|
||||
);
|
||||
});
|
||||
|
||||
test('mocap body center keeps the warmup flow on the motion data source', async () => {
|
||||
vi.useFakeTimers();
|
||||
setMocapBodyCenter(0.5);
|
||||
@@ -440,6 +569,33 @@ test('mocap greeting requires a real horizontal wave track', async () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('greeting completion goes to warmup intro without praise float text', async () => {
|
||||
vi.useFakeTimers();
|
||||
const { rerender, unmount } = render(<ChildMotionWarmupDemo />);
|
||||
|
||||
await revealCurrentStepCue();
|
||||
await completeCurrentPositionStepByHold();
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByText('打个招呼')).toBeTruthy();
|
||||
});
|
||||
|
||||
await revealCurrentStepCue();
|
||||
await completeGreetingByWaveTrack(rerender);
|
||||
|
||||
expect(screen.queryByText('真棒')).toBeNull();
|
||||
|
||||
await advanceWarmupTime(900);
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByText('准备热身')).toBeTruthy();
|
||||
});
|
||||
expect(screen.queryByText('真棒')).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
unmount();
|
||||
});
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('mocap arm swing steps require body-side mapping and vertical open arm motion', async () => {
|
||||
vi.useFakeTimers();
|
||||
const { rerender, unmount } = render(<ChildMotionWarmupDemo />);
|
||||
@@ -477,6 +633,14 @@ test('mocap arm swing steps require body-side mapping and vertical open arm moti
|
||||
});
|
||||
|
||||
await revealCurrentStepCue();
|
||||
expect(screen.getByTestId('child-motion-arm-swing-guide-left')).toBeTruthy();
|
||||
expect(screen.queryByTestId('child-motion-arm-swing-guide-right')).toBeNull();
|
||||
expect(
|
||||
screen
|
||||
.getByTestId('child-motion-arm-swing-guide-left')
|
||||
.querySelector('.child-motion-gesture-guide__arm-swing-paw-asset'),
|
||||
).toBeTruthy();
|
||||
|
||||
await sendMocapCameraHandTrack(rerender, 'left', [
|
||||
{ x: 0.78, y: 0.5 },
|
||||
{ x: 0.86, y: 0.5 },
|
||||
@@ -505,6 +669,14 @@ test('mocap arm swing steps require body-side mapping and vertical open arm moti
|
||||
});
|
||||
|
||||
await revealCurrentStepCue();
|
||||
expect(screen.getByTestId('child-motion-arm-swing-guide-right')).toBeTruthy();
|
||||
expect(screen.queryByTestId('child-motion-arm-swing-guide-left')).toBeNull();
|
||||
expect(
|
||||
screen
|
||||
.getByTestId('child-motion-arm-swing-guide-right')
|
||||
.querySelector('.child-motion-gesture-guide__arm-swing-paw-asset'),
|
||||
).toBeTruthy();
|
||||
|
||||
await sendMocapCameraHandTrack(rerender, 'right', [
|
||||
{ x: 0.2, y: 0.5 },
|
||||
{ x: 0.16, y: 0.42 },
|
||||
@@ -519,8 +691,9 @@ test('mocap arm swing steps require body-side mapping and vertical open arm moti
|
||||
|
||||
await advanceWarmupTime(900);
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: '原地跳一下' })).toBeTruthy();
|
||||
expect(screen.getByRole('heading', { name: '热身完成' })).toBeTruthy();
|
||||
});
|
||||
expect(screen.queryByRole('heading', { name: '原地跳一下' })).toBeNull();
|
||||
await advanceWarmupTime(720);
|
||||
await act(async () => {
|
||||
unmount();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { CSSProperties, PointerEvent as ReactPointerEvent } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
import {
|
||||
@@ -40,9 +40,9 @@ type WarmupStepPhase = 'intro' | 'active' | 'complete';
|
||||
type WarmupMocapGestureIntent =
|
||||
| 'greeting'
|
||||
| 'left-hand'
|
||||
| 'right-hand'
|
||||
| 'jump';
|
||||
| 'right-hand';
|
||||
type WarmupBodyHandSide = 'left' | 'right';
|
||||
type WarmupHandIndicators = Record<DragHand, ChildMotionPoint | null>;
|
||||
|
||||
const CHILD_MOTION_BABY_OBJECT_DEMO_DRAFT: BabyObjectMatchDraft = {
|
||||
draftId: 'child-motion-demo-baby-object-draft',
|
||||
@@ -94,7 +94,9 @@ 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_SUBTITLE_LINE_DELAY_MS = 2000;
|
||||
const WARMUP_STEP_COMPLETE_PAUSE_MS = 820;
|
||||
const WARMUP_TOTAL_STEPS = 11;
|
||||
const AVATAR_MOCAP_DEAD_ZONE = 0.012;
|
||||
const AVATAR_MOCAP_SMOOTHING = 0.28;
|
||||
const AVATAR_MOCAP_MAX_STEP = 0.035;
|
||||
@@ -128,6 +130,13 @@ function formatAvatarLeftPercent(value: number) {
|
||||
return `${Math.round(clampMotionUnit(value) * 1000) / 10}%`;
|
||||
}
|
||||
|
||||
function createEmptyWarmupHandIndicators(): WarmupHandIndicators {
|
||||
return {
|
||||
left: null,
|
||||
right: null,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveMocapHandWithBodySide(
|
||||
command: MocapInputCommand,
|
||||
side: WarmupBodyHandSide,
|
||||
@@ -136,6 +145,29 @@ function resolveMocapHandWithBodySide(
|
||||
return side === 'left' ? command.rightHand : command.leftHand;
|
||||
}
|
||||
|
||||
function resolveMocapSkeletonWristWithBodySide(
|
||||
command: MocapInputCommand,
|
||||
side: WarmupBodyHandSide,
|
||||
) {
|
||||
const joints = command.bodyJoints;
|
||||
return side === 'left' ? joints?.rightWrist : joints?.leftWrist;
|
||||
}
|
||||
|
||||
function resolveWarmupHandIndicatorsFromMocap(
|
||||
command: MocapInputCommand,
|
||||
): WarmupHandIndicators {
|
||||
return {
|
||||
left: mocapHandToWarmupIndicatorPoint(
|
||||
resolveMocapHandWithBodySide(command, 'left'),
|
||||
resolveMocapSkeletonWristWithBodySide(command, 'left'),
|
||||
),
|
||||
right: mocapHandToWarmupIndicatorPoint(
|
||||
resolveMocapHandWithBodySide(command, 'right'),
|
||||
resolveMocapSkeletonWristWithBodySide(command, 'right'),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveMocapJointWithBodySide(
|
||||
command: MocapInputCommand,
|
||||
side: WarmupBodyHandSide,
|
||||
@@ -175,6 +207,22 @@ function mocapHandToChildMotionPoint(
|
||||
};
|
||||
}
|
||||
|
||||
function mocapHandToWarmupIndicatorPoint(
|
||||
hand: MocapHandInput | null | undefined,
|
||||
skeletonWrist: MocapPointInput | null | undefined,
|
||||
): ChildMotionPoint | null {
|
||||
// 骨架手腕节点比手掌识别结果更稳定;热身指示器优先跟随骨架手腕。
|
||||
const point = skeletonWrist ?? hand?.wrist ?? hand;
|
||||
if (!point) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
x: clampMotionUnit(point.x),
|
||||
y: clampMotionUnit(point.y),
|
||||
};
|
||||
}
|
||||
|
||||
function appendWarmupMocapPoint(
|
||||
points: ChildMotionPoint[],
|
||||
point: ChildMotionPoint,
|
||||
@@ -218,13 +266,6 @@ function getMotionSourceText(state: MotionSourceState) {
|
||||
return '正在连接动作数据';
|
||||
}
|
||||
|
||||
function hasWarmupMocapAction(
|
||||
command: MocapInputCommand,
|
||||
expectedActions: string[],
|
||||
) {
|
||||
return command.actions.some((action) => expectedActions.includes(action));
|
||||
}
|
||||
|
||||
function countWarmupVerticalDirectionChanges(points: ChildMotionPoint[]) {
|
||||
let previousDirection = 0;
|
||||
let directionChanges = 0;
|
||||
@@ -403,7 +444,6 @@ function resolveDampedAvatarX(current: number, target: number) {
|
||||
|
||||
function resolveWarmupMocapGestureIntent(
|
||||
stepId: ChildMotionWarmupStepId,
|
||||
command: MocapInputCommand,
|
||||
paths: {
|
||||
leftHandPath: ChildMotionPoint[];
|
||||
rightHandPath: ChildMotionPoint[];
|
||||
@@ -434,19 +474,6 @@ function resolveWarmupMocapGestureIntent(
|
||||
return 'right-hand';
|
||||
}
|
||||
|
||||
if (
|
||||
stepId === 'jump_once' &&
|
||||
hasWarmupMocapAction(command, [
|
||||
'jump',
|
||||
'jump_once',
|
||||
'hop',
|
||||
'跳跃',
|
||||
'原地跳',
|
||||
])
|
||||
) {
|
||||
return 'jump';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -475,7 +502,6 @@ function getStepIndex(stepId: ChildMotionWarmupStepId) {
|
||||
'return_center_2',
|
||||
'wave_left_hand',
|
||||
'wave_right_hand',
|
||||
'jump_once',
|
||||
'warmup_finish',
|
||||
'level_select',
|
||||
];
|
||||
@@ -546,11 +572,15 @@ function ChildMotionGestureGuide({
|
||||
const isLeft = stepId === 'wave_left_hand';
|
||||
const isRight = stepId === 'wave_right_hand';
|
||||
const isGreeting = stepId === 'wave_greeting';
|
||||
const isJump = stepId === 'jump_once';
|
||||
const activePath = isLeft ? leftHandPath : isRight ? rightHandPath : [];
|
||||
|
||||
return (
|
||||
<div className="child-motion-gesture-guide" aria-hidden="true">
|
||||
<div
|
||||
className={`child-motion-gesture-guide ${
|
||||
isGreeting ? 'child-motion-gesture-guide--greeting' : ''
|
||||
}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{isGreeting ? (
|
||||
<span className="child-motion-gesture-guide__wave-cat">
|
||||
<span className="child-motion-gesture-guide__wave-cat-body" />
|
||||
@@ -561,8 +591,14 @@ function ChildMotionGestureGuide({
|
||||
{isLeft || isRight ? (
|
||||
<>
|
||||
<span
|
||||
className={`child-motion-gesture-guide__arm child-motion-gesture-guide__arm--${isLeft ? 'left' : 'right'}`}
|
||||
/>
|
||||
className={`child-motion-gesture-guide__arm-swing child-motion-gesture-guide__arm-swing--${isLeft ? 'left' : 'right'}`}
|
||||
data-testid={`child-motion-arm-swing-guide-${isLeft ? 'left' : 'right'}`}
|
||||
>
|
||||
<span className="child-motion-gesture-guide__arm-swing-track" />
|
||||
<span className="child-motion-gesture-guide__arm-swing-paw">
|
||||
<span className="child-motion-gesture-guide__arm-swing-paw-asset" />
|
||||
</span>
|
||||
</span>
|
||||
{activePath.map((point, index) => (
|
||||
<span
|
||||
key={`${isLeft ? 'left' : 'right'}-${index}`}
|
||||
@@ -576,9 +612,40 @@ function ChildMotionGestureGuide({
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
{isJump ? (
|
||||
<span className="child-motion-gesture-guide__jump">跳</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChildMotionHandIndicators({
|
||||
hands,
|
||||
}: {
|
||||
hands: WarmupHandIndicators;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="baby-object-runtime__hands child-motion-hand-indicators"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{(['left', 'right'] as const).map((hand) => {
|
||||
const point = hands[hand];
|
||||
if (!point) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={hand}
|
||||
className={`baby-object-runtime__hand baby-object-runtime__hand--${hand} child-motion-hand-indicator`}
|
||||
data-testid={`child-motion-${hand}-hand-indicator`}
|
||||
style={
|
||||
{
|
||||
'--baby-object-hand-x': `${point.x * 100}%`,
|
||||
'--baby-object-hand-y': `${point.y * 100}%`,
|
||||
} as CSSProperties
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -606,10 +673,6 @@ function ChildMotionCalibrationPanel({
|
||||
<span>右手</span>
|
||||
<strong>{calibration.rightHandPath.length}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>跳跃</span>
|
||||
<strong>{formatPercent(calibration.jumpSpace)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -630,11 +693,15 @@ export function ChildMotionWarmupDemo() {
|
||||
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||
const [leftHandPath, setLeftHandPath] = useState<ChildMotionPoint[]>([]);
|
||||
const [rightHandPath, setRightHandPath] = useState<ChildMotionPoint[]>([]);
|
||||
const [handIndicators, setHandIndicators] = useState(
|
||||
createEmptyWarmupHandIndicators,
|
||||
);
|
||||
const [activeHand, setActiveHand] = useState<DragHand | null>(null);
|
||||
const [isJumping, setIsJumping] = useState(false);
|
||||
const [justCompletedText, setJustCompletedText] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [subtitleLineIndex, setSubtitleLineIndex] = useState(0);
|
||||
const [cameraAccessState, setCameraAccessState] = useState<CameraAccessState>(
|
||||
() =>
|
||||
typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia
|
||||
@@ -657,7 +724,9 @@ export function ChildMotionWarmupDemo() {
|
||||
step.kind === 'finish',
|
||||
});
|
||||
const stepIndex = getStepIndex(stepId);
|
||||
const progressPercent = Math.round((stepIndex / 12) * 100);
|
||||
const progressPercent = Math.round(
|
||||
(stepIndex / (WARMUP_TOTAL_STEPS - 1)) * 100,
|
||||
);
|
||||
const holdProgress = getHoldProgress(stepId, avatarX, holdStartedAt, nowMs);
|
||||
const isStepActive = stepPhase === 'active';
|
||||
const shouldShowStepCues = stepPhase !== 'intro';
|
||||
@@ -681,12 +750,14 @@ export function ChildMotionWarmupDemo() {
|
||||
);
|
||||
|
||||
const nextStep = resolveNextChildMotionWarmupStep(stepId);
|
||||
if (stepId === 'jump_once') {
|
||||
if (stepId === 'warmup_finish') {
|
||||
markChildMotionWarmupCompletedInRuntime();
|
||||
}
|
||||
|
||||
const completionText =
|
||||
stepId === 'warmup_finish' || stepId === 'jump_once' ? null : '真棒';
|
||||
stepId === 'wave_greeting' || stepId === 'warmup_finish'
|
||||
? null
|
||||
: '真棒';
|
||||
setJustCompletedText(completionText);
|
||||
setStepPhase('complete');
|
||||
setHoldStartedAt(null);
|
||||
@@ -803,6 +874,7 @@ export function ChildMotionWarmupDemo() {
|
||||
setHoldStartedAt(null);
|
||||
setLeftHandPath([]);
|
||||
setRightHandPath([]);
|
||||
setSubtitleLineIndex(0);
|
||||
handledMocapPacketKeyRef.current = null;
|
||||
|
||||
if (step.kind === 'levelSelect') {
|
||||
@@ -819,6 +891,20 @@ export function ChildMotionWarmupDemo() {
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [step.kind, stepId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (step.spokenLines.length <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSubtitleLineIndex(0);
|
||||
const timeout = window.setTimeout(() => {
|
||||
setSubtitleLineIndex((current) =>
|
||||
Math.min(current + 1, step.spokenLines.length - 1),
|
||||
);
|
||||
}, WARMUP_SUBTITLE_LINE_DELAY_MS);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [step.spokenLines, stepId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (step.kind !== 'position' || !isStepActive) {
|
||||
return;
|
||||
@@ -925,7 +1011,7 @@ export function ChildMotionWarmupDemo() {
|
||||
setRightHandPath(nextRightHandPath);
|
||||
}
|
||||
|
||||
const intent = resolveWarmupMocapGestureIntent(stepId, command, {
|
||||
const intent = resolveWarmupMocapGestureIntent(stepId, {
|
||||
leftHandPath: nextLeftHandPath,
|
||||
rightHandPath: nextRightHandPath,
|
||||
primaryHandPath: nextPrimaryHandPath,
|
||||
@@ -934,13 +1020,6 @@ export function ChildMotionWarmupDemo() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (intent === 'jump') {
|
||||
setIsJumping(true);
|
||||
window.setTimeout(() => setIsJumping(false), 360);
|
||||
completeStep({ type: 'jump', jumpSpace: 0.14 });
|
||||
return;
|
||||
}
|
||||
|
||||
if (intent === 'right-hand') {
|
||||
const path = [...nextRightHandPath, rightPoint].filter(
|
||||
(point): point is ChildMotionPoint => Boolean(point),
|
||||
@@ -965,6 +1044,21 @@ export function ChildMotionWarmupDemo() {
|
||||
stepId,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (stepPhase === 'complete' || !mocapInput.latestCommand) {
|
||||
return;
|
||||
}
|
||||
|
||||
setHandIndicators(
|
||||
resolveWarmupHandIndicatorsFromMocap(mocapInput.latestCommand),
|
||||
);
|
||||
}, [
|
||||
mocapInput.latestCommand,
|
||||
mocapInput.rawPacketPreview?.receivedAtMs,
|
||||
mocapInput.rawPacketPreview?.text,
|
||||
stepPhase,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (stepPhase === 'complete' || !mocapInput.latestCommand) {
|
||||
return;
|
||||
@@ -1008,15 +1102,12 @@ export function ChildMotionWarmupDemo() {
|
||||
event.preventDefault();
|
||||
setIsJumping(true);
|
||||
window.setTimeout(() => setIsJumping(false), 360);
|
||||
if (stepId === 'jump_once' && isStepActive) {
|
||||
completeStep({ type: 'jump', jumpSpace: 0.14 });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [completeStep, isStepActive, stepId, stepPhase]);
|
||||
}, [stepPhase]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyUp = (event: KeyboardEvent) => {
|
||||
@@ -1040,20 +1131,29 @@ export function ChildMotionWarmupDemo() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.button !== 0 && event.button !== 2) {
|
||||
if (
|
||||
event.button !== 0 &&
|
||||
event.button !== 2 &&
|
||||
event.buttons !== 1 &&
|
||||
event.buttons !== 2
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
const nextHand: DragHand = event.button === 2 ? 'right' : 'left';
|
||||
const nextHand: DragHand =
|
||||
event.button === 2 || event.buttons === 2 ? 'right' : 'left';
|
||||
setActiveHand(nextHand);
|
||||
const point = normalizePointerPoint(event, event.currentTarget);
|
||||
setHandIndicators((current) => ({ ...current, [nextHand]: point }));
|
||||
if (nextHand === 'left') {
|
||||
setLeftHandPath([point]);
|
||||
} else {
|
||||
setRightHandPath([point]);
|
||||
}
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
if (typeof event.currentTarget.setPointerCapture === 'function') {
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStagePointerMove = (event: ReactPointerEvent<HTMLElement>) => {
|
||||
@@ -1062,6 +1162,7 @@ export function ChildMotionWarmupDemo() {
|
||||
}
|
||||
|
||||
const point = normalizePointerPoint(event, event.currentTarget);
|
||||
setHandIndicators((current) => ({ ...current, [activeHand]: point }));
|
||||
const appendPoint = (points: ChildMotionPoint[]) =>
|
||||
[...points, point].slice(-16);
|
||||
if (activeHand === 'left') {
|
||||
@@ -1076,7 +1177,10 @@ export function ChildMotionWarmupDemo() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||
if (
|
||||
typeof event.currentTarget.hasPointerCapture === 'function' &&
|
||||
event.currentTarget.hasPointerCapture(event.pointerId)
|
||||
) {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
const hand = activeHand;
|
||||
@@ -1110,10 +1214,8 @@ export function ChildMotionWarmupDemo() {
|
||||
setIsBabyObjectRuntimeOpen(true);
|
||||
};
|
||||
|
||||
const lineText = useMemo(
|
||||
() => step.spokenLines.join(','),
|
||||
[step.spokenLines],
|
||||
);
|
||||
const shouldHideStepTitle = stepId === 'center_arrive';
|
||||
const subtitleText = step.spokenLines[subtitleLineIndex] ?? step.spokenLines[0];
|
||||
|
||||
if (isBabyObjectRuntimeOpen) {
|
||||
return (
|
||||
@@ -1170,6 +1272,7 @@ export function ChildMotionWarmupDemo() {
|
||||
rightHandPath={rightHandPath}
|
||||
/>
|
||||
) : null}
|
||||
<ChildMotionHandIndicators hands={handIndicators} />
|
||||
<ChildMotionAvatar avatarX={avatarX} isJumping={isJumping} />
|
||||
{justCompletedText ? (
|
||||
<div className="child-motion-floating-reward">
|
||||
@@ -1178,10 +1281,16 @@ export function ChildMotionWarmupDemo() {
|
||||
) : null}
|
||||
|
||||
<div className="child-motion-hud child-motion-hud--top">
|
||||
<span className="child-motion-step-count">{`${Math.min(stepIndex + 1, 12)}/12`}</span>
|
||||
<div>
|
||||
<h1>{step.title}</h1>
|
||||
<p>{lineText}</p>
|
||||
<span className="child-motion-step-count">{`${Math.min(stepIndex + 1, WARMUP_TOTAL_STEPS)}/${WARMUP_TOTAL_STEPS}`}</span>
|
||||
<div
|
||||
className={
|
||||
shouldHideStepTitle
|
||||
? 'child-motion-hud__copy child-motion-hud__copy--subtitle-only'
|
||||
: 'child-motion-hud__copy'
|
||||
}
|
||||
>
|
||||
{shouldHideStepTitle ? null : <h1>{step.title}</h1>}
|
||||
<p>{subtitleText}</p>
|
||||
</div>
|
||||
<span className="child-motion-progress">{progressPercent}%</span>
|
||||
</div>
|
||||
|
||||
@@ -22,7 +22,6 @@ describe('childMotionWarmupModel', () => {
|
||||
'return_center_2',
|
||||
'wave_left_hand',
|
||||
'wave_right_hand',
|
||||
'jump_once',
|
||||
'warmup_finish',
|
||||
'level_select',
|
||||
]);
|
||||
@@ -76,19 +75,11 @@ describe('childMotionWarmupModel', () => {
|
||||
],
|
||||
},
|
||||
);
|
||||
const completed = applyChildMotionWarmupCompletion(
|
||||
'jump_once',
|
||||
withRightHand,
|
||||
{
|
||||
type: 'jump',
|
||||
jumpSpace: 0.14,
|
||||
},
|
||||
);
|
||||
|
||||
expect(completed.leftBoundary).toBeCloseTo(0.16);
|
||||
expect(completed.rightBoundary).toBeCloseTo(0.16);
|
||||
expect(completed.leftHandPath).toHaveLength(2);
|
||||
expect(completed.leftHandSpace).toEqual({
|
||||
expect(withRightHand.leftBoundary).toBeCloseTo(0.16);
|
||||
expect(withRightHand.rightBoundary).toBeCloseTo(0.16);
|
||||
expect(withRightHand.leftHandPath).toHaveLength(2);
|
||||
expect(withRightHand.leftHandSpace).toEqual({
|
||||
minX: 0.3,
|
||||
maxX: 0.34,
|
||||
minY: 0.32,
|
||||
@@ -97,7 +88,6 @@ describe('childMotionWarmupModel', () => {
|
||||
maxAngleDeg: 44,
|
||||
maxReach: 0.28,
|
||||
});
|
||||
expect(completed.rightHandSpace?.maxReach).toBe(0.31);
|
||||
expect(completed.jumpSpace).toBe(0.14);
|
||||
expect(withRightHand.rightHandSpace?.maxReach).toBe(0.31);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,7 +8,6 @@ export type ChildMotionWarmupStepId =
|
||||
| 'return_center_2'
|
||||
| 'wave_left_hand'
|
||||
| 'wave_right_hand'
|
||||
| 'jump_once'
|
||||
| 'warmup_finish'
|
||||
| 'level_select';
|
||||
|
||||
@@ -55,7 +54,6 @@ export type ChildMotionWarmupCalibration = {
|
||||
rightHandPath: ChildMotionPoint[];
|
||||
leftHandSpace: ChildMotionHandSpace | null;
|
||||
rightHandSpace: ChildMotionHandSpace | null;
|
||||
jumpSpace: number | null;
|
||||
};
|
||||
|
||||
export type ChildMotionWarmupCompletion =
|
||||
@@ -71,10 +69,6 @@ export type ChildMotionWarmupCompletion =
|
||||
type: 'right-hand';
|
||||
path: ChildMotionPoint[];
|
||||
}
|
||||
| {
|
||||
type: 'jump';
|
||||
jumpSpace: number;
|
||||
}
|
||||
| {
|
||||
type: 'narration';
|
||||
};
|
||||
@@ -92,14 +86,14 @@ export const CHILD_MOTION_WARMUP_STEPS: ChildMotionWarmupStep[] = [
|
||||
id: 'center_arrive',
|
||||
kind: 'position',
|
||||
title: '来到圆圈这里',
|
||||
spokenLines: ['欢迎你,小朋友,见到你真开心', '请你来到圆圈这里和我打个招呼吧'],
|
||||
spokenLines: ['欢迎你,小朋友,见到你真开心', '来圆圈这里和我打个招呼吧'],
|
||||
target: 'center',
|
||||
},
|
||||
{
|
||||
id: 'wave_greeting',
|
||||
kind: 'gesture',
|
||||
title: '打个招呼',
|
||||
spokenLines: ['请你来到圆圈这里和我打个招呼吧'],
|
||||
spokenLines: ['来圆圈这里和我打个招呼吧'],
|
||||
},
|
||||
{
|
||||
id: 'warmup_intro',
|
||||
@@ -147,12 +141,6 @@ export const CHILD_MOTION_WARMUP_STEPS: ChildMotionWarmupStep[] = [
|
||||
title: '挥动右手',
|
||||
spokenLines: ['挥动右手'],
|
||||
},
|
||||
{
|
||||
id: 'jump_once',
|
||||
kind: 'gesture',
|
||||
title: '原地跳一下',
|
||||
spokenLines: ['原地跳一下'],
|
||||
},
|
||||
{
|
||||
id: 'warmup_finish',
|
||||
kind: 'finish',
|
||||
@@ -224,7 +212,6 @@ export function createEmptyChildMotionCalibration(): ChildMotionWarmupCalibratio
|
||||
rightHandPath: [],
|
||||
leftHandSpace: null,
|
||||
rightHandSpace: null,
|
||||
jumpSpace: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -290,13 +277,6 @@ export function applyChildMotionWarmupCompletion(
|
||||
};
|
||||
}
|
||||
|
||||
if (stepId === 'jump_once' && completion.type === 'jump') {
|
||||
return {
|
||||
...calibration,
|
||||
jumpSpace: completion.jumpSpace,
|
||||
};
|
||||
}
|
||||
|
||||
return calibration;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user