1313 lines
39 KiB
TypeScript
1313 lines
39 KiB
TypeScript
import type { CSSProperties, PointerEvent as ReactPointerEvent } from 'react';
|
|
import { useCallback, useEffect, 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,
|
|
MocapPointInput,
|
|
} from '../../services/useMocapInput';
|
|
import { useMocapInput } from '../../services/useMocapInput';
|
|
import { BabyObjectMatchRuntimeShell } from '../edutainment-runtime/BabyObjectMatchRuntimeShell';
|
|
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';
|
|
type MotionSourceState = 'connecting' | 'ready' | 'waiting' | 'offline';
|
|
type WarmupStepPhase = 'intro' | 'active' | 'complete';
|
|
type WarmupMocapGestureIntent =
|
|
| 'greeting'
|
|
| 'left-hand'
|
|
| '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',
|
|
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: '香蕉',
|
|
},
|
|
],
|
|
visualPackage: null,
|
|
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_X_RANGE = 0.055;
|
|
const WARMUP_ARM_SWING_MIN_POINTS = 5;
|
|
const WARMUP_ARM_SWING_MIN_VERTICAL_RANGE = 0.08;
|
|
const WARMUP_ARM_SWING_MIN_ANGLE_RANGE_DEG = 28;
|
|
const WARMUP_ARM_SWING_MIN_REACH = 0.12;
|
|
const WARMUP_ARM_SWING_MIN_OUTWARD_X = 0.1;
|
|
const WARMUP_ARM_SWING_DIRECTION_EPSILON = 0.012;
|
|
const WARMUP_GREETING_WAVE_MIN_POINTS = 5;
|
|
const WARMUP_GREETING_WAVE_MIN_X_RANGE = 0.075;
|
|
const WARMUP_GREETING_WAVE_MIN_DIRECTION_CHANGES = 1;
|
|
const WARMUP_GREETING_WAVE_DIRECTION_EPSILON = 0.008;
|
|
const WARMUP_GREETING_WRIST_ABOVE_ELBOW_MARGIN = 0.04;
|
|
const WARMUP_GREETING_WRIST_ABOVE_SHOULDER_MARGIN = 0.08;
|
|
const WARMUP_STEP_INTRO_DELAY_MS = 1000;
|
|
const WARMUP_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;
|
|
|
|
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 formatAvatarLeftPercent(value: number) {
|
|
return `${Math.round(clampMotionUnit(value) * 1000) / 10}%`;
|
|
}
|
|
|
|
function createEmptyWarmupHandIndicators(): WarmupHandIndicators {
|
|
return {
|
|
left: null,
|
|
right: null,
|
|
};
|
|
}
|
|
|
|
function resolveMocapHandWithBodySide(
|
|
command: MocapInputCommand,
|
|
side: WarmupBodyHandSide,
|
|
) {
|
|
// 本地 mocap 的 handedness 目前是摄像头视角:画面右侧手对应用户身体左手。
|
|
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,
|
|
joint: 'shoulder' | 'elbow',
|
|
) {
|
|
const joints = command.bodyJoints;
|
|
if (side === 'left') {
|
|
return joint === 'shoulder' ? joints?.rightShoulder : joints?.rightElbow;
|
|
}
|
|
|
|
return joint === 'shoulder' ? joints?.leftShoulder : joints?.leftElbow;
|
|
}
|
|
|
|
function mocapHandToChildMotionPoint(
|
|
hand: MocapHandInput | null | undefined,
|
|
command?: MocapInputCommand,
|
|
bodySide?: WarmupBodyHandSide,
|
|
): ChildMotionPoint | null {
|
|
if (!hand) {
|
|
return null;
|
|
}
|
|
|
|
const armMetrics =
|
|
command && bodySide
|
|
? resolveWarmupArmMetrics(hand, command, bodySide)
|
|
: null;
|
|
|
|
return {
|
|
x: clampMotionUnit(hand.x),
|
|
y: clampMotionUnit(hand.y),
|
|
isRaised: command
|
|
? isWarmupGreetingHandRaised(hand, command, bodySide)
|
|
: undefined,
|
|
isArmExtended: armMetrics?.isExtended,
|
|
armAngleDeg: armMetrics?.angleDeg,
|
|
armReach: armMetrics?.reach,
|
|
};
|
|
}
|
|
|
|
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,
|
|
) {
|
|
return [...points, point].slice(-16);
|
|
}
|
|
|
|
function getMotionSourceState(
|
|
mocapStatus: MocapConnectionStatus,
|
|
latestCommand: MocapInputCommand | null,
|
|
): MotionSourceState {
|
|
if (mocapStatus === 'connecting' || mocapStatus === 'idle') {
|
|
return 'connecting';
|
|
}
|
|
|
|
if (mocapStatus === 'connected') {
|
|
return latestCommand &&
|
|
(Boolean(latestCommand.bodyCenter) ||
|
|
Boolean(latestCommand.hands?.length) ||
|
|
latestCommand.actions.length > 0)
|
|
? 'ready'
|
|
: 'waiting';
|
|
}
|
|
|
|
return 'offline';
|
|
}
|
|
|
|
function getMotionSourceText(state: MotionSourceState) {
|
|
if (state === 'ready') {
|
|
return '动作数据已连接';
|
|
}
|
|
|
|
if (state === 'waiting') {
|
|
return '动作数据已连接,等待识别';
|
|
}
|
|
|
|
if (state === 'offline') {
|
|
return '动作数据暂不可用,已保留本地调试';
|
|
}
|
|
|
|
return '正在连接动作数据';
|
|
}
|
|
|
|
function countWarmupVerticalDirectionChanges(points: ChildMotionPoint[]) {
|
|
let previousDirection = 0;
|
|
let directionChanges = 0;
|
|
|
|
for (let index = 1; index < points.length; index += 1) {
|
|
const delta = points[index]!.y - points[index - 1]!.y;
|
|
if (Math.abs(delta) < WARMUP_ARM_SWING_DIRECTION_EPSILON) {
|
|
continue;
|
|
}
|
|
|
|
const direction = Math.sign(delta);
|
|
if (previousDirection !== 0 && direction !== previousDirection) {
|
|
directionChanges += 1;
|
|
}
|
|
previousDirection = direction;
|
|
}
|
|
|
|
return directionChanges;
|
|
}
|
|
|
|
function hasWarmupArmSwingPath(points: ChildMotionPoint[]) {
|
|
const extendedPoints = points.filter((point) => point.isArmExtended);
|
|
if (extendedPoints.length < WARMUP_ARM_SWING_MIN_POINTS) {
|
|
return false;
|
|
}
|
|
|
|
const xValues = extendedPoints.map((point) => point.x);
|
|
const yValues = extendedPoints.map((point) => point.y);
|
|
const angleValues = extendedPoints
|
|
.map((point) => point.armAngleDeg)
|
|
.filter((angle): angle is number => typeof angle === 'number');
|
|
const xRange = Math.max(...xValues) - Math.min(...xValues);
|
|
const yRange = Math.max(...yValues) - Math.min(...yValues);
|
|
const angleRange =
|
|
angleValues.length > 0
|
|
? Math.max(...angleValues) - Math.min(...angleValues)
|
|
: 0;
|
|
|
|
return (
|
|
xRange >= WARMUP_MOCAP_WAVE_MIN_X_RANGE &&
|
|
yRange >= WARMUP_ARM_SWING_MIN_VERTICAL_RANGE &&
|
|
angleRange >= WARMUP_ARM_SWING_MIN_ANGLE_RANGE_DEG &&
|
|
countWarmupVerticalDirectionChanges(extendedPoints) >= 1
|
|
);
|
|
}
|
|
|
|
function countWarmupHorizontalDirectionChanges(points: ChildMotionPoint[]) {
|
|
let previousDirection = 0;
|
|
let directionChanges = 0;
|
|
|
|
for (let index = 1; index < points.length; index += 1) {
|
|
const delta = points[index]!.x - points[index - 1]!.x;
|
|
if (Math.abs(delta) < WARMUP_GREETING_WAVE_DIRECTION_EPSILON) {
|
|
continue;
|
|
}
|
|
|
|
const direction = Math.sign(delta);
|
|
if (previousDirection !== 0 && direction !== previousDirection) {
|
|
directionChanges += 1;
|
|
}
|
|
previousDirection = direction;
|
|
}
|
|
|
|
return directionChanges;
|
|
}
|
|
|
|
function hasWarmupGreetingWavePath(points: ChildMotionPoint[]) {
|
|
const raisedPoints = points.filter((point) => point.isRaised);
|
|
if (raisedPoints.length < WARMUP_GREETING_WAVE_MIN_POINTS) {
|
|
return false;
|
|
}
|
|
|
|
const xValues = raisedPoints.map((point) => point.x);
|
|
const xRange = Math.max(...xValues) - Math.min(...xValues);
|
|
return (
|
|
xRange >= WARMUP_GREETING_WAVE_MIN_X_RANGE &&
|
|
countWarmupHorizontalDirectionChanges(raisedPoints) >=
|
|
WARMUP_GREETING_WAVE_MIN_DIRECTION_CHANGES
|
|
);
|
|
}
|
|
|
|
function isWarmupGreetingHandRaised(
|
|
hand: MocapHandInput,
|
|
command: MocapInputCommand,
|
|
bodySide?: WarmupBodyHandSide,
|
|
) {
|
|
const wrist = hand.wrist ?? { x: hand.x, y: hand.y };
|
|
const elbow = bodySide
|
|
? resolveMocapJointWithBodySide(command, bodySide, 'elbow')
|
|
: hand.side === 'left'
|
|
? command.bodyJoints?.leftElbow
|
|
: hand.side === 'right'
|
|
? command.bodyJoints?.rightElbow
|
|
: null;
|
|
if (elbow) {
|
|
return wrist.y <= elbow.y + WARMUP_GREETING_WRIST_ABOVE_ELBOW_MARGIN;
|
|
}
|
|
|
|
const shoulder = bodySide
|
|
? resolveMocapJointWithBodySide(command, bodySide, 'shoulder')
|
|
: hand.side === 'left'
|
|
? command.bodyJoints?.leftShoulder
|
|
: hand.side === 'right'
|
|
? command.bodyJoints?.rightShoulder
|
|
: null;
|
|
if (shoulder) {
|
|
return wrist.y <= shoulder.y + WARMUP_GREETING_WRIST_ABOVE_SHOULDER_MARGIN;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function getWarmupPointDistance(left: MocapPointInput, right: MocapPointInput) {
|
|
return Math.hypot(left.x - right.x, left.y - right.y);
|
|
}
|
|
|
|
function resolveWarmupArmMetrics(
|
|
hand: MocapHandInput,
|
|
command: MocapInputCommand,
|
|
bodySide: WarmupBodyHandSide,
|
|
) {
|
|
const wrist = hand.wrist ?? { x: hand.x, y: hand.y };
|
|
const shoulder = resolveMocapJointWithBodySide(command, bodySide, 'shoulder');
|
|
if (!shoulder) {
|
|
return null;
|
|
}
|
|
|
|
const elbow = resolveMocapJointWithBodySide(command, bodySide, 'elbow');
|
|
const reach = getWarmupPointDistance(shoulder, wrist);
|
|
const outwardX =
|
|
bodySide === 'left' ? shoulder.x - wrist.x : wrist.x - shoulder.x;
|
|
const upperArmReach = elbow ? getWarmupPointDistance(shoulder, elbow) : null;
|
|
const angleDeg =
|
|
(Math.atan2(shoulder.y - wrist.y, Math.abs(wrist.x - shoulder.x)) * 180) /
|
|
Math.PI;
|
|
const isNotDrooping = elbow
|
|
? wrist.y <= elbow.y + WARMUP_GREETING_WRIST_ABOVE_ELBOW_MARGIN
|
|
: wrist.y <= shoulder.y + WARMUP_GREETING_WRIST_ABOVE_SHOULDER_MARGIN;
|
|
const isExtended =
|
|
outwardX >= WARMUP_ARM_SWING_MIN_OUTWARD_X &&
|
|
reach >= WARMUP_ARM_SWING_MIN_REACH &&
|
|
(!upperArmReach || reach >= upperArmReach * 1.2) &&
|
|
isNotDrooping;
|
|
|
|
return {
|
|
angleDeg,
|
|
reach,
|
|
isExtended,
|
|
};
|
|
}
|
|
|
|
function resolveAvatarXFromMocap(command: MocapInputCommand) {
|
|
const bodyCenterX = command.bodyCenter?.x;
|
|
if (typeof bodyCenterX !== 'number' || !Number.isFinite(bodyCenterX)) {
|
|
return null;
|
|
}
|
|
|
|
return clampMotionUnit(bodyCenterX);
|
|
}
|
|
|
|
function resolveDampedAvatarX(current: number, target: number) {
|
|
const clampedCurrent = clampMotionUnit(current);
|
|
const clampedTarget = clampMotionUnit(target);
|
|
const delta = clampedTarget - clampedCurrent;
|
|
if (Math.abs(delta) <= AVATAR_MOCAP_DEAD_ZONE) {
|
|
return clampedCurrent;
|
|
}
|
|
|
|
const smoothedDelta = delta * AVATAR_MOCAP_SMOOTHING;
|
|
const limitedDelta =
|
|
Math.sign(smoothedDelta) *
|
|
Math.min(Math.abs(smoothedDelta), AVATAR_MOCAP_MAX_STEP);
|
|
|
|
return clampMotionUnit(clampedCurrent + limitedDelta);
|
|
}
|
|
|
|
function resolveWarmupMocapGestureIntent(
|
|
stepId: ChildMotionWarmupStepId,
|
|
paths: {
|
|
leftHandPath: ChildMotionPoint[];
|
|
rightHandPath: ChildMotionPoint[];
|
|
primaryHandPath: ChildMotionPoint[];
|
|
},
|
|
): WarmupMocapGestureIntent | null {
|
|
if (stepId === 'wave_greeting') {
|
|
if (
|
|
hasWarmupGreetingWavePath(paths.leftHandPath) ||
|
|
hasWarmupGreetingWavePath(paths.rightHandPath) ||
|
|
hasWarmupGreetingWavePath(paths.primaryHandPath)
|
|
) {
|
|
return 'greeting';
|
|
}
|
|
}
|
|
|
|
if (
|
|
stepId === 'wave_left_hand' &&
|
|
hasWarmupArmSwingPath(paths.leftHandPath)
|
|
) {
|
|
return 'left-hand';
|
|
}
|
|
|
|
if (
|
|
stepId === 'wave_right_hand' &&
|
|
hasWarmupArmSwingPath(paths.rightHandPath)
|
|
) {
|
|
return 'right-hand';
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
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',
|
|
'warmup_finish',
|
|
'level_select',
|
|
];
|
|
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: formatAvatarLeftPercent(avatarX),
|
|
}}
|
|
aria-label="用户角色剪影"
|
|
>
|
|
<span className="child-motion-avatar__sprite" aria-hidden="true">
|
|
<span className="child-motion-avatar__head" />
|
|
<span className="child-motion-avatar__body" />
|
|
<span className="child-motion-avatar__arm child-motion-avatar__arm--left" />
|
|
<span className="child-motion-avatar__arm child-motion-avatar__arm--right" />
|
|
<span className="child-motion-avatar__leg child-motion-avatar__leg--left" />
|
|
<span className="child-motion-avatar__leg child-motion-avatar__leg--right" />
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 activePath = isLeft ? leftHandPath : isRight ? rightHandPath : [];
|
|
|
|
return (
|
|
<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" />
|
|
<span className="child-motion-gesture-guide__wave-cat-arm child-motion-gesture-guide__wave-cat-arm--left" />
|
|
<span className="child-motion-gesture-guide__wave-cat-arm child-motion-gesture-guide__wave-cat-arm--right" />
|
|
</span>
|
|
) : null}
|
|
{isLeft || isRight ? (
|
|
<>
|
|
<span
|
|
className={`child-motion-gesture-guide__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}`}
|
|
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}
|
|
</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>
|
|
);
|
|
}
|
|
|
|
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>
|
|
);
|
|
}
|
|
|
|
export function ChildMotionWarmupDemo() {
|
|
const [stepId, setStepId] = useState<ChildMotionWarmupStepId>(() =>
|
|
hasCompletedChildMotionWarmupInRuntime() ? 'level_select' : 'center_arrive',
|
|
);
|
|
const [stepPhase, setStepPhase] = useState<WarmupStepPhase>(() =>
|
|
hasCompletedChildMotionWarmupInRuntime() ? 'active' : 'intro',
|
|
);
|
|
const [isBabyObjectRuntimeOpen, setIsBabyObjectRuntimeOpen] = useState(false);
|
|
const [avatarX, setAvatarX] = useState(CHILD_MOTION_CENTER_X);
|
|
const [calibration, setCalibration] = useState(
|
|
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 [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
|
|
? 'blocked'
|
|
: 'idle',
|
|
);
|
|
const holdCompletionRef = useRef(false);
|
|
const cameraVideoRef = useRef<HTMLVideoElement | null>(null);
|
|
const cameraStreamRef = useRef<MediaStream | null>(null);
|
|
const handledMocapPacketKeyRef = useRef<string | null>(null);
|
|
const completionTimeoutRef = useRef<number | null>(null);
|
|
const feedbackTimeoutRef = useRef<number | null>(null);
|
|
|
|
const step = getChildMotionWarmupStep(stepId);
|
|
const mocapInput = useMocapInput({
|
|
enabled:
|
|
step.kind === 'position' ||
|
|
step.kind === 'gesture' ||
|
|
step.kind === 'narration' ||
|
|
step.kind === 'finish',
|
|
});
|
|
const stepIndex = getStepIndex(stepId);
|
|
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';
|
|
const displayHoldProgress =
|
|
stepPhase === 'complete' && step.kind === 'position' ? 1 : holdProgress;
|
|
const targetX = step.target ? getChildMotionTargetX(step.target) : null;
|
|
const motionSourceState = getMotionSourceState(
|
|
mocapInput.status,
|
|
mocapInput.latestCommand,
|
|
);
|
|
const motionSourceText = getMotionSourceText(motionSourceState);
|
|
|
|
const completeStep = useCallback(
|
|
(completion: Parameters<typeof applyChildMotionWarmupCompletion>[2]) => {
|
|
if (stepPhase !== 'active') {
|
|
return;
|
|
}
|
|
|
|
setCalibration((current) =>
|
|
applyChildMotionWarmupCompletion(stepId, current, completion),
|
|
);
|
|
|
|
const nextStep = resolveNextChildMotionWarmupStep(stepId);
|
|
if (stepId === 'warmup_finish') {
|
|
markChildMotionWarmupCompletedInRuntime();
|
|
}
|
|
|
|
const completionText =
|
|
stepId === 'wave_greeting' || stepId === 'warmup_finish'
|
|
? null
|
|
: '真棒';
|
|
setJustCompletedText(completionText);
|
|
setStepPhase('complete');
|
|
setHoldStartedAt(null);
|
|
holdCompletionRef.current = false;
|
|
|
|
if (feedbackTimeoutRef.current !== null) {
|
|
window.clearTimeout(feedbackTimeoutRef.current);
|
|
}
|
|
feedbackTimeoutRef.current = window.setTimeout(() => {
|
|
feedbackTimeoutRef.current = null;
|
|
setJustCompletedText(null);
|
|
}, WARMUP_STEP_COMPLETE_PAUSE_MS);
|
|
|
|
if (completionTimeoutRef.current !== null) {
|
|
window.clearTimeout(completionTimeoutRef.current);
|
|
}
|
|
completionTimeoutRef.current = window.setTimeout(() => {
|
|
completionTimeoutRef.current = null;
|
|
setStepId(nextStep);
|
|
setStepPhase(nextStep === 'level_select' ? 'active' : 'intro');
|
|
}, WARMUP_STEP_COMPLETE_PAUSE_MS);
|
|
},
|
|
[stepId, stepPhase],
|
|
);
|
|
|
|
useEffect(() => {
|
|
const timer = window.setInterval(() => setNowMs(Date.now()), 120);
|
|
return () => window.clearInterval(timer);
|
|
}, []);
|
|
|
|
useEffect(
|
|
() => () => {
|
|
if (completionTimeoutRef.current !== null) {
|
|
window.clearTimeout(completionTimeoutRef.current);
|
|
}
|
|
if (feedbackTimeoutRef.current !== null) {
|
|
window.clearTimeout(feedbackTimeoutRef.current);
|
|
}
|
|
},
|
|
[],
|
|
);
|
|
|
|
useEffect(() => {
|
|
const videoElement = cameraVideoRef.current;
|
|
if (
|
|
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([]);
|
|
setSubtitleLineIndex(0);
|
|
handledMocapPacketKeyRef.current = null;
|
|
|
|
if (step.kind === 'levelSelect') {
|
|
setStepPhase('active');
|
|
return;
|
|
}
|
|
|
|
setStepPhase('intro');
|
|
const timeout = window.setTimeout(
|
|
() =>
|
|
setStepPhase((current) => (current === 'intro' ? 'active' : current)),
|
|
WARMUP_STEP_INTRO_DELAY_MS,
|
|
);
|
|
return () => window.clearTimeout(timeout);
|
|
}, [step.kind, stepId]);
|
|
|
|
useEffect(() => {
|
|
if (step.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;
|
|
}
|
|
|
|
if (!isAvatarOnWarmupTarget(step, avatarX)) {
|
|
setHoldStartedAt(null);
|
|
holdCompletionRef.current = false;
|
|
return;
|
|
}
|
|
|
|
setHoldStartedAt((current) => current ?? Date.now());
|
|
}, [avatarX, isStepActive, step]);
|
|
|
|
useEffect(() => {
|
|
if (
|
|
step.kind !== 'position' ||
|
|
!isStepActive ||
|
|
holdStartedAt === null ||
|
|
holdCompletionRef.current ||
|
|
nowMs - holdStartedAt < CHILD_MOTION_HOLD_DURATION_MS
|
|
) {
|
|
return;
|
|
}
|
|
|
|
holdCompletionRef.current = true;
|
|
completeStep({ type: 'position', avatarX });
|
|
}, [avatarX, completeStep, holdStartedAt, isStepActive, nowMs, step.kind]);
|
|
|
|
useEffect(() => {
|
|
if (
|
|
!isStepActive ||
|
|
(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, isStepActive, step.kind]);
|
|
|
|
useEffect(() => {
|
|
if (step.kind !== 'gesture' || !isStepActive || !mocapInput.latestCommand) {
|
|
return;
|
|
}
|
|
|
|
const command = mocapInput.latestCommand;
|
|
const packetKey =
|
|
mocapInput.rawPacketPreview?.receivedAtMs !== undefined
|
|
? `${mocapInput.rawPacketPreview.receivedAtMs}:${mocapInput.rawPacketPreview.text}`
|
|
: JSON.stringify(command);
|
|
if (handledMocapPacketKeyRef.current === packetKey) {
|
|
return;
|
|
}
|
|
|
|
const leftBodyHand = resolveMocapHandWithBodySide(command, 'left');
|
|
const rightBodyHand = resolveMocapHandWithBodySide(command, 'right');
|
|
const primaryBodySide =
|
|
command.primaryHand === leftBodyHand
|
|
? 'left'
|
|
: command.primaryHand === rightBodyHand
|
|
? 'right'
|
|
: undefined;
|
|
const primaryPoint = mocapHandToChildMotionPoint(
|
|
command.primaryHand,
|
|
command,
|
|
primaryBodySide,
|
|
);
|
|
const primaryHandSide = command.primaryHand?.side ?? 'unknown';
|
|
const fallbackPrimaryToLeft =
|
|
Boolean(primaryPoint) &&
|
|
!leftBodyHand &&
|
|
(primaryBodySide === 'left' ||
|
|
(primaryHandSide === 'unknown' && stepId === 'wave_greeting'));
|
|
const fallbackPrimaryToRight =
|
|
Boolean(primaryPoint) && !rightBodyHand && primaryBodySide === 'right';
|
|
const leftPoint =
|
|
mocapHandToChildMotionPoint(leftBodyHand, command, 'left') ??
|
|
(fallbackPrimaryToLeft ? primaryPoint : null);
|
|
const rightPoint =
|
|
mocapHandToChildMotionPoint(rightBodyHand, command, 'right') ??
|
|
(fallbackPrimaryToRight ? primaryPoint : null);
|
|
const nextLeftHandPath = leftPoint
|
|
? appendWarmupMocapPoint(leftHandPath, leftPoint)
|
|
: leftHandPath;
|
|
const nextRightHandPath = rightPoint
|
|
? appendWarmupMocapPoint(rightHandPath, rightPoint)
|
|
: rightHandPath;
|
|
const nextPrimaryHandPath = primaryPoint
|
|
? primaryBodySide === 'right'
|
|
? nextRightHandPath
|
|
: nextLeftHandPath
|
|
: [];
|
|
handledMocapPacketKeyRef.current = packetKey;
|
|
if (leftPoint) {
|
|
setLeftHandPath(nextLeftHandPath);
|
|
}
|
|
if (rightPoint) {
|
|
setRightHandPath(nextRightHandPath);
|
|
}
|
|
|
|
const intent = resolveWarmupMocapGestureIntent(stepId, {
|
|
leftHandPath: nextLeftHandPath,
|
|
rightHandPath: nextRightHandPath,
|
|
primaryHandPath: nextPrimaryHandPath,
|
|
});
|
|
if (!intent) {
|
|
return;
|
|
}
|
|
|
|
if (intent === 'right-hand') {
|
|
const path = [...nextRightHandPath, rightPoint].filter(
|
|
(point): point is ChildMotionPoint => Boolean(point),
|
|
);
|
|
completeStep({ type: 'right-hand', path: path.slice(-16) });
|
|
return;
|
|
}
|
|
|
|
const path = [...nextLeftHandPath, leftPoint].filter(
|
|
(point): point is ChildMotionPoint => Boolean(point),
|
|
);
|
|
completeStep({ type: 'left-hand', path: path.slice(-16) });
|
|
}, [
|
|
completeStep,
|
|
leftHandPath,
|
|
mocapInput.latestCommand,
|
|
mocapInput.rawPacketPreview?.receivedAtMs,
|
|
mocapInput.rawPacketPreview?.text,
|
|
rightHandPath,
|
|
isStepActive,
|
|
step.kind,
|
|
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;
|
|
}
|
|
|
|
const nextAvatarX = resolveAvatarXFromMocap(mocapInput.latestCommand);
|
|
if (nextAvatarX === null) {
|
|
return;
|
|
}
|
|
|
|
setAvatarX((current) => resolveDampedAvatarX(current, nextAvatarX));
|
|
}, [
|
|
mocapInput.latestCommand,
|
|
mocapInput.rawPacketPreview?.receivedAtMs,
|
|
mocapInput.rawPacketPreview?.text,
|
|
stepPhase,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
if (event.repeat) {
|
|
return;
|
|
}
|
|
|
|
if (stepPhase === 'complete') {
|
|
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);
|
|
}
|
|
};
|
|
|
|
window.addEventListener('keydown', handleKeyDown);
|
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
}, [stepPhase]);
|
|
|
|
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 (!isStepActive) {
|
|
return;
|
|
}
|
|
|
|
if (
|
|
event.button !== 0 &&
|
|
event.button !== 2 &&
|
|
event.buttons !== 1 &&
|
|
event.buttons !== 2
|
|
) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
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]);
|
|
}
|
|
if (typeof event.currentTarget.setPointerCapture === 'function') {
|
|
event.currentTarget.setPointerCapture(event.pointerId);
|
|
}
|
|
};
|
|
|
|
const handleStagePointerMove = (event: ReactPointerEvent<HTMLElement>) => {
|
|
if (!activeHand) {
|
|
return;
|
|
}
|
|
|
|
const point = normalizePointerPoint(event, event.currentTarget);
|
|
setHandIndicators((current) => ({ ...current, [activeHand]: point }));
|
|
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 (
|
|
typeof event.currentTarget.hasPointerCapture === 'function' &&
|
|
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 (!isStepActive) {
|
|
return;
|
|
}
|
|
|
|
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 handleStartBabyObjectLevel = () => {
|
|
setIsBabyObjectRuntimeOpen(true);
|
|
};
|
|
|
|
const shouldHideStepTitle = stepId === 'center_arrive';
|
|
const subtitleText = step.spokenLines[subtitleLineIndex] ?? step.spokenLines[0];
|
|
|
|
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">
|
|
请横屏体验
|
|
</div>
|
|
|
|
<section
|
|
className={`child-motion-stage child-motion-stage--${stepPhase}`}
|
|
data-testid="child-motion-stage"
|
|
data-step-phase={stepPhase}
|
|
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
|
|
/>
|
|
{motionSourceState !== 'ready' ? (
|
|
<div
|
|
className={`child-motion-camera-state child-motion-camera-state--${motionSourceState}`}
|
|
aria-live="polite"
|
|
>
|
|
{motionSourceText}
|
|
</div>
|
|
) : null}
|
|
<div className="child-motion-floor" aria-hidden="true" />
|
|
{shouldShowStepCues && targetX !== null && step.kind === 'position' ? (
|
|
<ChildMotionRing targetX={targetX} progress={displayHoldProgress} />
|
|
) : null}
|
|
{shouldShowStepCues && step.kind === 'gesture' ? (
|
|
<ChildMotionGestureGuide
|
|
stepId={stepId}
|
|
leftHandPath={leftHandPath}
|
|
rightHandPath={rightHandPath}
|
|
/>
|
|
) : null}
|
|
<ChildMotionHandIndicators hands={handIndicators} />
|
|
<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, 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>
|
|
|
|
{step.kind === 'levelSelect' ? (
|
|
<div className="child-motion-start-panel">
|
|
<button type="button" onClick={handleStartBabyObjectLevel}>
|
|
开始游戏
|
|
</button>
|
|
</div>
|
|
) : null}
|
|
|
|
<ChildMotionCalibrationPanel calibration={calibration} />
|
|
</section>
|
|
</main>
|
|
);
|
|
}
|
|
|
|
export default ChildMotionWarmupDemo;
|