feat: add child motion entry and fix auth env
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-05-10 18:27:51 +08:00
parent 86fc382413
commit 46a254f142
22 changed files with 2868 additions and 58 deletions

View File

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

View 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;

View File

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

View 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;
}