import { ArrowLeft, Gift, PartyPopper, RotateCcw, SkipForward, } from 'lucide-react'; import { type CSSProperties, type PointerEvent as ReactPointerEvent, useCallback, useEffect, useRef, useState, } from 'react'; import type { BabyObjectMatchDraft, BabyObjectMatchItemAsset, BabyObjectMatchVisualAsset, BabyObjectMatchVisualAssetKind, } 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_GIFT_APPEAR_DURATION_MS = 620; const BABY_OBJECT_MATCH_GIFT_OPEN_DURATION_MS = 640; const BABY_OBJECT_MATCH_ITEM_APPEAR_DURATION_MS = 620; const BABY_OBJECT_MATCH_FEEDBACK_DURATION_MS = 1180; 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 = | 'gift-entering' | 'gift-opening' | 'item-appearing' | 'active' | 'correct' | 'wrong' | 'complete'; type RuntimeRound = { item: BabyObjectMatchItemAsset; baskets: Record; }; type DragState = { side: BasketSide; startX: number; lastX: number; }; type RuntimeHandPoint = { x: number; y: number; }; type RuntimeMocapHandPaths = { left: RuntimeHandPoint[]; right: RuntimeHandPoint[]; }; type BabyObjectMatchRandom = () => number; type BabyObjectMatchStageStyle = CSSProperties & Partial< Record< | '--baby-object-ui-frame-image' | '--baby-object-gift-box-image' | '--baby-object-basket-image' | '--baby-object-smoke-image', string > >; 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 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 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); } function findVisualAsset( draft: BabyObjectMatchDraft, kind: BabyObjectMatchVisualAssetKind, ): BabyObjectMatchVisualAsset | null { return ( draft.visualPackage?.assets.find((asset) => asset.assetKind === kind) ?? null ); } function buildCssImageValue(src: string) { return `url("${src.replace(/"/gu, '\\"')}")`; } export function BabyObjectMatchRuntimeShell({ draft, embedded = false, enableMocapInput = true, mocapInput = null, random, onBack, onNextLevel, }: BabyObjectMatchRuntimeShellProps) { const randomRef = useRef( random ?? (() => Math.random()), ); const introTimerRef = useRef(null); const feedbackTimerRef = useRef(null); const dragStateRef = useRef(null); const handledMocapPacketKeyRef = useRef(null); const latestMocapPacketKeyRef = useRef(null); const mocapHandPathsRef = useRef({ left: [], right: [], }); const [phase, setPhase] = useState('gift-entering'); const [successCount, setSuccessCount] = useState(0); const [round, setRound] = useState(() => buildRuntimeRound(draft, randomRef.current), ); const [feedbackText, setFeedbackText] = useState(null); const [lastTargetSide, setLastTargetSide] = useState(null); const liveMocapInput = useMocapInput({ enabled: enableMocapInput && !mocapInput, }); const resolvedMocapInput = mocapInput ?? liveMocapInput; const backgroundAsset = findVisualAsset(draft, 'background'); const uiFrameAsset = findVisualAsset(draft, 'ui-frame'); const giftBoxAsset = findVisualAsset(draft, 'gift-box'); const basketAsset = findVisualAsset(draft, 'basket'); const smokeAsset = findVisualAsset(draft, 'smoke-puff'); const stageStyle: BabyObjectMatchStageStyle = { ...(uiFrameAsset ? { '--baby-object-ui-frame-image': buildCssImageValue( uiFrameAsset.imageSrc, ), } : {}), ...(giftBoxAsset ? { '--baby-object-gift-box-image': buildCssImageValue( giftBoxAsset.imageSrc, ), } : {}), ...(basketAsset ? { '--baby-object-basket-image': buildCssImageValue( basketAsset.imageSrc, ), } : {}), ...(smokeAsset ? { '--baby-object-smoke-image': buildCssImageValue( smokeAsset.imageSrc, ), } : {}), }; const progressText = `${successCount}/${BABY_OBJECT_MATCH_SUCCESS_TARGET}`; const isComplete = phase === 'complete'; const currentItem = round?.item ?? null; const isJudgementOpen = phase === 'active'; const shouldShowCurrentItem = currentItem && (phase === 'item-appearing' || phase === 'active' || phase === 'correct' || phase === 'wrong'); const shouldShowGift = phase === 'gift-entering' || phase === 'gift-opening'; const shouldShowSmoke = phase === 'gift-opening' || phase === 'item-appearing'; useEffect(() => { randomRef.current = random ?? (() => Math.random()); }, [random]); useEffect(() => { latestMocapPacketKeyRef.current = resolvedMocapInput.latestCommand ? buildMocapPacketKey( resolvedMocapInput.latestCommand, resolvedMocapInput.rawPacketPreview, ) : null; }, [resolvedMocapInput.latestCommand, resolvedMocapInput.rawPacketPreview]); const clearFeedbackTimer = useCallback(() => { if (feedbackTimerRef.current !== null) { window.clearTimeout(feedbackTimerRef.current); feedbackTimerRef.current = null; } }, []); const clearIntroTimer = useCallback(() => { if (introTimerRef.current !== null) { window.clearTimeout(introTimerRef.current); introTimerRef.current = null; } }, []); const resetInputPaths = useCallback(() => { dragStateRef.current = null; handledMocapPacketKeyRef.current = null; mocapHandPathsRef.current = { left: [], right: [] }; }, []); useEffect(() => { clearIntroTimer(); if (phase === 'gift-entering') { introTimerRef.current = window.setTimeout(() => { introTimerRef.current = null; setPhase('gift-opening'); }, BABY_OBJECT_MATCH_GIFT_APPEAR_DURATION_MS); return clearIntroTimer; } if (phase === 'gift-opening') { introTimerRef.current = window.setTimeout(() => { introTimerRef.current = null; setPhase('item-appearing'); }, BABY_OBJECT_MATCH_GIFT_OPEN_DURATION_MS); return clearIntroTimer; } if (phase === 'item-appearing') { introTimerRef.current = window.setTimeout(() => { introTimerRef.current = null; resetInputPaths(); handledMocapPacketKeyRef.current = latestMocapPacketKeyRef.current; setPhase('active'); }, BABY_OBJECT_MATCH_ITEM_APPEAR_DURATION_MS); return clearIntroTimer; } return clearIntroTimer; }, [clearIntroTimer, phase, resetInputPaths]); const resetRuntime = useCallback(() => { clearIntroTimer(); clearFeedbackTimer(); resetInputPaths(); setSuccessCount(0); setRound(buildRuntimeRound(draft, randomRef.current)); setFeedbackText(null); setLastTargetSide(null); setPhase('gift-entering'); }, [clearFeedbackTimer, clearIntroTimer, draft, resetInputPaths]); const finishFeedback = useCallback( (nextSuccessCount: number, wasCorrect: boolean) => { clearIntroTimer(); clearFeedbackTimer(); feedbackTimerRef.current = window.setTimeout(() => { feedbackTimerRef.current = null; if (wasCorrect) { if (nextSuccessCount >= BABY_OBJECT_MATCH_SUCCESS_TARGET) { setFeedbackText('恭喜你!小朋友!'); setRound(null); setPhase('complete'); return; } setRound(buildRuntimeRound(draft, randomRef.current)); setFeedbackText(null); setLastTargetSide(null); resetInputPaths(); setPhase('gift-entering'); return; } setFeedbackText(null); setLastTargetSide(null); resetInputPaths(); setPhase('active'); }, BABY_OBJECT_MATCH_FEEDBACK_DURATION_MS); }, [clearFeedbackTimer, clearIntroTimer, draft, resetInputPaths], ); const sendItemToBasket = useCallback( (side: BasketSide) => { if (!isJudgementOpen || !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, isJudgementOpen, round, successCount], ); useEffect( () => () => { clearIntroTimer(); clearFeedbackTimer(); }, [clearFeedbackTimer, clearIntroTimer], ); useEffect(() => { const command = resolvedMocapInput.latestCommand; if (!command || isComplete) { return; } const packetKey = buildMocapPacketKey( command, resolvedMocapInput.rawPacketPreview, ); if (handledMocapPacketKeyRef.current === packetKey) { return; } handledMocapPacketKeyRef.current = packetKey; if (!isJudgementOpen) { resetInputPaths(); return; } const nextPaths = resolveMocapHandPaths(command, mocapHandPathsRef.current); mocapHandPathsRef.current = nextPaths; const targetSide = resolveMocapHorizontalMoveSide(nextPaths); if (targetSide) { sendItemToBasket(targetSide); resetInputPaths(); } }, [ isComplete, isJudgementOpen, resetInputPaths, resolvedMocapInput.latestCommand, resolvedMocapInput.rawPacketPreview, sendItemToBasket, ]); const getPointerUnitX = ( event: ReactPointerEvent, 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) => { if (!isJudgementOpen) { return; } 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) => { if (!isJudgementOpen) { dragStateRef.current = null; return; } if (!dragStateRef.current) { return; } dragStateRef.current = { ...dragStateRef.current, lastX: getPointerUnitX(event, event.currentTarget), }; }; const handlePointerUp = (event: ReactPointerEvent) => { 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 (
event.preventDefault()} > {backgroundAsset ? (
); } export default BabyObjectMatchRuntimeShell;