feat: add edutainment drawing and visual package flows
This commit is contained in:
@@ -1,7 +1,4 @@
|
||||
import type {
|
||||
CSSProperties,
|
||||
PointerEvent as ReactPointerEvent,
|
||||
} from 'react';
|
||||
import type { CSSProperties, PointerEvent as ReactPointerEvent } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
@@ -14,6 +11,7 @@ import type {
|
||||
MocapConnectionStatus,
|
||||
MocapHandInput,
|
||||
MocapInputCommand,
|
||||
MocapPointInput,
|
||||
} from '../../services/useMocapInput';
|
||||
import { useMocapInput } from '../../services/useMocapInput';
|
||||
import { BabyObjectMatchRuntimeShell } from '../edutainment-runtime/BabyObjectMatchRuntimeShell';
|
||||
@@ -38,7 +36,13 @@ import {
|
||||
type DragHand = 'left' | 'right';
|
||||
type CameraAccessState = 'idle' | 'requesting' | 'ready' | 'blocked';
|
||||
type MotionSourceState = 'connecting' | 'ready' | 'waiting' | 'offline';
|
||||
type WarmupMocapGestureIntent = 'greeting' | 'left-hand' | 'right-hand' | 'jump';
|
||||
type WarmupStepPhase = 'intro' | 'active' | 'complete';
|
||||
type WarmupMocapGestureIntent =
|
||||
| 'greeting'
|
||||
| 'left-hand'
|
||||
| 'right-hand'
|
||||
| 'jump';
|
||||
type WarmupBodyHandSide = 'left' | 'right';
|
||||
|
||||
const CHILD_MOTION_BABY_OBJECT_DEMO_DRAFT: BabyObjectMatchDraft = {
|
||||
draftId: 'child-motion-demo-baby-object-draft',
|
||||
@@ -68,6 +72,7 @@ const CHILD_MOTION_BABY_OBJECT_DEMO_DRAFT: BabyObjectMatchDraft = {
|
||||
prompt: '香蕉',
|
||||
},
|
||||
],
|
||||
visualPackage: null,
|
||||
themeTags: [BABY_OBJECT_MATCH_EDUTAINMENT_TAG, '宝贝识物'],
|
||||
publicationStatus: 'published',
|
||||
createdAt: '2026-05-11T00:00:00.000Z',
|
||||
@@ -75,8 +80,24 @@ const CHILD_MOTION_BABY_OBJECT_DEMO_DRAFT: BabyObjectMatchDraft = {
|
||||
publishedAt: '2026-05-11T00:00:00.000Z',
|
||||
};
|
||||
|
||||
const WARMUP_MOCAP_WAVE_MIN_POINTS = 3;
|
||||
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_STEP_COMPLETE_PAUSE_MS = 820;
|
||||
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));
|
||||
@@ -103,16 +124,54 @@ function formatPercent(value: number | null) {
|
||||
return `${Math.round(value * 100)}%`;
|
||||
}
|
||||
|
||||
function formatAvatarLeftPercent(value: number) {
|
||||
return `${Math.round(clampMotionUnit(value) * 1000) / 10}%`;
|
||||
}
|
||||
|
||||
function resolveMocapHandWithBodySide(
|
||||
command: MocapInputCommand,
|
||||
side: WarmupBodyHandSide,
|
||||
) {
|
||||
// 本地 mocap 的 handedness 目前是摄像头视角:画面右侧手对应用户身体左手。
|
||||
return side === 'left' ? command.rightHand : command.leftHand;
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -166,20 +225,180 @@ function hasWarmupMocapAction(
|
||||
return command.actions.some((action) => expectedActions.includes(action));
|
||||
}
|
||||
|
||||
function hasWarmupMocapWavePath(points: ChildMotionPoint[]) {
|
||||
if (points.length < WARMUP_MOCAP_WAVE_MIN_POINTS) {
|
||||
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 = points.map((point) => point.x);
|
||||
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 (
|
||||
Math.max(...xValues) - Math.min(...xValues) >=
|
||||
WARMUP_MOCAP_WAVE_MIN_X_RANGE
|
||||
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) {
|
||||
return command.bodyCenter?.x ?? null;
|
||||
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(
|
||||
@@ -193,22 +412,9 @@ function resolveWarmupMocapGestureIntent(
|
||||
): WarmupMocapGestureIntent | null {
|
||||
if (stepId === 'wave_greeting') {
|
||||
if (
|
||||
hasWarmupMocapAction(command, [
|
||||
'wave',
|
||||
'wave_greeting',
|
||||
'hand_wave',
|
||||
'hello',
|
||||
'greeting',
|
||||
'open_palm',
|
||||
'handwave',
|
||||
'wavehand',
|
||||
'招手',
|
||||
'挥手',
|
||||
]) ||
|
||||
command.hands?.some((hand) => hand.state === 'open_palm') ||
|
||||
hasWarmupMocapWavePath(paths.leftHandPath) ||
|
||||
hasWarmupMocapWavePath(paths.rightHandPath) ||
|
||||
hasWarmupMocapWavePath(paths.primaryHandPath)
|
||||
hasWarmupGreetingWavePath(paths.leftHandPath) ||
|
||||
hasWarmupGreetingWavePath(paths.rightHandPath) ||
|
||||
hasWarmupGreetingWavePath(paths.primaryHandPath)
|
||||
) {
|
||||
return 'greeting';
|
||||
}
|
||||
@@ -216,43 +422,27 @@ function resolveWarmupMocapGestureIntent(
|
||||
|
||||
if (
|
||||
stepId === 'wave_left_hand' &&
|
||||
(hasWarmupMocapAction(command, [
|
||||
'left_wave',
|
||||
'wave_left',
|
||||
'left_hand_wave',
|
||||
'wave_left_hand',
|
||||
'left_handwave',
|
||||
'lefthand_wave',
|
||||
'lefthandwave',
|
||||
'左手挥手',
|
||||
'挥动左手',
|
||||
]) ||
|
||||
hasWarmupMocapWavePath(paths.leftHandPath))
|
||||
hasWarmupArmSwingPath(paths.leftHandPath)
|
||||
) {
|
||||
return 'left-hand';
|
||||
}
|
||||
|
||||
if (
|
||||
stepId === 'wave_right_hand' &&
|
||||
(hasWarmupMocapAction(command, [
|
||||
'right_wave',
|
||||
'wave_right',
|
||||
'right_hand_wave',
|
||||
'wave_right_hand',
|
||||
'right_handwave',
|
||||
'righthand_wave',
|
||||
'righthandwave',
|
||||
'右手挥手',
|
||||
'挥动右手',
|
||||
]) ||
|
||||
hasWarmupMocapWavePath(paths.rightHandPath))
|
||||
hasWarmupArmSwingPath(paths.rightHandPath)
|
||||
) {
|
||||
return 'right-hand';
|
||||
}
|
||||
|
||||
if (
|
||||
stepId === 'jump_once' &&
|
||||
hasWarmupMocapAction(command, ['jump', 'jump_once', 'hop', '跳跃', '原地跳'])
|
||||
hasWarmupMocapAction(command, [
|
||||
'jump',
|
||||
'jump_once',
|
||||
'hop',
|
||||
'跳跃',
|
||||
'原地跳',
|
||||
])
|
||||
) {
|
||||
return 'jump';
|
||||
}
|
||||
@@ -304,16 +494,18 @@ function ChildMotionAvatar({
|
||||
className={`child-motion-avatar ${isJumping ? 'child-motion-avatar--jumping' : ''}`}
|
||||
data-testid="child-motion-avatar"
|
||||
style={{
|
||||
left: `${avatarX * 100}%`,
|
||||
left: formatAvatarLeftPercent(avatarX),
|
||||
}}
|
||||
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" />
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -329,10 +521,12 @@ function ChildMotionRing({
|
||||
<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}
|
||||
style={
|
||||
{
|
||||
left: `${targetX * 100}%`,
|
||||
'--child-motion-ring-progress': `${Math.round(progress * 360)}deg`,
|
||||
} as CSSProperties
|
||||
}
|
||||
aria-label="绿色圆环"
|
||||
>
|
||||
<span className="child-motion-ring__core" />
|
||||
@@ -358,12 +552,16 @@ function ChildMotionGestureGuide({
|
||||
return (
|
||||
<div className="child-motion-gesture-guide" aria-hidden="true">
|
||||
{isGreeting ? (
|
||||
<span className="child-motion-gesture-guide__wave">挥手</span>
|
||||
<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__hand child-motion-gesture-guide__hand--${isLeft ? 'left' : 'right'}`}
|
||||
className={`child-motion-gesture-guide__arm child-motion-gesture-guide__arm--${isLeft ? 'left' : 'right'}`}
|
||||
/>
|
||||
{activePath.map((point, index) => (
|
||||
<span
|
||||
@@ -378,7 +576,9 @@ function ChildMotionGestureGuide({
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
{isJump ? <span className="child-motion-gesture-guide__jump">跳</span> : null}
|
||||
{isJump ? (
|
||||
<span className="child-motion-gesture-guide__jump">跳</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -418,6 +618,9 @@ 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(
|
||||
@@ -429,18 +632,21 @@ export function ChildMotionWarmupDemo() {
|
||||
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
|
||||
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 handledMocapPacketKeyRef = useRef<string | null>(null);
|
||||
const completionTimeoutRef = useRef<number | null>(null);
|
||||
const feedbackTimeoutRef = useRef<number | null>(null);
|
||||
|
||||
const step = getChildMotionWarmupStep(stepId);
|
||||
const mocapInput = useMocapInput({
|
||||
@@ -453,6 +659,10 @@ export function ChildMotionWarmupDemo() {
|
||||
const stepIndex = getStepIndex(stepId);
|
||||
const progressPercent = Math.round((stepIndex / 12) * 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,
|
||||
@@ -462,6 +672,10 @@ export function ChildMotionWarmupDemo() {
|
||||
|
||||
const completeStep = useCallback(
|
||||
(completion: Parameters<typeof applyChildMotionWarmupCompletion>[2]) => {
|
||||
if (stepPhase !== 'active') {
|
||||
return;
|
||||
}
|
||||
|
||||
setCalibration((current) =>
|
||||
applyChildMotionWarmupCompletion(stepId, current, completion),
|
||||
);
|
||||
@@ -471,15 +685,31 @@ export function ChildMotionWarmupDemo() {
|
||||
markChildMotionWarmupCompletedInRuntime();
|
||||
}
|
||||
|
||||
setJustCompletedText(
|
||||
stepId === 'warmup_finish' || stepId === 'jump_once' ? null : '真棒',
|
||||
);
|
||||
window.setTimeout(() => setJustCompletedText(null), 720);
|
||||
setStepId(nextStep);
|
||||
const completionText =
|
||||
stepId === 'warmup_finish' || stepId === 'jump_once' ? 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],
|
||||
[stepId, stepPhase],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -487,6 +717,18 @@ export function ChildMotionWarmupDemo() {
|
||||
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 (
|
||||
@@ -561,10 +803,24 @@ export function ChildMotionWarmupDemo() {
|
||||
setHoldStartedAt(null);
|
||||
setLeftHandPath([]);
|
||||
setRightHandPath([]);
|
||||
}, [stepId]);
|
||||
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.kind !== 'position') {
|
||||
if (step.kind !== 'position' || !isStepActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -575,11 +831,12 @@ export function ChildMotionWarmupDemo() {
|
||||
}
|
||||
|
||||
setHoldStartedAt((current) => current ?? Date.now());
|
||||
}, [avatarX, step]);
|
||||
}, [avatarX, isStepActive, step]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
step.kind !== 'position' ||
|
||||
!isStepActive ||
|
||||
holdStartedAt === null ||
|
||||
holdCompletionRef.current ||
|
||||
nowMs - holdStartedAt < CHILD_MOTION_HOLD_DURATION_MS
|
||||
@@ -589,10 +846,13 @@ export function ChildMotionWarmupDemo() {
|
||||
|
||||
holdCompletionRef.current = true;
|
||||
completeStep({ type: 'position', avatarX });
|
||||
}, [avatarX, completeStep, holdStartedAt, nowMs, step.kind]);
|
||||
}, [avatarX, completeStep, holdStartedAt, isStepActive, nowMs, step.kind]);
|
||||
|
||||
useEffect(() => {
|
||||
if (step.kind !== 'narration' && step.kind !== 'finish') {
|
||||
if (
|
||||
!isStepActive ||
|
||||
(step.kind !== 'narration' && step.kind !== 'finish')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -603,10 +863,10 @@ export function ChildMotionWarmupDemo() {
|
||||
: CHILD_MOTION_NARRATION_DURATION_MS,
|
||||
);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [completeStep, step.kind]);
|
||||
}, [completeStep, isStepActive, step.kind]);
|
||||
|
||||
useEffect(() => {
|
||||
if (step.kind !== 'gesture' || !mocapInput.latestCommand) {
|
||||
if (step.kind !== 'gesture' || !isStepActive || !mocapInput.latestCommand) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -619,25 +879,32 @@ export function ChildMotionWarmupDemo() {
|
||||
return;
|
||||
}
|
||||
|
||||
const primaryPoint = mocapHandToChildMotionPoint(command.primaryHand);
|
||||
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) &&
|
||||
!command.leftHand &&
|
||||
(primaryHandSide === 'left' ||
|
||||
primaryHandSide === 'unknown' ||
|
||||
stepId === 'wave_left_hand' ||
|
||||
stepId === 'wave_greeting');
|
||||
!leftBodyHand &&
|
||||
(primaryBodySide === 'left' ||
|
||||
(primaryHandSide === 'unknown' && stepId === 'wave_greeting'));
|
||||
const fallbackPrimaryToRight =
|
||||
Boolean(primaryPoint) &&
|
||||
!command.rightHand &&
|
||||
(primaryHandSide === 'right' ||
|
||||
stepId === 'wave_right_hand');
|
||||
Boolean(primaryPoint) && !rightBodyHand && primaryBodySide === 'right';
|
||||
const leftPoint =
|
||||
mocapHandToChildMotionPoint(command.leftHand) ??
|
||||
mocapHandToChildMotionPoint(leftBodyHand, command, 'left') ??
|
||||
(fallbackPrimaryToLeft ? primaryPoint : null);
|
||||
const rightPoint =
|
||||
mocapHandToChildMotionPoint(command.rightHand) ??
|
||||
mocapHandToChildMotionPoint(rightBodyHand, command, 'right') ??
|
||||
(fallbackPrimaryToRight ? primaryPoint : null);
|
||||
const nextLeftHandPath = leftPoint
|
||||
? appendWarmupMocapPoint(leftHandPath, leftPoint)
|
||||
@@ -646,7 +913,7 @@ export function ChildMotionWarmupDemo() {
|
||||
? appendWarmupMocapPoint(rightHandPath, rightPoint)
|
||||
: rightHandPath;
|
||||
const nextPrimaryHandPath = primaryPoint
|
||||
? command.primaryHand?.side === 'right'
|
||||
? primaryBodySide === 'right'
|
||||
? nextRightHandPath
|
||||
: nextLeftHandPath
|
||||
: [];
|
||||
@@ -675,14 +942,14 @@ export function ChildMotionWarmupDemo() {
|
||||
}
|
||||
|
||||
if (intent === 'right-hand') {
|
||||
const path = [...nextRightHandPath, rightPoint ?? primaryPoint].filter(
|
||||
const path = [...nextRightHandPath, rightPoint].filter(
|
||||
(point): point is ChildMotionPoint => Boolean(point),
|
||||
);
|
||||
completeStep({ type: 'right-hand', path: path.slice(-16) });
|
||||
return;
|
||||
}
|
||||
|
||||
const path = [...nextLeftHandPath, leftPoint ?? primaryPoint].filter(
|
||||
const path = [...nextLeftHandPath, leftPoint].filter(
|
||||
(point): point is ChildMotionPoint => Boolean(point),
|
||||
);
|
||||
completeStep({ type: 'left-hand', path: path.slice(-16) });
|
||||
@@ -693,12 +960,13 @@ export function ChildMotionWarmupDemo() {
|
||||
mocapInput.rawPacketPreview?.receivedAtMs,
|
||||
mocapInput.rawPacketPreview?.text,
|
||||
rightHandPath,
|
||||
isStepActive,
|
||||
step.kind,
|
||||
stepId,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mocapInput.latestCommand) {
|
||||
if (stepPhase === 'complete' || !mocapInput.latestCommand) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -707,11 +975,12 @@ export function ChildMotionWarmupDemo() {
|
||||
return;
|
||||
}
|
||||
|
||||
setAvatarX(nextAvatarX);
|
||||
setAvatarX((current) => resolveDampedAvatarX(current, nextAvatarX));
|
||||
}, [
|
||||
mocapInput.latestCommand,
|
||||
mocapInput.rawPacketPreview?.receivedAtMs,
|
||||
mocapInput.rawPacketPreview?.text,
|
||||
stepPhase,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -720,6 +989,10 @@ export function ChildMotionWarmupDemo() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (stepPhase === 'complete') {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = event.key.toLowerCase();
|
||||
if (key === 'a') {
|
||||
setAvatarX(0.34);
|
||||
@@ -735,7 +1008,7 @@ export function ChildMotionWarmupDemo() {
|
||||
event.preventDefault();
|
||||
setIsJumping(true);
|
||||
window.setTimeout(() => setIsJumping(false), 360);
|
||||
if (stepId === 'jump_once') {
|
||||
if (stepId === 'jump_once' && isStepActive) {
|
||||
completeStep({ type: 'jump', jumpSpace: 0.14 });
|
||||
}
|
||||
}
|
||||
@@ -743,12 +1016,17 @@ export function ChildMotionWarmupDemo() {
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [completeStep, stepId]);
|
||||
}, [completeStep, isStepActive, stepId, stepPhase]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyUp = (event: KeyboardEvent) => {
|
||||
const key = event.key.toLowerCase();
|
||||
if (key === 'a' || key === 'd' || event.code === 'KeyA' || event.code === 'KeyD') {
|
||||
if (
|
||||
key === 'a' ||
|
||||
key === 'd' ||
|
||||
event.code === 'KeyA' ||
|
||||
event.code === 'KeyD'
|
||||
) {
|
||||
setAvatarX(CHILD_MOTION_CENTER_X);
|
||||
}
|
||||
};
|
||||
@@ -758,6 +1036,10 @@ export function ChildMotionWarmupDemo() {
|
||||
}, []);
|
||||
|
||||
const handleStagePointerDown = (event: ReactPointerEvent<HTMLElement>) => {
|
||||
if (!isStepActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.button !== 0 && event.button !== 2) {
|
||||
return;
|
||||
}
|
||||
@@ -805,6 +1087,10 @@ export function ChildMotionWarmupDemo() {
|
||||
: [...rightHandPath, point].slice(-16);
|
||||
setActiveHand(null);
|
||||
|
||||
if (!isStepActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (stepId === 'wave_greeting') {
|
||||
completeStep({ type: 'left-hand', path: completedPath });
|
||||
return;
|
||||
@@ -824,7 +1110,10 @@ export function ChildMotionWarmupDemo() {
|
||||
setIsBabyObjectRuntimeOpen(true);
|
||||
};
|
||||
|
||||
const lineText = useMemo(() => step.spokenLines.join(','), [step.spokenLines]);
|
||||
const lineText = useMemo(
|
||||
() => step.spokenLines.join(','),
|
||||
[step.spokenLines],
|
||||
);
|
||||
|
||||
if (isBabyObjectRuntimeOpen) {
|
||||
return (
|
||||
@@ -845,8 +1134,9 @@ export function ChildMotionWarmupDemo() {
|
||||
</div>
|
||||
|
||||
<section
|
||||
className="child-motion-stage"
|
||||
className={`child-motion-stage child-motion-stage--${stepPhase}`}
|
||||
data-testid="child-motion-stage"
|
||||
data-step-phase={stepPhase}
|
||||
onPointerDown={handleStagePointerDown}
|
||||
onPointerMove={handleStagePointerMove}
|
||||
onPointerUp={handleStagePointerUp}
|
||||
@@ -870,10 +1160,10 @@ export function ChildMotionWarmupDemo() {
|
||||
</div>
|
||||
) : null}
|
||||
<div className="child-motion-floor" aria-hidden="true" />
|
||||
{targetX !== null && step.kind === 'position' ? (
|
||||
<ChildMotionRing targetX={targetX} progress={holdProgress} />
|
||||
{shouldShowStepCues && targetX !== null && step.kind === 'position' ? (
|
||||
<ChildMotionRing targetX={targetX} progress={displayHoldProgress} />
|
||||
) : null}
|
||||
{step.kind === 'gesture' ? (
|
||||
{shouldShowStepCues && step.kind === 'gesture' ? (
|
||||
<ChildMotionGestureGuide
|
||||
stepId={stepId}
|
||||
leftHandPath={leftHandPath}
|
||||
@@ -882,7 +1172,9 @@ export function ChildMotionWarmupDemo() {
|
||||
) : null}
|
||||
<ChildMotionAvatar avatarX={avatarX} isJumping={isJumping} />
|
||||
{justCompletedText ? (
|
||||
<div className="child-motion-floating-reward">{justCompletedText}</div>
|
||||
<div className="child-motion-floating-reward">
|
||||
{justCompletedText}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="child-motion-hud child-motion-hud--top">
|
||||
|
||||
Reference in New Issue
Block a user