feat: add baby object match edutainment flow
Some checks failed
CI / verify (push) Has been cancelled
Some checks failed
CI / verify (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,692 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { act, fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
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 { UseMocapInputResult } from '../../services/useMocapInput';
|
||||
import { BabyObjectMatchRuntimeShell } from './BabyObjectMatchRuntimeShell';
|
||||
|
||||
vi.mock('../ResolvedAssetImage', () => ({
|
||||
ResolvedAssetImage: ({
|
||||
src,
|
||||
alt,
|
||||
className,
|
||||
}: {
|
||||
src?: string | null;
|
||||
alt?: string;
|
||||
className?: string;
|
||||
}) => (src ? <img src={src} alt={alt} className={className} /> : null),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/useMocapInput', () => ({
|
||||
useMocapInput: () => ({
|
||||
status: 'idle',
|
||||
latestCommand: null,
|
||||
rawPacketPreview: null,
|
||||
error: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
function createDraft(): BabyObjectMatchDraft {
|
||||
return {
|
||||
draftId: 'baby-object-draft-1',
|
||||
profileId: 'baby-object-profile-1',
|
||||
templateId: BABY_OBJECT_MATCH_TEMPLATE_ID,
|
||||
templateName: BABY_OBJECT_MATCH_TEMPLATE_NAME,
|
||||
workTitle: '宝贝识物',
|
||||
workDescription: '苹果和香蕉识物分类',
|
||||
itemNames: ['苹果', '香蕉'],
|
||||
itemAssets: [
|
||||
{
|
||||
itemId: 'baby-object-item-1',
|
||||
itemName: '苹果',
|
||||
imageSrc: 'data:image/svg+xml;utf8,apple',
|
||||
assetObjectId: null,
|
||||
generationProvider: 'placeholder',
|
||||
prompt: '苹果',
|
||||
},
|
||||
{
|
||||
itemId: 'baby-object-item-2',
|
||||
itemName: '香蕉',
|
||||
imageSrc: 'data:image/svg+xml;utf8,banana',
|
||||
assetObjectId: null,
|
||||
generationProvider: 'placeholder',
|
||||
prompt: '香蕉',
|
||||
},
|
||||
],
|
||||
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',
|
||||
};
|
||||
}
|
||||
|
||||
function createMocapInput(
|
||||
overrides: Partial<UseMocapInputResult> = {},
|
||||
): UseMocapInputResult {
|
||||
return {
|
||||
status: 'connected',
|
||||
latestCommand: null,
|
||||
rawPacketPreview: null,
|
||||
error: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createRandomSequence(values: number[]) {
|
||||
let index = 0;
|
||||
return () => {
|
||||
const value = values[index] ?? values[values.length - 1] ?? 0;
|
||||
index += 1;
|
||||
return value;
|
||||
};
|
||||
}
|
||||
|
||||
function dispatchPointerEvent(
|
||||
target: HTMLElement,
|
||||
type: string,
|
||||
options: {
|
||||
pointerId: number;
|
||||
button?: number;
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
},
|
||||
) {
|
||||
const event = new Event(type, { bubbles: true, cancelable: true });
|
||||
Object.assign(event, options);
|
||||
target.dispatchEvent(event);
|
||||
}
|
||||
|
||||
function dragHand(stage: HTMLElement, button: 0 | 2) {
|
||||
Object.defineProperty(stage, 'getBoundingClientRect', {
|
||||
configurable: true,
|
||||
value: () => ({
|
||||
x: 0,
|
||||
y: 0,
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 320,
|
||||
bottom: 240,
|
||||
width: 320,
|
||||
height: 240,
|
||||
toJSON: () => ({}),
|
||||
}),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
dispatchPointerEvent(stage, 'pointerdown', {
|
||||
pointerId: button + 1,
|
||||
button,
|
||||
clientX: 20,
|
||||
clientY: 140,
|
||||
});
|
||||
});
|
||||
act(() => {
|
||||
dispatchPointerEvent(stage, 'pointermove', {
|
||||
pointerId: button + 1,
|
||||
button,
|
||||
clientX: 120,
|
||||
clientY: 140,
|
||||
});
|
||||
});
|
||||
act(() => {
|
||||
dispatchPointerEvent(stage, 'pointerup', {
|
||||
pointerId: button + 1,
|
||||
button,
|
||||
clientX: 120,
|
||||
clientY: 140,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test('opens the gift box with F and shows the next item', () => {
|
||||
render(
|
||||
<BabyObjectMatchRuntimeShell
|
||||
draft={createDraft()}
|
||||
random={createRandomSequence([0, 0])}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
within(screen.getByTestId('baby-object-current-item')).queryByAltText(
|
||||
'苹果',
|
||||
),
|
||||
).toBeNull();
|
||||
|
||||
fireEvent.keyDown(window, { key: 'f', code: 'KeyF' });
|
||||
|
||||
expect(screen.getByText('将物品放入对应的篮子里')).toBeTruthy();
|
||||
expect(
|
||||
within(screen.getByTestId('baby-object-current-item')).getByAltText(
|
||||
'苹果',
|
||||
),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('keeps left and right baskets fixed while only the gift item is random', () => {
|
||||
render(
|
||||
<BabyObjectMatchRuntimeShell
|
||||
draft={createDraft()}
|
||||
random={createRandomSequence([0.99])}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.keyDown(window, { key: 'f', code: 'KeyF' });
|
||||
|
||||
expect(
|
||||
within(screen.getByTestId('baby-object-current-item')).getByAltText(
|
||||
'香蕉',
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByLabelText('左侧篮子 苹果')).toBeTruthy();
|
||||
expect(screen.getByLabelText('右侧篮子 香蕉')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('mocap open palm followed by grab opens the gift box', () => {
|
||||
const { rerender } = render(
|
||||
<BabyObjectMatchRuntimeShell
|
||||
draft={createDraft()}
|
||||
random={createRandomSequence([0, 0])}
|
||||
mocapInput={createMocapInput()}
|
||||
/>,
|
||||
);
|
||||
|
||||
rerender(
|
||||
<BabyObjectMatchRuntimeShell
|
||||
draft={createDraft()}
|
||||
random={createRandomSequence([0, 0])}
|
||||
mocapInput={createMocapInput({
|
||||
latestCommand: {
|
||||
actions: [],
|
||||
hands: [{ x: 0.5, y: 0.5, state: 'open_palm', side: 'left' }],
|
||||
primaryHand: { x: 0.5, y: 0.5, state: 'open_palm', side: 'left' },
|
||||
leftHand: { x: 0.5, y: 0.5, state: 'open_palm', side: 'left' },
|
||||
rightHand: null,
|
||||
},
|
||||
rawPacketPreview: { text: 'open-left', receivedAtMs: 1 },
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
within(screen.getByTestId('baby-object-current-item')).queryByAltText(
|
||||
'苹果',
|
||||
),
|
||||
).toBeNull();
|
||||
|
||||
rerender(
|
||||
<BabyObjectMatchRuntimeShell
|
||||
draft={createDraft()}
|
||||
random={createRandomSequence([0, 0])}
|
||||
mocapInput={createMocapInput({
|
||||
latestCommand: {
|
||||
actions: [],
|
||||
hands: [{ x: 0.5, y: 0.5, state: 'grab', side: 'left' }],
|
||||
primaryHand: { x: 0.5, y: 0.5, state: 'grab', side: 'left' },
|
||||
leftHand: { x: 0.5, y: 0.5, state: 'grab', side: 'left' },
|
||||
rightHand: null,
|
||||
},
|
||||
rawPacketPreview: { text: 'grab-left', receivedAtMs: 2 },
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
within(screen.getByTestId('baby-object-current-item')).getByAltText(
|
||||
'苹果',
|
||||
),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('mocap camera-right hand movement sends the player left hand item into the left basket', () => {
|
||||
vi.useFakeTimers();
|
||||
const random = createRandomSequence([0, 0]);
|
||||
const { rerender } = render(
|
||||
<BabyObjectMatchRuntimeShell
|
||||
draft={createDraft()}
|
||||
random={random}
|
||||
mocapInput={createMocapInput({
|
||||
latestCommand: {
|
||||
actions: [],
|
||||
hands: [{ x: 0.5, y: 0.5, state: 'open_palm', side: 'right' }],
|
||||
primaryHand: { x: 0.5, y: 0.5, state: 'open_palm', side: 'right' },
|
||||
leftHand: null,
|
||||
rightHand: { x: 0.5, y: 0.5, state: 'open_palm', side: 'right' },
|
||||
},
|
||||
rawPacketPreview: { text: 'open-camera-right', receivedAtMs: 1 },
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
rerender(
|
||||
<BabyObjectMatchRuntimeShell
|
||||
draft={createDraft()}
|
||||
random={random}
|
||||
mocapInput={createMocapInput({
|
||||
latestCommand: {
|
||||
actions: [],
|
||||
hands: [{ x: 0.5, y: 0.5, state: 'grab', side: 'right' }],
|
||||
primaryHand: { x: 0.5, y: 0.5, state: 'grab', side: 'right' },
|
||||
leftHand: null,
|
||||
rightHand: { x: 0.5, y: 0.5, state: 'grab', side: 'right' },
|
||||
},
|
||||
rawPacketPreview: { text: 'grab-camera-right', receivedAtMs: 2 },
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
rerender(
|
||||
<BabyObjectMatchRuntimeShell
|
||||
draft={createDraft()}
|
||||
random={random}
|
||||
mocapInput={createMocapInput({
|
||||
latestCommand: {
|
||||
actions: [],
|
||||
hands: [{ x: 0.22, y: 0.45, state: 'open_palm', side: 'right' }],
|
||||
primaryHand: { x: 0.22, y: 0.45, state: 'open_palm', side: 'right' },
|
||||
leftHand: null,
|
||||
rightHand: { x: 0.22, y: 0.45, state: 'open_palm', side: 'right' },
|
||||
},
|
||||
rawPacketPreview: { text: 'camera-right-horizontal-1', receivedAtMs: 3 },
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
rerender(
|
||||
<BabyObjectMatchRuntimeShell
|
||||
draft={createDraft()}
|
||||
random={random}
|
||||
mocapInput={createMocapInput({
|
||||
latestCommand: {
|
||||
actions: [],
|
||||
hands: [{ x: 0.24, y: 0.45, state: 'open_palm', side: 'right' }],
|
||||
primaryHand: { x: 0.24, y: 0.45, state: 'open_palm', side: 'right' },
|
||||
leftHand: null,
|
||||
rightHand: { x: 0.24, y: 0.45, state: 'open_palm', side: 'right' },
|
||||
},
|
||||
rawPacketPreview: { text: 'camera-right-horizontal-2', receivedAtMs: 4 },
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
rerender(
|
||||
<BabyObjectMatchRuntimeShell
|
||||
draft={createDraft()}
|
||||
random={random}
|
||||
mocapInput={createMocapInput({
|
||||
latestCommand: {
|
||||
actions: [],
|
||||
hands: [{ x: 0.22, y: 0.45, state: 'open_palm', side: 'right' }],
|
||||
primaryHand: { x: 0.22, y: 0.45, state: 'open_palm', side: 'right' },
|
||||
leftHand: null,
|
||||
rightHand: { x: 0.22, y: 0.45, state: 'open_palm', side: 'right' },
|
||||
},
|
||||
rawPacketPreview: { text: 'camera-right-horizontal-3', receivedAtMs: 5 },
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText('真棒')).toBeNull();
|
||||
|
||||
rerender(
|
||||
<BabyObjectMatchRuntimeShell
|
||||
draft={createDraft()}
|
||||
random={random}
|
||||
mocapInput={createMocapInput({
|
||||
latestCommand: {
|
||||
actions: [],
|
||||
hands: [{ x: 0.31, y: 0.45, state: 'open_palm', side: 'right' }],
|
||||
primaryHand: { x: 0.31, y: 0.45, state: 'open_palm', side: 'right' },
|
||||
leftHand: null,
|
||||
rightHand: { x: 0.31, y: 0.45, state: 'open_palm', side: 'right' },
|
||||
},
|
||||
rawPacketPreview: { text: 'camera-right-horizontal-4', receivedAtMs: 6 },
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('真棒')).toBeTruthy();
|
||||
expect(screen.getByLabelText('成功次数').textContent).toBe('1/20');
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('mocap camera-left hand movement sends the player right hand item into the right basket', () => {
|
||||
vi.useFakeTimers();
|
||||
const random = createRandomSequence([0, 0]);
|
||||
const { rerender } = render(
|
||||
<BabyObjectMatchRuntimeShell
|
||||
draft={createDraft()}
|
||||
random={random}
|
||||
mocapInput={createMocapInput({
|
||||
latestCommand: {
|
||||
actions: [],
|
||||
hands: [{ x: 0.5, y: 0.5, state: 'open_palm', side: 'left' }],
|
||||
primaryHand: { x: 0.5, y: 0.5, state: 'open_palm', side: 'left' },
|
||||
leftHand: { x: 0.5, y: 0.5, state: 'open_palm', side: 'left' },
|
||||
rightHand: null,
|
||||
},
|
||||
rawPacketPreview: { text: 'open-camera-left', receivedAtMs: 1 },
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
rerender(
|
||||
<BabyObjectMatchRuntimeShell
|
||||
draft={createDraft()}
|
||||
random={random}
|
||||
mocapInput={createMocapInput({
|
||||
latestCommand: {
|
||||
actions: [],
|
||||
hands: [{ x: 0.5, y: 0.5, state: 'grab', side: 'left' }],
|
||||
primaryHand: { x: 0.5, y: 0.5, state: 'grab', side: 'left' },
|
||||
leftHand: { x: 0.5, y: 0.5, state: 'grab', side: 'left' },
|
||||
rightHand: null,
|
||||
},
|
||||
rawPacketPreview: { text: 'grab-camera-left', receivedAtMs: 2 },
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
rerender(
|
||||
<BabyObjectMatchRuntimeShell
|
||||
draft={createDraft()}
|
||||
random={random}
|
||||
mocapInput={createMocapInput({
|
||||
latestCommand: {
|
||||
actions: [],
|
||||
hands: [{ x: 0.82, y: 0.45, state: 'open_palm', side: 'left' }],
|
||||
primaryHand: { x: 0.82, y: 0.45, state: 'open_palm', side: 'left' },
|
||||
leftHand: { x: 0.82, y: 0.45, state: 'open_palm', side: 'left' },
|
||||
rightHand: null,
|
||||
},
|
||||
rawPacketPreview: { text: 'camera-left-horizontal-1', receivedAtMs: 3 },
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
rerender(
|
||||
<BabyObjectMatchRuntimeShell
|
||||
draft={createDraft()}
|
||||
random={random}
|
||||
mocapInput={createMocapInput({
|
||||
latestCommand: {
|
||||
actions: [],
|
||||
hands: [{ x: 0.8, y: 0.45, state: 'open_palm', side: 'left' }],
|
||||
primaryHand: { x: 0.8, y: 0.45, state: 'open_palm', side: 'left' },
|
||||
leftHand: { x: 0.8, y: 0.45, state: 'open_palm', side: 'left' },
|
||||
rightHand: null,
|
||||
},
|
||||
rawPacketPreview: { text: 'camera-left-horizontal-2', receivedAtMs: 4 },
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
rerender(
|
||||
<BabyObjectMatchRuntimeShell
|
||||
draft={createDraft()}
|
||||
random={random}
|
||||
mocapInput={createMocapInput({
|
||||
latestCommand: {
|
||||
actions: [],
|
||||
hands: [{ x: 0.82, y: 0.45, state: 'open_palm', side: 'left' }],
|
||||
primaryHand: { x: 0.82, y: 0.45, state: 'open_palm', side: 'left' },
|
||||
leftHand: { x: 0.82, y: 0.45, state: 'open_palm', side: 'left' },
|
||||
rightHand: null,
|
||||
},
|
||||
rawPacketPreview: { text: 'camera-left-horizontal-3', receivedAtMs: 5 },
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText('再想一想吧')).toBeNull();
|
||||
|
||||
rerender(
|
||||
<BabyObjectMatchRuntimeShell
|
||||
draft={createDraft()}
|
||||
random={random}
|
||||
mocapInput={createMocapInput({
|
||||
latestCommand: {
|
||||
actions: [],
|
||||
hands: [{ x: 0.73, y: 0.45, state: 'open_palm', side: 'left' }],
|
||||
primaryHand: { x: 0.73, y: 0.45, state: 'open_palm', side: 'left' },
|
||||
leftHand: { x: 0.73, y: 0.45, state: 'open_palm', side: 'left' },
|
||||
rightHand: null,
|
||||
},
|
||||
rawPacketPreview: { text: 'camera-left-horizontal-4', receivedAtMs: 6 },
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('再想一想吧')).toBeTruthy();
|
||||
expect(screen.getByLabelText('成功次数').textContent).toBe('0/20');
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('mocap action names do not select a basket without horizontal hand movement', () => {
|
||||
const random = createRandomSequence([0, 0]);
|
||||
const { rerender } = render(
|
||||
<BabyObjectMatchRuntimeShell
|
||||
draft={createDraft()}
|
||||
random={random}
|
||||
mocapInput={createMocapInput({
|
||||
latestCommand: {
|
||||
actions: [],
|
||||
hands: [{ x: 0.5, y: 0.5, state: 'open_palm', side: 'left' }],
|
||||
primaryHand: { x: 0.5, y: 0.5, state: 'open_palm', side: 'left' },
|
||||
leftHand: { x: 0.5, y: 0.5, state: 'open_palm', side: 'left' },
|
||||
rightHand: null,
|
||||
},
|
||||
rawPacketPreview: { text: 'open-left', receivedAtMs: 1 },
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
rerender(
|
||||
<BabyObjectMatchRuntimeShell
|
||||
draft={createDraft()}
|
||||
random={random}
|
||||
mocapInput={createMocapInput({
|
||||
latestCommand: {
|
||||
actions: [],
|
||||
hands: [{ x: 0.5, y: 0.5, state: 'grab', side: 'left' }],
|
||||
primaryHand: { x: 0.5, y: 0.5, state: 'grab', side: 'left' },
|
||||
leftHand: { x: 0.5, y: 0.5, state: 'grab', side: 'left' },
|
||||
rightHand: null,
|
||||
},
|
||||
rawPacketPreview: { text: 'grab-left', receivedAtMs: 2 },
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
rerender(
|
||||
<BabyObjectMatchRuntimeShell
|
||||
draft={createDraft()}
|
||||
random={random}
|
||||
mocapInput={createMocapInput({
|
||||
latestCommand: {
|
||||
actions: ['wave_left_hand', 'wave_right_hand', 'wave'],
|
||||
hands: [{ x: 0.5, y: 0.5, state: 'open_palm', side: 'left' }],
|
||||
primaryHand: { x: 0.5, y: 0.5, state: 'open_palm', side: 'left' },
|
||||
leftHand: { x: 0.5, y: 0.5, state: 'open_palm', side: 'left' },
|
||||
rightHand: null,
|
||||
},
|
||||
rawPacketPreview: { text: 'action-only-wave', receivedAtMs: 3 },
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText('真棒')).toBeNull();
|
||||
expect(screen.queryByText('再想一想吧')).toBeNull();
|
||||
expect(
|
||||
within(screen.getByTestId('baby-object-current-item')).getByAltText(
|
||||
'苹果',
|
||||
),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('mocap unknown hand horizontal movement does not select a basket', () => {
|
||||
const random = createRandomSequence([0, 0]);
|
||||
const { rerender } = render(
|
||||
<BabyObjectMatchRuntimeShell
|
||||
draft={createDraft()}
|
||||
random={random}
|
||||
mocapInput={createMocapInput({
|
||||
latestCommand: {
|
||||
actions: [],
|
||||
hands: [{ x: 0.5, y: 0.5, state: 'open_palm', side: 'unknown' }],
|
||||
primaryHand: { x: 0.5, y: 0.5, state: 'open_palm', side: 'unknown' },
|
||||
leftHand: null,
|
||||
rightHand: null,
|
||||
},
|
||||
rawPacketPreview: { text: 'open-unknown', receivedAtMs: 1 },
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
rerender(
|
||||
<BabyObjectMatchRuntimeShell
|
||||
draft={createDraft()}
|
||||
random={random}
|
||||
mocapInput={createMocapInput({
|
||||
latestCommand: {
|
||||
actions: [],
|
||||
hands: [{ x: 0.5, y: 0.5, state: 'grab', side: 'unknown' }],
|
||||
primaryHand: { x: 0.5, y: 0.5, state: 'grab', side: 'unknown' },
|
||||
leftHand: null,
|
||||
rightHand: null,
|
||||
},
|
||||
rawPacketPreview: { text: 'grab-unknown', receivedAtMs: 2 },
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
for (let index = 0; index < 4; index += 1) {
|
||||
const x = [0.22, 0.24, 0.22, 0.31][index] ?? 0.22;
|
||||
rerender(
|
||||
<BabyObjectMatchRuntimeShell
|
||||
draft={createDraft()}
|
||||
random={random}
|
||||
mocapInput={createMocapInput({
|
||||
latestCommand: {
|
||||
actions: [],
|
||||
hands: [{ x, y: 0.45, state: 'open_palm', side: 'unknown' }],
|
||||
primaryHand: { x, y: 0.45, state: 'open_palm', side: 'unknown' },
|
||||
leftHand: null,
|
||||
rightHand: null,
|
||||
},
|
||||
rawPacketPreview: {
|
||||
text: `unknown-horizontal-${index + 1}`,
|
||||
receivedAtMs: index + 3,
|
||||
},
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
expect(screen.queryByText('真棒')).toBeNull();
|
||||
expect(screen.queryByText('再想一想吧')).toBeNull();
|
||||
expect(
|
||||
within(screen.getByTestId('baby-object-current-item')).getByAltText(
|
||||
'苹果',
|
||||
),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('left hand horizontal drag sends a correct item into the left basket', () => {
|
||||
vi.useFakeTimers();
|
||||
const { container } = render(
|
||||
<BabyObjectMatchRuntimeShell
|
||||
draft={createDraft()}
|
||||
random={createRandomSequence([0, 0])}
|
||||
/>,
|
||||
);
|
||||
const stage = container.querySelector('.baby-object-runtime__stage');
|
||||
if (!(stage instanceof HTMLElement)) {
|
||||
throw new Error('Missing baby object runtime stage');
|
||||
}
|
||||
|
||||
fireEvent.keyDown(window, { key: 'f', code: 'KeyF' });
|
||||
dragHand(stage, 0);
|
||||
|
||||
expect(screen.getByText('真棒')).toBeTruthy();
|
||||
expect(screen.getByLabelText('成功次数').textContent).toBe('1/20');
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(800);
|
||||
});
|
||||
|
||||
expect(screen.queryByText('真棒')).toBeNull();
|
||||
expect(
|
||||
within(screen.getByTestId('baby-object-current-item')).queryByAltText(
|
||||
'苹果',
|
||||
),
|
||||
).toBeNull();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('wrong basket keeps the item active after feedback', () => {
|
||||
vi.useFakeTimers();
|
||||
const { container } = render(
|
||||
<BabyObjectMatchRuntimeShell
|
||||
draft={createDraft()}
|
||||
random={createRandomSequence([0, 0])}
|
||||
/>,
|
||||
);
|
||||
const stage = container.querySelector('.baby-object-runtime__stage');
|
||||
if (!(stage instanceof HTMLElement)) {
|
||||
throw new Error('Missing baby object runtime stage');
|
||||
}
|
||||
|
||||
fireEvent.keyDown(window, { key: 'f', code: 'KeyF' });
|
||||
dragHand(stage, 2);
|
||||
|
||||
expect(screen.getByText('再想一想吧')).toBeTruthy();
|
||||
expect(screen.getByLabelText('成功次数').textContent).toBe('0/20');
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(800);
|
||||
});
|
||||
|
||||
expect(screen.queryByText('再想一想吧')).toBeNull();
|
||||
expect(
|
||||
within(screen.getByTestId('baby-object-current-item')).getByAltText(
|
||||
'苹果',
|
||||
),
|
||||
).toBeTruthy();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('twenty correct placements completes the level', () => {
|
||||
vi.useFakeTimers();
|
||||
const randomValues = Array.from({ length: 40 }, () => 0);
|
||||
const { container } = render(
|
||||
<BabyObjectMatchRuntimeShell
|
||||
draft={createDraft()}
|
||||
random={createRandomSequence(randomValues)}
|
||||
/>,
|
||||
);
|
||||
const stage = container.querySelector('.baby-object-runtime__stage');
|
||||
if (!(stage instanceof HTMLElement)) {
|
||||
throw new Error('Missing baby object runtime stage');
|
||||
}
|
||||
|
||||
for (let index = 0; index < 20; index += 1) {
|
||||
fireEvent.keyDown(window, { key: 'f', code: 'KeyF' });
|
||||
dragHand(stage, 0);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(800);
|
||||
});
|
||||
}
|
||||
|
||||
expect(screen.getAllByText('恭喜你!小朋友!').length).toBeGreaterThan(0);
|
||||
expect(screen.getByRole('button', { name: '再来一次' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '下一关' })).toBeTruthy();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user