719 lines
20 KiB
TypeScript
719 lines
20 KiB
TypeScript
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<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;
|
||
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<BabyObjectMatchRandom>(
|
||
random ?? (() => Math.random()),
|
||
);
|
||
const introTimerRef = useRef<number | null>(null);
|
||
const feedbackTimerRef = useRef<number | null>(null);
|
||
const dragStateRef = useRef<DragState | null>(null);
|
||
const handledMocapPacketKeyRef = useRef<string | null>(null);
|
||
const latestMocapPacketKeyRef = useRef<string | null>(null);
|
||
const mocapHandPathsRef = useRef<RuntimeMocapHandPaths>({
|
||
left: [],
|
||
right: [],
|
||
});
|
||
const [phase, setPhase] = useState<RuntimePhase>('gift-entering');
|
||
const [successCount, setSuccessCount] = useState(0);
|
||
const [round, setRound] = useState<RuntimeRound | null>(() =>
|
||
buildRuntimeRound(draft, randomRef.current),
|
||
);
|
||
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 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<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 (!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<HTMLElement>) => {
|
||
if (!isJudgementOpen) {
|
||
dragStateRef.current = null;
|
||
return;
|
||
}
|
||
|
||
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${
|
||
backgroundAsset ? ' baby-object-runtime__stage--skinned' : ''
|
||
}`}
|
||
style={stageStyle}
|
||
onPointerDown={handlePointerDown}
|
||
onPointerMove={handlePointerMove}
|
||
onPointerUp={handlePointerUp}
|
||
onPointerCancel={handlePointerUp}
|
||
onContextMenu={(event) => event.preventDefault()}
|
||
>
|
||
{backgroundAsset ? (
|
||
<ResolvedAssetImage
|
||
src={backgroundAsset.imageSrc}
|
||
alt=""
|
||
className="baby-object-runtime__background-image"
|
||
data-testid="baby-object-background-image"
|
||
aria-hidden="true"
|
||
/>
|
||
) : null}
|
||
|
||
{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>
|
||
|
||
{shouldShowGift ? (
|
||
<div
|
||
className={`baby-object-runtime__gift${
|
||
giftBoxAsset ? ' baby-object-runtime__gift--skinned' : ''
|
||
}${
|
||
phase === 'gift-entering'
|
||
? ' baby-object-runtime__gift--entering'
|
||
: ''
|
||
}${
|
||
phase === 'gift-opening'
|
||
? ' baby-object-runtime__gift--opening baby-object-runtime__gift--open'
|
||
: ''
|
||
}`}
|
||
aria-label="礼物盒"
|
||
>
|
||
{giftBoxAsset ? (
|
||
<ResolvedAssetImage
|
||
src={giftBoxAsset.imageSrc}
|
||
alt="礼物盒"
|
||
className="baby-object-runtime__gift-image"
|
||
/>
|
||
) : (
|
||
<Gift className="baby-object-runtime__gift-icon" />
|
||
)}
|
||
</div>
|
||
) : null}
|
||
|
||
{shouldShowSmoke ? (
|
||
<div
|
||
className={`baby-object-runtime__smoke${
|
||
smokeAsset ? ' baby-object-runtime__smoke--skinned' : ''
|
||
}${
|
||
phase === 'item-appearing'
|
||
? ' baby-object-runtime__smoke--releasing'
|
||
: ''
|
||
}`}
|
||
data-testid="baby-object-smoke-effect"
|
||
aria-hidden="true"
|
||
/>
|
||
) : null}
|
||
|
||
<div
|
||
className={`baby-object-runtime__item${
|
||
shouldShowCurrentItem ? ' baby-object-runtime__item--visible' : ''
|
||
}${
|
||
phase === 'item-appearing'
|
||
? ' baby-object-runtime__item--appearing'
|
||
: ''
|
||
}${
|
||
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"
|
||
>
|
||
{shouldShowCurrentItem ? (
|
||
<>
|
||
<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}${
|
||
phase === 'correct' && lastTargetSide === side
|
||
? ' baby-object-runtime__basket--correct'
|
||
: ''
|
||
}`}
|
||
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${
|
||
basketAsset
|
||
? ' baby-object-runtime__basket-body--skinned'
|
||
: ''
|
||
}`}
|
||
>
|
||
{basketAsset ? (
|
||
<ResolvedAssetImage
|
||
src={basketAsset.imageSrc}
|
||
alt=""
|
||
className="baby-object-runtime__basket-shell-image"
|
||
/>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</section>
|
||
</main>
|
||
);
|
||
}
|
||
|
||
export default BabyObjectMatchRuntimeShell;
|