feat: add child motion entry and fix auth env
Some checks failed
CI / verify (push) Has been cancelled
Some checks failed
CI / verify (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import { ChildMotionWarmupDemo } from './ChildMotionWarmupDemo';
|
||||
import {
|
||||
markChildMotionWarmupCompletedInRuntime,
|
||||
resetChildMotionWarmupRuntimeSession,
|
||||
} from './childMotionWarmupModel';
|
||||
|
||||
beforeEach(() => {
|
||||
resetChildMotionWarmupRuntimeSession();
|
||||
vi.restoreAllMocks();
|
||||
Object.defineProperty(navigator, 'mediaDevices', {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
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.getByText('请横屏体验')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('re-entering within the same runtime session opens the start button', () => {
|
||||
markChildMotionWarmupCompletedInRuntime();
|
||||
|
||||
render(<ChildMotionWarmupDemo />);
|
||||
|
||||
expect(screen.getByRole('button', { name: '开始游戏' })).toBeTruthy();
|
||||
});
|
||||
|
||||
test('developer keyboard input moves the avatar and triggers jump state', () => {
|
||||
render(<ChildMotionWarmupDemo />);
|
||||
|
||||
const avatar = screen.getByTestId('child-motion-avatar');
|
||||
|
||||
fireEvent.keyDown(window, { key: 'a', code: 'KeyA' });
|
||||
expect(avatar.getAttribute('style')).toContain('left: 34%');
|
||||
|
||||
fireEvent.keyDown(window, { key: 'd', code: 'KeyD' });
|
||||
expect(avatar.getAttribute('style')).toContain('left: 66%');
|
||||
|
||||
fireEvent.keyUp(window, { key: 'd', code: 'KeyD' });
|
||||
expect(avatar.getAttribute('style')).toContain('left: 50%');
|
||||
|
||||
fireEvent.keyDown(window, { key: ' ', code: 'Space' });
|
||||
expect(avatar.className).toContain('child-motion-avatar--jumping');
|
||||
});
|
||||
|
||||
test('connects camera stream and releases it on unmount', async () => {
|
||||
const stopTrack = vi.fn();
|
||||
const stream = {
|
||||
getTracks: () => [
|
||||
{
|
||||
stop: stopTrack,
|
||||
},
|
||||
],
|
||||
} as unknown as MediaStream;
|
||||
const getUserMedia = vi.fn().mockResolvedValue(stream);
|
||||
const play = vi
|
||||
.spyOn(HTMLMediaElement.prototype, 'play')
|
||||
.mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, 'mediaDevices', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getUserMedia,
|
||||
},
|
||||
});
|
||||
|
||||
const { unmount } = render(<ChildMotionWarmupDemo />);
|
||||
|
||||
expect(await screen.findByText('正在连接摄像头')).toBeTruthy();
|
||||
await vi.waitFor(() => {
|
||||
expect(getUserMedia).toHaveBeenCalledWith({
|
||||
audio: false,
|
||||
video: {
|
||||
facingMode: 'user',
|
||||
},
|
||||
});
|
||||
expect(play).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
unmount();
|
||||
|
||||
expect(stopTrack).toHaveBeenCalled();
|
||||
});
|
||||
582
src/components/child-motion-demo/ChildMotionWarmupDemo.tsx
Normal file
582
src/components/child-motion-demo/ChildMotionWarmupDemo.tsx
Normal file
@@ -0,0 +1,582 @@
|
||||
import type {
|
||||
CSSProperties,
|
||||
PointerEvent as ReactPointerEvent,
|
||||
} from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
applyChildMotionWarmupCompletion,
|
||||
CHILD_MOTION_CENTER_X,
|
||||
CHILD_MOTION_FINISH_DURATION_MS,
|
||||
CHILD_MOTION_HOLD_DURATION_MS,
|
||||
CHILD_MOTION_NARRATION_DURATION_MS,
|
||||
type ChildMotionPoint,
|
||||
type ChildMotionWarmupCalibration,
|
||||
type ChildMotionWarmupStepId,
|
||||
createEmptyChildMotionCalibration,
|
||||
getChildMotionTargetX,
|
||||
getChildMotionWarmupStep,
|
||||
hasCompletedChildMotionWarmupInRuntime,
|
||||
isAvatarOnWarmupTarget,
|
||||
markChildMotionWarmupCompletedInRuntime,
|
||||
resolveNextChildMotionWarmupStep,
|
||||
} from './childMotionWarmupModel';
|
||||
|
||||
type DragHand = 'left' | 'right';
|
||||
type CameraAccessState = 'idle' | 'requesting' | 'ready' | 'blocked';
|
||||
|
||||
function clampMotionUnit(value: number) {
|
||||
return Math.max(0, Math.min(1, value));
|
||||
}
|
||||
|
||||
function normalizePointerPoint(
|
||||
event: ReactPointerEvent<HTMLElement>,
|
||||
element: HTMLElement,
|
||||
): ChildMotionPoint {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const width = rect.width || 1;
|
||||
const height = rect.height || 1;
|
||||
return {
|
||||
x: clampMotionUnit((event.clientX - rect.left) / width),
|
||||
y: clampMotionUnit((event.clientY - rect.top) / height),
|
||||
};
|
||||
}
|
||||
|
||||
function formatPercent(value: number | null) {
|
||||
if (value === null) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
return `${Math.round(value * 100)}%`;
|
||||
}
|
||||
|
||||
function getHoldProgress(
|
||||
stepId: ChildMotionWarmupStepId,
|
||||
avatarX: number,
|
||||
holdStartedAt: number | null,
|
||||
nowMs: number,
|
||||
) {
|
||||
const step = getChildMotionWarmupStep(stepId);
|
||||
if (!isAvatarOnWarmupTarget(step, avatarX) || holdStartedAt === null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.min(1, (nowMs - holdStartedAt) / CHILD_MOTION_HOLD_DURATION_MS);
|
||||
}
|
||||
|
||||
function getStepIndex(stepId: ChildMotionWarmupStepId) {
|
||||
const order: ChildMotionWarmupStepId[] = [
|
||||
'center_arrive',
|
||||
'wave_greeting',
|
||||
'warmup_intro',
|
||||
'move_left',
|
||||
'return_center_1',
|
||||
'move_right',
|
||||
'return_center_2',
|
||||
'wave_left_hand',
|
||||
'wave_right_hand',
|
||||
'jump_once',
|
||||
'warmup_finish',
|
||||
'level_select',
|
||||
'play_placeholder',
|
||||
];
|
||||
return Math.max(0, order.indexOf(stepId));
|
||||
}
|
||||
|
||||
function ChildMotionAvatar({
|
||||
avatarX,
|
||||
isJumping,
|
||||
}: {
|
||||
avatarX: number;
|
||||
isJumping: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`child-motion-avatar ${isJumping ? 'child-motion-avatar--jumping' : ''}`}
|
||||
data-testid="child-motion-avatar"
|
||||
style={{
|
||||
left: `${avatarX * 100}%`,
|
||||
}}
|
||||
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" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChildMotionRing({
|
||||
targetX,
|
||||
progress,
|
||||
}: {
|
||||
targetX: number;
|
||||
progress: number;
|
||||
}) {
|
||||
return (
|
||||
<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}
|
||||
aria-label="绿色圆环"
|
||||
>
|
||||
<span className="child-motion-ring__core" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChildMotionGestureGuide({
|
||||
stepId,
|
||||
leftHandPath,
|
||||
rightHandPath,
|
||||
}: {
|
||||
stepId: ChildMotionWarmupStepId;
|
||||
leftHandPath: ChildMotionPoint[];
|
||||
rightHandPath: ChildMotionPoint[];
|
||||
}) {
|
||||
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">
|
||||
{isGreeting ? (
|
||||
<span className="child-motion-gesture-guide__wave">挥手</span>
|
||||
) : null}
|
||||
{isLeft || isRight ? (
|
||||
<>
|
||||
<span
|
||||
className={`child-motion-gesture-guide__hand child-motion-gesture-guide__hand--${isLeft ? 'left' : 'right'}`}
|
||||
/>
|
||||
{activePath.map((point, index) => (
|
||||
<span
|
||||
key={`${isLeft ? 'left' : 'right'}-${index}`}
|
||||
className="child-motion-gesture-guide__trail"
|
||||
style={{
|
||||
left: `${point.x * 100}%`,
|
||||
top: `${point.y * 100}%`,
|
||||
opacity: 0.22 + (index / Math.max(1, activePath.length)) * 0.58,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
{isJump ? <span className="child-motion-gesture-guide__jump">跳</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChildMotionCalibrationPanel({
|
||||
calibration,
|
||||
}: {
|
||||
calibration: ChildMotionWarmupCalibration;
|
||||
}) {
|
||||
return (
|
||||
<div className="child-motion-calibration" aria-label="热身记录">
|
||||
<div>
|
||||
<span>左边界</span>
|
||||
<strong>{formatPercent(calibration.leftBoundary)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>右边界</span>
|
||||
<strong>{formatPercent(calibration.rightBoundary)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>左手</span>
|
||||
<strong>{calibration.leftHandPath.length}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>右手</span>
|
||||
<strong>{calibration.rightHandPath.length}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>跳跃</span>
|
||||
<strong>{formatPercent(calibration.jumpSpace)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChildMotionWarmupDemo() {
|
||||
const [stepId, setStepId] = useState<ChildMotionWarmupStepId>(() =>
|
||||
hasCompletedChildMotionWarmupInRuntime() ? 'level_select' : 'center_arrive',
|
||||
);
|
||||
const [avatarX, setAvatarX] = useState(CHILD_MOTION_CENTER_X);
|
||||
const [calibration, setCalibration] = useState(
|
||||
createEmptyChildMotionCalibration,
|
||||
);
|
||||
const [holdStartedAt, setHoldStartedAt] = useState<number | null>(null);
|
||||
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||
const [leftHandPath, setLeftHandPath] = useState<ChildMotionPoint[]>([]);
|
||||
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
|
||||
? 'blocked'
|
||||
: 'idle',
|
||||
);
|
||||
const holdCompletionRef = useRef(false);
|
||||
const cameraVideoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const cameraStreamRef = useRef<MediaStream | null>(null);
|
||||
|
||||
const step = getChildMotionWarmupStep(stepId);
|
||||
const stepIndex = getStepIndex(stepId);
|
||||
const progressPercent = Math.round((stepIndex / 12) * 100);
|
||||
const holdProgress = getHoldProgress(stepId, avatarX, holdStartedAt, nowMs);
|
||||
const targetX = step.target ? getChildMotionTargetX(step.target) : null;
|
||||
|
||||
const completeStep = useCallback(
|
||||
(completion: Parameters<typeof applyChildMotionWarmupCompletion>[2]) => {
|
||||
setCalibration((current) =>
|
||||
applyChildMotionWarmupCompletion(stepId, current, completion),
|
||||
);
|
||||
|
||||
const nextStep = resolveNextChildMotionWarmupStep(stepId);
|
||||
if (stepId === 'jump_once') {
|
||||
markChildMotionWarmupCompletedInRuntime();
|
||||
}
|
||||
|
||||
setJustCompletedText(
|
||||
stepId === 'warmup_finish' || stepId === 'jump_once' ? null : '真棒',
|
||||
);
|
||||
window.setTimeout(() => setJustCompletedText(null), 720);
|
||||
setStepId(nextStep);
|
||||
setHoldStartedAt(null);
|
||||
holdCompletionRef.current = false;
|
||||
},
|
||||
[stepId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => setNowMs(Date.now()), 120);
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const videoElement = cameraVideoRef.current;
|
||||
if (
|
||||
typeof navigator === 'undefined' ||
|
||||
!navigator.mediaDevices?.getUserMedia ||
|
||||
!videoElement
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
let isMounted = true;
|
||||
const startCamera = async () => {
|
||||
if (!navigator.mediaDevices?.getUserMedia) {
|
||||
if (isMounted) {
|
||||
setCameraAccessState('blocked');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setCameraAccessState('requesting');
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: false,
|
||||
video: {
|
||||
facingMode: 'user',
|
||||
},
|
||||
});
|
||||
|
||||
if (!isMounted) {
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
return;
|
||||
}
|
||||
|
||||
cameraStreamRef.current?.getTracks().forEach((track) => track.stop());
|
||||
cameraStreamRef.current = stream;
|
||||
videoElement.srcObject = stream;
|
||||
await videoElement.play();
|
||||
setCameraAccessState('ready');
|
||||
} catch {
|
||||
cameraStreamRef.current?.getTracks().forEach((track) => track.stop());
|
||||
cameraStreamRef.current = null;
|
||||
videoElement.srcObject = null;
|
||||
if (isMounted) {
|
||||
setCameraAccessState('blocked');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void startCamera();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
const stream = cameraStreamRef.current;
|
||||
cameraStreamRef.current = null;
|
||||
if (stream) {
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
}
|
||||
videoElement.srcObject = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const stream = cameraStreamRef.current;
|
||||
const videoElement = cameraVideoRef.current;
|
||||
if (stream && videoElement && videoElement.srcObject !== stream) {
|
||||
videoElement.srcObject = stream;
|
||||
}
|
||||
}, [cameraAccessState]);
|
||||
|
||||
useEffect(() => {
|
||||
holdCompletionRef.current = false;
|
||||
setHoldStartedAt(null);
|
||||
setLeftHandPath([]);
|
||||
setRightHandPath([]);
|
||||
}, [stepId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (step.kind !== 'position') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isAvatarOnWarmupTarget(step, avatarX)) {
|
||||
setHoldStartedAt(null);
|
||||
holdCompletionRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
setHoldStartedAt((current) => current ?? Date.now());
|
||||
}, [avatarX, step]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
step.kind !== 'position' ||
|
||||
holdStartedAt === null ||
|
||||
holdCompletionRef.current ||
|
||||
nowMs - holdStartedAt < CHILD_MOTION_HOLD_DURATION_MS
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
holdCompletionRef.current = true;
|
||||
completeStep({ type: 'position', avatarX });
|
||||
}, [avatarX, completeStep, holdStartedAt, nowMs, step.kind]);
|
||||
|
||||
useEffect(() => {
|
||||
if (step.kind !== 'narration' && step.kind !== 'finish') {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeout = window.setTimeout(
|
||||
() => completeStep({ type: 'narration' }),
|
||||
step.kind === 'finish'
|
||||
? CHILD_MOTION_FINISH_DURATION_MS
|
||||
: CHILD_MOTION_NARRATION_DURATION_MS,
|
||||
);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [completeStep, step.kind]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.repeat) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = event.key.toLowerCase();
|
||||
if (key === 'a') {
|
||||
setAvatarX(0.34);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === 'd') {
|
||||
setAvatarX(0.66);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.code === 'Space') {
|
||||
event.preventDefault();
|
||||
setIsJumping(true);
|
||||
window.setTimeout(() => setIsJumping(false), 360);
|
||||
if (stepId === 'jump_once') {
|
||||
completeStep({ type: 'jump', jumpSpace: 0.14 });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [completeStep, stepId]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyUp = (event: KeyboardEvent) => {
|
||||
const key = event.key.toLowerCase();
|
||||
if (key === 'a' || key === 'd' || event.code === 'KeyA' || event.code === 'KeyD') {
|
||||
setAvatarX(CHILD_MOTION_CENTER_X);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keyup', handleKeyUp);
|
||||
return () => window.removeEventListener('keyup', handleKeyUp);
|
||||
}, []);
|
||||
|
||||
const handleStagePointerDown = (event: ReactPointerEvent<HTMLElement>) => {
|
||||
if (event.button !== 0 && event.button !== 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
const nextHand: DragHand = event.button === 2 ? 'right' : 'left';
|
||||
setActiveHand(nextHand);
|
||||
const point = normalizePointerPoint(event, event.currentTarget);
|
||||
if (nextHand === 'left') {
|
||||
setLeftHandPath([point]);
|
||||
} else {
|
||||
setRightHandPath([point]);
|
||||
}
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
};
|
||||
|
||||
const handleStagePointerMove = (event: ReactPointerEvent<HTMLElement>) => {
|
||||
if (!activeHand) {
|
||||
return;
|
||||
}
|
||||
|
||||
const point = normalizePointerPoint(event, event.currentTarget);
|
||||
const appendPoint = (points: ChildMotionPoint[]) =>
|
||||
[...points, point].slice(-16);
|
||||
if (activeHand === 'left') {
|
||||
setLeftHandPath(appendPoint);
|
||||
} else {
|
||||
setRightHandPath(appendPoint);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStagePointerUp = (event: ReactPointerEvent<HTMLElement>) => {
|
||||
if (!activeHand) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
const hand = activeHand;
|
||||
const point = normalizePointerPoint(event, event.currentTarget);
|
||||
const completedPath =
|
||||
hand === 'left'
|
||||
? [...leftHandPath, point].slice(-16)
|
||||
: [...rightHandPath, point].slice(-16);
|
||||
setActiveHand(null);
|
||||
|
||||
if (stepId === 'wave_greeting') {
|
||||
completeStep({ type: 'left-hand', path: completedPath });
|
||||
return;
|
||||
}
|
||||
|
||||
if (stepId === 'wave_left_hand' && hand === 'left') {
|
||||
completeStep({ type: 'left-hand', path: completedPath });
|
||||
return;
|
||||
}
|
||||
|
||||
if (stepId === 'wave_right_hand' && hand === 'right') {
|
||||
completeStep({ type: 'right-hand', path: completedPath });
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartPlaceholderLevel = () => {
|
||||
setStepId('play_placeholder');
|
||||
};
|
||||
|
||||
const handleReturnToStart = () => {
|
||||
setStepId('level_select');
|
||||
};
|
||||
|
||||
const lineText = useMemo(() => step.spokenLines.join(','), [step.spokenLines]);
|
||||
|
||||
return (
|
||||
<main className="child-motion-demo" data-testid="child-motion-demo">
|
||||
<div className="child-motion-orientation-tip" role="status">
|
||||
请横屏体验
|
||||
</div>
|
||||
|
||||
<section
|
||||
className="child-motion-stage"
|
||||
data-testid="child-motion-stage"
|
||||
onPointerDown={handleStagePointerDown}
|
||||
onPointerMove={handleStagePointerMove}
|
||||
onPointerUp={handleStagePointerUp}
|
||||
onPointerCancel={handleStagePointerUp}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
>
|
||||
<video
|
||||
ref={cameraVideoRef}
|
||||
className="child-motion-camera-layer"
|
||||
aria-hidden="true"
|
||||
autoPlay
|
||||
muted
|
||||
playsInline
|
||||
/>
|
||||
{cameraAccessState === 'requesting' ? (
|
||||
<div className="child-motion-camera-state" aria-live="polite">
|
||||
正在连接摄像头
|
||||
</div>
|
||||
) : null}
|
||||
{cameraAccessState === 'blocked' ? (
|
||||
<div className="child-motion-camera-state" aria-live="polite">
|
||||
摄像头暂不可用,已切换到本地演示
|
||||
</div>
|
||||
) : null}
|
||||
<div className="child-motion-floor" aria-hidden="true" />
|
||||
{targetX !== null && step.kind === 'position' ? (
|
||||
<ChildMotionRing targetX={targetX} progress={holdProgress} />
|
||||
) : null}
|
||||
{step.kind === 'gesture' ? (
|
||||
<ChildMotionGestureGuide
|
||||
stepId={stepId}
|
||||
leftHandPath={leftHandPath}
|
||||
rightHandPath={rightHandPath}
|
||||
/>
|
||||
) : null}
|
||||
<ChildMotionAvatar avatarX={avatarX} isJumping={isJumping} />
|
||||
{justCompletedText ? (
|
||||
<div className="child-motion-floating-reward">{justCompletedText}</div>
|
||||
) : 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>
|
||||
</div>
|
||||
<span className="child-motion-progress">{progressPercent}%</span>
|
||||
</div>
|
||||
|
||||
{step.kind === 'levelSelect' ? (
|
||||
<div className="child-motion-start-panel">
|
||||
<button type="button" onClick={handleStartPlaceholderLevel}>
|
||||
开始游戏
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
export default ChildMotionWarmupDemo;
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
applyChildMotionWarmupCompletion,
|
||||
CHILD_MOTION_CENTER_X,
|
||||
CHILD_MOTION_WARMUP_STEPS,
|
||||
createEmptyChildMotionCalibration,
|
||||
getChildMotionWarmupStep,
|
||||
isAvatarOnWarmupTarget,
|
||||
resolveNextChildMotionWarmupStep,
|
||||
} from './childMotionWarmupModel';
|
||||
|
||||
describe('childMotionWarmupModel', () => {
|
||||
it('keeps the confirmed warmup order as a strict state chain', () => {
|
||||
expect(CHILD_MOTION_WARMUP_STEPS.map((step) => step.id)).toEqual([
|
||||
'center_arrive',
|
||||
'wave_greeting',
|
||||
'warmup_intro',
|
||||
'move_left',
|
||||
'return_center_1',
|
||||
'move_right',
|
||||
'return_center_2',
|
||||
'wave_left_hand',
|
||||
'wave_right_hand',
|
||||
'jump_once',
|
||||
'warmup_finish',
|
||||
'level_select',
|
||||
'play_placeholder',
|
||||
]);
|
||||
expect(resolveNextChildMotionWarmupStep('center_arrive')).toBe(
|
||||
'wave_greeting',
|
||||
);
|
||||
expect(resolveNextChildMotionWarmupStep('level_select')).toBe(
|
||||
'play_placeholder',
|
||||
);
|
||||
});
|
||||
|
||||
it('checks position completion against the active green ring target', () => {
|
||||
expect(
|
||||
isAvatarOnWarmupTarget(
|
||||
getChildMotionWarmupStep('center_arrive'),
|
||||
CHILD_MOTION_CENTER_X,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isAvatarOnWarmupTarget(getChildMotionWarmupStep('move_left'), 0.66),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('records session-only calibration values from completed steps', () => {
|
||||
const empty = createEmptyChildMotionCalibration();
|
||||
const withLeft = applyChildMotionWarmupCompletion('move_left', empty, {
|
||||
type: 'position',
|
||||
avatarX: 0.34,
|
||||
});
|
||||
const withRight = applyChildMotionWarmupCompletion('move_right', withLeft, {
|
||||
type: 'position',
|
||||
avatarX: 0.66,
|
||||
});
|
||||
const withLeftHand = applyChildMotionWarmupCompletion(
|
||||
'wave_left_hand',
|
||||
withRight,
|
||||
{
|
||||
type: 'left-hand',
|
||||
path: [
|
||||
{ x: 0.3, y: 0.4 },
|
||||
{ x: 0.34, y: 0.32 },
|
||||
],
|
||||
},
|
||||
);
|
||||
const completed = applyChildMotionWarmupCompletion(
|
||||
'jump_once',
|
||||
withLeftHand,
|
||||
{
|
||||
type: 'jump',
|
||||
jumpSpace: 0.14,
|
||||
},
|
||||
);
|
||||
|
||||
expect(completed.leftBoundary).toBeCloseTo(0.16);
|
||||
expect(completed.rightBoundary).toBeCloseTo(0.16);
|
||||
expect(completed.leftHandPath).toHaveLength(2);
|
||||
expect(completed.jumpSpace).toBe(0.14);
|
||||
});
|
||||
});
|
||||
274
src/components/child-motion-demo/childMotionWarmupModel.ts
Normal file
274
src/components/child-motion-demo/childMotionWarmupModel.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
export type ChildMotionWarmupStepId =
|
||||
| 'center_arrive'
|
||||
| 'wave_greeting'
|
||||
| 'warmup_intro'
|
||||
| 'move_left'
|
||||
| 'return_center_1'
|
||||
| 'move_right'
|
||||
| 'return_center_2'
|
||||
| 'wave_left_hand'
|
||||
| 'wave_right_hand'
|
||||
| 'jump_once'
|
||||
| 'warmup_finish'
|
||||
| 'level_select'
|
||||
| 'play_placeholder';
|
||||
|
||||
export type ChildMotionWarmupTarget = 'center' | 'left' | 'right';
|
||||
|
||||
export type ChildMotionWarmupStepKind =
|
||||
| 'position'
|
||||
| 'gesture'
|
||||
| 'narration'
|
||||
| 'finish'
|
||||
| 'levelSelect'
|
||||
| 'placeholder';
|
||||
|
||||
export type ChildMotionWarmupStep = {
|
||||
id: ChildMotionWarmupStepId;
|
||||
kind: ChildMotionWarmupStepKind;
|
||||
title: string;
|
||||
spokenLines: string[];
|
||||
target?: ChildMotionWarmupTarget;
|
||||
};
|
||||
|
||||
export type ChildMotionPoint = {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
export type ChildMotionWarmupCalibration = {
|
||||
leftBoundary: number | null;
|
||||
rightBoundary: number | null;
|
||||
leftHandPath: ChildMotionPoint[];
|
||||
rightHandPath: ChildMotionPoint[];
|
||||
jumpSpace: number | null;
|
||||
};
|
||||
|
||||
export type ChildMotionWarmupCompletion =
|
||||
| {
|
||||
type: 'position';
|
||||
avatarX: number;
|
||||
}
|
||||
| {
|
||||
type: 'left-hand';
|
||||
path: ChildMotionPoint[];
|
||||
}
|
||||
| {
|
||||
type: 'right-hand';
|
||||
path: ChildMotionPoint[];
|
||||
}
|
||||
| {
|
||||
type: 'jump';
|
||||
jumpSpace: number;
|
||||
}
|
||||
| {
|
||||
type: 'narration';
|
||||
};
|
||||
|
||||
export const CHILD_MOTION_CENTER_X = 0.5;
|
||||
export const CHILD_MOTION_LEFT_X = 0.34;
|
||||
export const CHILD_MOTION_RIGHT_X = 0.66;
|
||||
export const CHILD_MOTION_POSITION_EPSILON = 0.045;
|
||||
export const CHILD_MOTION_HOLD_DURATION_MS = 2000;
|
||||
export const CHILD_MOTION_NARRATION_DURATION_MS = 900;
|
||||
export const CHILD_MOTION_FINISH_DURATION_MS = 1200;
|
||||
|
||||
export const CHILD_MOTION_WARMUP_STEPS: ChildMotionWarmupStep[] = [
|
||||
{
|
||||
id: 'center_arrive',
|
||||
kind: 'position',
|
||||
title: '来到圆圈这里',
|
||||
spokenLines: ['欢迎你,小朋友,见到你真开心', '请你来到圆圈这里和我打个招呼吧'],
|
||||
target: 'center',
|
||||
},
|
||||
{
|
||||
id: 'wave_greeting',
|
||||
kind: 'gesture',
|
||||
title: '打个招呼',
|
||||
spokenLines: ['请你来到圆圈这里和我打个招呼吧'],
|
||||
},
|
||||
{
|
||||
id: 'warmup_intro',
|
||||
kind: 'narration',
|
||||
title: '准备热身',
|
||||
spokenLines: ['你好呀小朋友,为了你玩的安全和开心,先来和我一起热个身吧'],
|
||||
},
|
||||
{
|
||||
id: 'move_left',
|
||||
kind: 'position',
|
||||
title: '向左一步',
|
||||
spokenLines: ['向左一步'],
|
||||
target: 'left',
|
||||
},
|
||||
{
|
||||
id: 'return_center_1',
|
||||
kind: 'position',
|
||||
title: '回到中间来',
|
||||
spokenLines: ['回到中间来'],
|
||||
target: 'center',
|
||||
},
|
||||
{
|
||||
id: 'move_right',
|
||||
kind: 'position',
|
||||
title: '向右一步',
|
||||
spokenLines: ['向右一步'],
|
||||
target: 'right',
|
||||
},
|
||||
{
|
||||
id: 'return_center_2',
|
||||
kind: 'position',
|
||||
title: '回到中间来',
|
||||
spokenLines: ['回到中间来'],
|
||||
target: 'center',
|
||||
},
|
||||
{
|
||||
id: 'wave_left_hand',
|
||||
kind: 'gesture',
|
||||
title: '挥动左手',
|
||||
spokenLines: ['挥动左手'],
|
||||
},
|
||||
{
|
||||
id: 'wave_right_hand',
|
||||
kind: 'gesture',
|
||||
title: '挥动右手',
|
||||
spokenLines: ['挥动右手'],
|
||||
},
|
||||
{
|
||||
id: 'jump_once',
|
||||
kind: 'gesture',
|
||||
title: '原地跳一下',
|
||||
spokenLines: ['原地跳一下'],
|
||||
},
|
||||
{
|
||||
id: 'warmup_finish',
|
||||
kind: 'finish',
|
||||
title: '热身完成',
|
||||
spokenLines: ['真厉害,你是我见过最聪明的小朋友', '别走开,现在开始我们的游戏吧'],
|
||||
},
|
||||
{
|
||||
id: 'level_select',
|
||||
kind: 'levelSelect',
|
||||
title: '准备开始',
|
||||
spokenLines: ['现在开始我们的游戏吧'],
|
||||
},
|
||||
{
|
||||
id: 'play_placeholder',
|
||||
kind: 'placeholder',
|
||||
title: '下一关',
|
||||
spokenLines: ['游戏关卡正在准备中'],
|
||||
},
|
||||
];
|
||||
|
||||
const STEP_BY_ID = new Map(
|
||||
CHILD_MOTION_WARMUP_STEPS.map((step) => [step.id, step]),
|
||||
);
|
||||
|
||||
const NEXT_STEP_BY_ID = new Map<ChildMotionWarmupStepId, ChildMotionWarmupStepId>(
|
||||
CHILD_MOTION_WARMUP_STEPS.slice(0, -1).map((step, index) => [
|
||||
step.id,
|
||||
CHILD_MOTION_WARMUP_STEPS[index + 1]!.id,
|
||||
]),
|
||||
);
|
||||
|
||||
let childMotionWarmupCompletedInRuntime = false;
|
||||
|
||||
export function getChildMotionWarmupStep(id: ChildMotionWarmupStepId) {
|
||||
return STEP_BY_ID.get(id) ?? CHILD_MOTION_WARMUP_STEPS[0]!;
|
||||
}
|
||||
|
||||
export function getChildMotionTargetX(target: ChildMotionWarmupTarget) {
|
||||
if (target === 'left') {
|
||||
return CHILD_MOTION_LEFT_X;
|
||||
}
|
||||
|
||||
if (target === 'right') {
|
||||
return CHILD_MOTION_RIGHT_X;
|
||||
}
|
||||
|
||||
return CHILD_MOTION_CENTER_X;
|
||||
}
|
||||
|
||||
export function isAvatarOnWarmupTarget(
|
||||
step: ChildMotionWarmupStep,
|
||||
avatarX: number,
|
||||
) {
|
||||
if (step.kind !== 'position' || !step.target) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
Math.abs(avatarX - getChildMotionTargetX(step.target)) <=
|
||||
CHILD_MOTION_POSITION_EPSILON
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveNextChildMotionWarmupStep(
|
||||
stepId: ChildMotionWarmupStepId,
|
||||
) {
|
||||
return NEXT_STEP_BY_ID.get(stepId) ?? stepId;
|
||||
}
|
||||
|
||||
export function createEmptyChildMotionCalibration(): ChildMotionWarmupCalibration {
|
||||
return {
|
||||
leftBoundary: null,
|
||||
rightBoundary: null,
|
||||
leftHandPath: [],
|
||||
rightHandPath: [],
|
||||
jumpSpace: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function applyChildMotionWarmupCompletion(
|
||||
stepId: ChildMotionWarmupStepId,
|
||||
calibration: ChildMotionWarmupCalibration,
|
||||
completion: ChildMotionWarmupCompletion,
|
||||
): ChildMotionWarmupCalibration {
|
||||
if (stepId === 'move_left' && completion.type === 'position') {
|
||||
return {
|
||||
...calibration,
|
||||
leftBoundary: Math.max(0, CHILD_MOTION_CENTER_X - completion.avatarX),
|
||||
};
|
||||
}
|
||||
|
||||
if (stepId === 'move_right' && completion.type === 'position') {
|
||||
return {
|
||||
...calibration,
|
||||
rightBoundary: Math.max(0, completion.avatarX - CHILD_MOTION_CENTER_X),
|
||||
};
|
||||
}
|
||||
|
||||
if (stepId === 'wave_left_hand' && completion.type === 'left-hand') {
|
||||
return {
|
||||
...calibration,
|
||||
leftHandPath: completion.path,
|
||||
};
|
||||
}
|
||||
|
||||
if (stepId === 'wave_right_hand' && completion.type === 'right-hand') {
|
||||
return {
|
||||
...calibration,
|
||||
rightHandPath: completion.path,
|
||||
};
|
||||
}
|
||||
|
||||
if (stepId === 'jump_once' && completion.type === 'jump') {
|
||||
return {
|
||||
...calibration,
|
||||
jumpSpace: completion.jumpSpace,
|
||||
};
|
||||
}
|
||||
|
||||
return calibration;
|
||||
}
|
||||
|
||||
export function hasCompletedChildMotionWarmupInRuntime() {
|
||||
return childMotionWarmupCompletedInRuntime;
|
||||
}
|
||||
|
||||
export function markChildMotionWarmupCompletedInRuntime() {
|
||||
childMotionWarmupCompletedInRuntime = true;
|
||||
}
|
||||
|
||||
export function resetChildMotionWarmupRuntimeSession() {
|
||||
childMotionWarmupCompletedInRuntime = false;
|
||||
}
|
||||
Reference in New Issue
Block a user