feat: add baby object match edutainment flow
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-05-12 16:08:59 +08:00
parent cf074837a4
commit d41f260a2a
58 changed files with 5628 additions and 466 deletions

View File

@@ -0,0 +1,583 @@
import {
ArrowLeft,
Gift,
PartyPopper,
RotateCcw,
SkipForward,
} from 'lucide-react';
import {
type PointerEvent as ReactPointerEvent,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import type {
BabyObjectMatchDraft,
BabyObjectMatchItemAsset,
} from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import type {
MocapHandInput,
MocapInputCommand,
UseMocapInputResult,
} from '../../services/useMocapInput';
import { useMocapInput } from '../../services/useMocapInput';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
const BABY_OBJECT_MATCH_SUCCESS_TARGET = 20;
const BABY_OBJECT_MATCH_FEEDBACK_DURATION_MS = 760;
const BABY_OBJECT_MATCH_MIN_HORIZONTAL_MOVE_DISTANCE = 0.05;
const BABY_OBJECT_MATCH_HAND_PATH_LIMIT = 16;
type BabyObjectMatchRuntimeShellProps = {
draft: BabyObjectMatchDraft;
embedded?: boolean;
enableMocapInput?: boolean;
mocapInput?: UseMocapInputResult | null;
random?: BabyObjectMatchRandom;
onBack?: () => void;
onNextLevel?: () => void;
};
type BasketSide = 'left' | 'right';
type RuntimePhase = 'waiting' | 'active' | 'correct' | 'wrong' | 'complete';
type RuntimeRound = {
item: BabyObjectMatchItemAsset;
baskets: Record<BasketSide, BabyObjectMatchItemAsset>;
};
type DragState = {
side: BasketSide;
startX: number;
lastX: number;
};
type RuntimeHandPoint = {
x: number;
y: number;
};
type RuntimeMocapHandPaths = {
left: RuntimeHandPoint[];
right: RuntimeHandPoint[];
};
type BabyObjectMatchRandom = () => number;
const OPEN_PALM_ACTIONS = [
'open_palm',
'open_palm_up',
'open',
'palm',
'hand_open',
];
const GRAB_ACTIONS = [
'grab',
'grabbing',
'close',
'fist',
'closed_fist',
'closed',
];
function pickRandomIndex(length: number, random: BabyObjectMatchRandom) {
if (length <= 1) {
return 0;
}
return Math.min(length - 1, Math.floor(random() * length));
}
function buildRuntimeRound(
draft: BabyObjectMatchDraft,
random: BabyObjectMatchRandom,
): RuntimeRound {
const items = draft.itemAssets;
const item = items[pickRandomIndex(items.length, random)] ?? items[0]!;
return {
item,
baskets: {
left: items[0]!,
right: items[1]!,
},
};
}
function isHorizontalDrag(dragState: DragState) {
return (
Math.abs(dragState.lastX - dragState.startX) >=
BABY_OBJECT_MATCH_MIN_HORIZONTAL_MOVE_DISTANCE
);
}
function hasMocapAction(command: MocapInputCommand, actions: string[]) {
return command.actions.some((action) => actions.includes(action));
}
function mocapHandToRuntimePoint(
hand: MocapHandInput | null | undefined,
): RuntimeHandPoint | null {
if (!hand) {
return null;
}
return { x: hand.x, y: hand.y };
}
function appendRuntimeHandPoint(
points: RuntimeHandPoint[],
point: RuntimeHandPoint,
) {
return [...points, point].slice(-BABY_OBJECT_MATCH_HAND_PATH_LIMIT);
}
function hasRuntimeHorizontalMovePath(points: RuntimeHandPoint[]) {
if (points.length < 3) {
return false;
}
const xValues = points.map((point) => point.x);
return (
Math.max(...xValues) - Math.min(...xValues) >=
BABY_OBJECT_MATCH_MIN_HORIZONTAL_MOVE_DISTANCE
);
}
function resolveMocapHandPaths(
command: MocapInputCommand,
currentPaths: RuntimeMocapHandPaths,
) {
// 本地 mocap 当前按摄像头视角输出 handedness这里换回用户身体视角再选篮。
const leftPoint = mocapHandToRuntimePoint(command.rightHand);
const rightPoint = mocapHandToRuntimePoint(command.leftHand);
return {
left: leftPoint
? appendRuntimeHandPoint(currentPaths.left, leftPoint)
: currentPaths.left,
right: rightPoint
? appendRuntimeHandPoint(currentPaths.right, rightPoint)
: currentPaths.right,
} satisfies RuntimeMocapHandPaths;
}
function hasOpenPalmMocapHand(command: MocapInputCommand) {
return (
hasMocapAction(command, OPEN_PALM_ACTIONS) ||
Boolean(command.hands?.some((hand) => hand.state === 'open_palm')) ||
command.leftHand?.state === 'open_palm' ||
command.rightHand?.state === 'open_palm' ||
command.primaryHand?.state === 'open_palm'
);
}
function hasGrabMocapHand(command: MocapInputCommand) {
return (
hasMocapAction(command, GRAB_ACTIONS) ||
Boolean(command.hands?.some((hand) => hand.state === 'grab')) ||
command.leftHand?.state === 'grab' ||
command.rightHand?.state === 'grab' ||
command.primaryHand?.state === 'grab'
);
}
function resolveMocapHorizontalMoveSide(
paths: RuntimeMocapHandPaths,
): BasketSide | null {
if (hasRuntimeHorizontalMovePath(paths.left)) {
return 'left';
}
if (hasRuntimeHorizontalMovePath(paths.right)) {
return 'right';
}
return null;
}
function buildMocapPacketKey(
command: MocapInputCommand,
rawPacketPreview: UseMocapInputResult['rawPacketPreview'],
) {
return rawPacketPreview?.receivedAtMs !== undefined
? `${rawPacketPreview.receivedAtMs}:${rawPacketPreview.text}`
: JSON.stringify(command);
}
export function BabyObjectMatchRuntimeShell({
draft,
embedded = false,
enableMocapInput = true,
mocapInput = null,
random,
onBack,
onNextLevel,
}: BabyObjectMatchRuntimeShellProps) {
const randomRef = useRef<BabyObjectMatchRandom>(random ?? (() => Math.random()));
const feedbackTimerRef = useRef<number | null>(null);
const dragStateRef = useRef<DragState | null>(null);
const handledMocapPacketKeyRef = useRef<string | null>(null);
const hasOpenPalmBeforeGrabRef = useRef(false);
const mocapHandPathsRef = useRef<RuntimeMocapHandPaths>({
left: [],
right: [],
});
const [phase, setPhase] = useState<RuntimePhase>('waiting');
const [successCount, setSuccessCount] = useState(0);
const [round, setRound] = useState<RuntimeRound | null>(null);
const [feedbackText, setFeedbackText] = useState<string | null>(null);
const [lastTargetSide, setLastTargetSide] = useState<BasketSide | null>(null);
const liveMocapInput = useMocapInput({
enabled: enableMocapInput && !mocapInput,
});
const resolvedMocapInput = mocapInput ?? liveMocapInput;
const progressText = `${successCount}/${BABY_OBJECT_MATCH_SUCCESS_TARGET}`;
const isComplete = phase === 'complete';
const currentItem = round?.item ?? null;
useEffect(() => {
randomRef.current = random ?? (() => Math.random());
}, [random]);
const clearFeedbackTimer = useCallback(() => {
if (feedbackTimerRef.current !== null) {
window.clearTimeout(feedbackTimerRef.current);
feedbackTimerRef.current = null;
}
}, []);
const openGiftBox = useCallback(() => {
if (phase !== 'waiting') {
return;
}
clearFeedbackTimer();
setFeedbackText(null);
setLastTargetSide(null);
setRound(buildRuntimeRound(draft, randomRef.current));
setPhase('active');
}, [clearFeedbackTimer, draft, phase]);
const resetRuntime = useCallback(() => {
clearFeedbackTimer();
dragStateRef.current = null;
handledMocapPacketKeyRef.current = null;
hasOpenPalmBeforeGrabRef.current = false;
mocapHandPathsRef.current = { left: [], right: [] };
setSuccessCount(0);
setRound(null);
setFeedbackText(null);
setLastTargetSide(null);
setPhase('waiting');
}, [clearFeedbackTimer]);
const finishFeedback = useCallback(
(nextSuccessCount: number, wasCorrect: boolean) => {
clearFeedbackTimer();
feedbackTimerRef.current = window.setTimeout(() => {
feedbackTimerRef.current = null;
if (wasCorrect) {
if (nextSuccessCount >= BABY_OBJECT_MATCH_SUCCESS_TARGET) {
setFeedbackText('恭喜你!小朋友!');
setRound(null);
setPhase('complete');
return;
}
setRound(null);
setFeedbackText(null);
setLastTargetSide(null);
setPhase('waiting');
return;
}
setFeedbackText(null);
setLastTargetSide(null);
mocapHandPathsRef.current = { left: [], right: [] };
setPhase('active');
}, BABY_OBJECT_MATCH_FEEDBACK_DURATION_MS);
},
[clearFeedbackTimer],
);
const sendItemToBasket = useCallback(
(side: BasketSide) => {
if (phase !== 'active' || !round) {
return;
}
const isCorrect = round.baskets[side].itemId === round.item.itemId;
setLastTargetSide(side);
if (isCorrect) {
const nextSuccessCount = successCount + 1;
setSuccessCount(nextSuccessCount);
setFeedbackText('真棒');
setPhase('correct');
finishFeedback(nextSuccessCount, true);
return;
}
setFeedbackText('再想一想吧');
setPhase('wrong');
finishFeedback(successCount, false);
},
[finishFeedback, phase, round, successCount],
);
useEffect(() => clearFeedbackTimer, [clearFeedbackTimer]);
useEffect(() => {
if (phase === 'waiting') {
mocapHandPathsRef.current = { left: [], right: [] };
return;
}
hasOpenPalmBeforeGrabRef.current = false;
}, [phase]);
useEffect(() => {
const command = resolvedMocapInput.latestCommand;
if (!command || isComplete) {
return;
}
const packetKey = buildMocapPacketKey(
command,
resolvedMocapInput.rawPacketPreview,
);
if (handledMocapPacketKeyRef.current === packetKey) {
return;
}
handledMocapPacketKeyRef.current = packetKey;
if (phase === 'waiting') {
if (hasGrabMocapHand(command) && hasOpenPalmBeforeGrabRef.current) {
hasOpenPalmBeforeGrabRef.current = false;
mocapHandPathsRef.current = { left: [], right: [] };
openGiftBox();
return;
}
if (hasOpenPalmMocapHand(command)) {
hasOpenPalmBeforeGrabRef.current = true;
}
return;
}
if (phase !== 'active') {
mocapHandPathsRef.current = { left: [], right: [] };
return;
}
const nextPaths = resolveMocapHandPaths(
command,
mocapHandPathsRef.current,
);
mocapHandPathsRef.current = nextPaths;
const targetSide = resolveMocapHorizontalMoveSide(nextPaths);
if (targetSide) {
sendItemToBasket(targetSide);
mocapHandPathsRef.current = { left: [], right: [] };
}
}, [
isComplete,
openGiftBox,
phase,
resolvedMocapInput.latestCommand,
resolvedMocapInput.rawPacketPreview,
sendItemToBasket,
]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key.toLowerCase() !== 'f') {
return;
}
event.preventDefault();
openGiftBox();
};
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [openGiftBox]);
const getPointerUnitX = (
event: ReactPointerEvent<HTMLElement>,
element: HTMLElement,
) => {
const rect = element.getBoundingClientRect();
const width = rect.width || 1;
return Math.max(0, Math.min(1, (event.clientX - rect.left) / width));
};
const handlePointerDown = (event: ReactPointerEvent<HTMLElement>) => {
if (event.button !== 0 && event.button !== 2) {
return;
}
const side: BasketSide = event.button === 2 ? 'right' : 'left';
const pointerX = getPointerUnitX(event, event.currentTarget);
dragStateRef.current = {
side,
startX: pointerX,
lastX: pointerX,
};
event.preventDefault();
if (typeof event.currentTarget.setPointerCapture === 'function') {
event.currentTarget.setPointerCapture(event.pointerId);
}
};
const handlePointerMove = (event: ReactPointerEvent<HTMLElement>) => {
if (!dragStateRef.current) {
return;
}
dragStateRef.current = {
...dragStateRef.current,
lastX: getPointerUnitX(event, event.currentTarget),
};
};
const handlePointerUp = (event: ReactPointerEvent<HTMLElement>) => {
const dragState = dragStateRef.current;
dragStateRef.current = null;
if (
typeof event.currentTarget.hasPointerCapture === 'function' &&
event.currentTarget.hasPointerCapture(event.pointerId)
) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
if (!dragState || !isHorizontalDrag(dragState)) {
return;
}
sendItemToBasket(dragState.side);
};
return (
<main
className={`baby-object-runtime${embedded ? ' baby-object-runtime--embedded' : ''}`}
data-testid="baby-object-match-runtime"
>
<section
className="baby-object-runtime__stage"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
onContextMenu={(event) => event.preventDefault()}
>
{onBack ? (
<button
type="button"
className="baby-object-runtime__back"
onClick={onBack}
aria-label="返回"
title="返回"
>
<ArrowLeft className="h-5 w-5" />
</button>
) : null}
<div className="baby-object-runtime__subtitle" role="status">
</div>
<div className="baby-object-runtime__counter" aria-label="成功次数">
{progressText}
</div>
<div
className={`baby-object-runtime__gift${phase === 'active' || phase === 'correct' || phase === 'wrong' ? ' baby-object-runtime__gift--open' : ''}`}
aria-label="礼物盒"
>
<Gift className="baby-object-runtime__gift-icon" />
</div>
<div
className={`baby-object-runtime__item${
phase === 'correct'
? ` baby-object-runtime__item--to-${lastTargetSide ?? 'left'}`
: phase === 'wrong'
? ` baby-object-runtime__item--wrong-${lastTargetSide ?? 'left'}`
: ''
}`}
data-testid="baby-object-current-item"
aria-live="polite"
>
{currentItem ? (
<>
<ResolvedAssetImage
src={currentItem.imageSrc}
alt={currentItem.itemName}
className="baby-object-runtime__item-image"
/>
<span className="baby-object-runtime__item-name">
{currentItem.itemName}
</span>
</>
) : null}
</div>
{feedbackText ? (
<div
className={`baby-object-runtime__feedback baby-object-runtime__feedback--${phase}`}
>
{feedbackText}
</div>
) : null}
{isComplete ? (
<div className="baby-object-runtime__complete" role="dialog">
<PartyPopper className="h-8 w-8" />
<div></div>
<div className="baby-object-runtime__complete-actions">
<button type="button" onClick={resetRuntime}>
<RotateCcw className="h-4 w-4" />
</button>
<button type="button" onClick={onNextLevel}>
<SkipForward className="h-4 w-4" />
</button>
</div>
</div>
) : null}
<div className="baby-object-runtime__baskets">
{(['left', 'right'] as const).map((side) => {
const basketItem = round?.baskets[side] ?? draft.itemAssets[side === 'left' ? 0 : 1];
return (
<div
key={side}
className={`baby-object-runtime__basket baby-object-runtime__basket--${side}`}
aria-label={`${side === 'left' ? '左侧' : '右侧'}篮子 ${basketItem.itemName}`}
>
<div className="baby-object-runtime__basket-icon">
<ResolvedAssetImage
src={basketItem.imageSrc}
alt={basketItem.itemName}
className="baby-object-runtime__basket-image"
/>
</div>
<div className="baby-object-runtime__basket-body" />
</div>
);
})}
</div>
</section>
</main>
);
}
export default BabyObjectMatchRuntimeShell;