This commit is contained in:
2026-05-14 21:33:34 +08:00
193 changed files with 17051 additions and 1203 deletions

View File

@@ -0,0 +1,50 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { BarkBattleConfigEditor } from './BarkBattleConfigEditor';
describe('BarkBattleConfigEditor', () => {
it('allows creators to edit lightweight config and publish a Bark Battle work', async () => {
const onPublish = vi.fn();
render(<BarkBattleConfigEditor isBusy={false} onPublish={onPublish} />);
expect(screen.getByRole('heading', { name: '汪汪声浪大作战' })).toBeTruthy();
expect(screen.getByText('轻配置作品')).toBeTruthy();
expect((screen.getByLabelText('作品标题') as HTMLInputElement).value).toBe('我的声浪竞技场');
expect((screen.getByLabelText('难度预设') as HTMLSelectElement).value).toBe('normal');
expect((screen.getByLabelText('开启排行榜') as HTMLInputElement).checked).toBe(true);
await userEvent.clear(screen.getByLabelText('作品标题'));
await userEvent.type(screen.getByLabelText('作品标题'), '周末狗狗杯');
await userEvent.selectOptions(screen.getByLabelText('主题背景'), 'neon-park');
await userEvent.selectOptions(screen.getByLabelText('玩家狗狗'), 'shiba');
await userEvent.selectOptions(screen.getByLabelText('对手狗狗'), 'husky');
await userEvent.selectOptions(screen.getByLabelText('难度预设'), 'hard');
await userEvent.click(screen.getByLabelText('开启排行榜'));
await userEvent.click(screen.getByRole('button', { name: '发布并试玩' }));
expect(onPublish).toHaveBeenCalledWith({
title: '周末狗狗杯',
description: '',
themePreset: 'neon-park',
playerDogSkinPreset: 'shiba',
opponentDogSkinPreset: 'husky',
difficultyPreset: 'hard',
leaderboardEnabled: false,
});
});
it('requires a non-empty title before publishing', async () => {
const onPublish = vi.fn();
render(<BarkBattleConfigEditor isBusy={false} onPublish={onPublish} />);
await userEvent.clear(screen.getByLabelText('作品标题'));
await userEvent.click(screen.getByRole('button', { name: '发布并试玩' }));
expect(onPublish).not.toHaveBeenCalled();
expect(screen.getByText('请先填写作品标题')).toBeTruthy();
});
});

View File

@@ -0,0 +1,161 @@
import { useMemo, useState } from 'react';
import type { BarkBattleConfigEditorPayload } from '../../../packages/shared/src/contracts/barkBattle';
import type { BarkBattleDifficultyPreset } from '../../../packages/shared/src/contracts/barkBattle';
import { BarkBattlePreviewCard } from './BarkBattlePreviewCard';
export type BarkBattleConfigEditorProps = {
isBusy?: boolean;
onPublish: (payload: BarkBattleConfigEditorPayload) => void | Promise<void>;
onBack?: () => void;
};
const THEME_OPTIONS = [
{ value: 'sunny-yard', label: '阳光院子' },
{ value: 'neon-park', label: '霓虹公园' },
{ value: 'moonlight-rooftop', label: '月光天台' },
];
const DOG_SKIN_OPTIONS = [
{ value: 'corgi', label: '柯基' },
{ value: 'shiba', label: '柴犬' },
{ value: 'husky', label: '哈士奇' },
];
const DIFFICULTY_OPTIONS: Array<{ value: BarkBattleDifficultyPreset; label: string }> = [
{ value: 'easy', label: '轻松' },
{ value: 'normal', label: '标准' },
{ value: 'hard', label: '硬核' },
];
export function BarkBattleConfigEditor({
isBusy = false,
onPublish,
onBack,
}: BarkBattleConfigEditorProps) {
const [title, setTitle] = useState('我的声浪竞技场');
const [description, setDescription] = useState('');
const [themePreset, setThemePreset] = useState('sunny-yard');
const [playerDogSkinPreset, setPlayerDogSkinPreset] = useState('corgi');
const [opponentDogSkinPreset, setOpponentDogSkinPreset] = useState('husky');
const [difficultyPreset, setDifficultyPreset] = useState<BarkBattleDifficultyPreset>('normal');
const [leaderboardEnabled, setLeaderboardEnabled] = useState(true);
const [error, setError] = useState<string | null>(null);
const payload = useMemo<BarkBattleConfigEditorPayload>(
() => ({
title: title.trim(),
description: description.trim(),
themePreset,
playerDogSkinPreset,
opponentDogSkinPreset,
difficultyPreset,
leaderboardEnabled,
}),
[
title,
description,
themePreset,
playerDogSkinPreset,
opponentDogSkinPreset,
difficultyPreset,
leaderboardEnabled,
],
);
const handlePublish = () => {
if (!payload.title) {
setError('请先填写作品标题');
return;
}
setError(null);
void onPublish(payload);
};
return (
<section className="min-h-screen bg-slate-950 px-4 py-6 text-slate-50 sm:px-6" aria-label="Bark Battle 轻配置编辑器">
<div className="mx-auto flex w-full max-w-5xl flex-col gap-5 lg:grid lg:grid-cols-[minmax(0,1fr)_360px]">
<div className="rounded-3xl border border-cyan-300/20 bg-slate-900/90 p-5 shadow-2xl shadow-cyan-950/40">
<div className="mb-5 flex items-start justify-between gap-3">
<div>
<p className="mb-2 inline-flex rounded-full bg-cyan-300/10 px-3 py-1 text-xs font-bold text-cyan-100"></p>
<h1 className="text-2xl font-black tracking-tight sm:text-3xl"></h1>
<p className="mt-2 text-sm text-slate-300"></p>
</div>
{onBack ? (
<button type="button" onClick={onBack} className="rounded-full border border-slate-600 px-3 py-2 text-sm text-slate-200">
</button>
) : null}
</div>
<div className="grid gap-4">
<label className="grid gap-2 text-sm font-semibold text-slate-200">
<input
value={title}
onChange={(event) => setTitle(event.target.value)}
className="rounded-2xl border border-slate-700 bg-slate-950 px-4 py-3 text-base text-white outline-none focus:border-cyan-300"
maxLength={40}
/>
</label>
<label className="grid gap-2 text-sm font-semibold text-slate-200">
<textarea
value={description}
onChange={(event) => setDescription(event.target.value)}
className="min-h-[88px] rounded-2xl border border-slate-700 bg-slate-950 px-4 py-3 text-base text-white outline-none focus:border-cyan-300"
maxLength={160}
placeholder="一句话告诉玩家这场声浪对决的氛围"
/>
</label>
<div className="grid gap-4 sm:grid-cols-2">
<label className="grid gap-2 text-sm font-semibold text-slate-200">
<select value={themePreset} onChange={(event) => setThemePreset(event.target.value)} className="rounded-2xl border border-slate-700 bg-slate-950 px-4 py-3 text-white">
{THEME_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
<label className="grid gap-2 text-sm font-semibold text-slate-200">
<select value={difficultyPreset} onChange={(event) => setDifficultyPreset(event.target.value as BarkBattleDifficultyPreset)} className="rounded-2xl border border-slate-700 bg-slate-950 px-4 py-3 text-white">
{DIFFICULTY_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
<label className="grid gap-2 text-sm font-semibold text-slate-200">
<select value={playerDogSkinPreset} onChange={(event) => setPlayerDogSkinPreset(event.target.value)} className="rounded-2xl border border-slate-700 bg-slate-950 px-4 py-3 text-white">
{DOG_SKIN_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
<label className="grid gap-2 text-sm font-semibold text-slate-200">
<select value={opponentDogSkinPreset} onChange={(event) => setOpponentDogSkinPreset(event.target.value)} className="rounded-2xl border border-slate-700 bg-slate-950 px-4 py-3 text-white">
{DOG_SKIN_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
</div>
<label className="flex items-center justify-between gap-3 rounded-2xl border border-slate-700 bg-slate-950 px-4 py-3 text-sm font-semibold text-slate-100">
<span>
<span className="block text-xs font-normal text-slate-400"></span>
</span>
<input aria-label="开启排行榜" type="checkbox" checked={leaderboardEnabled} onChange={(event) => setLeaderboardEnabled(event.target.checked)} className="h-5 w-5" />
</label>
{error ? <p className="rounded-2xl bg-rose-500/15 px-4 py-3 text-sm font-semibold text-rose-100">{error}</p> : null}
<button type="button" disabled={isBusy} onClick={handlePublish} className="rounded-full bg-cyan-200 px-5 py-3 text-sm font-black text-slate-950 disabled:opacity-50">
{isBusy ? '发布中…' : '发布并试玩'}
</button>
</div>
</div>
<BarkBattlePreviewCard config={payload} />
</div>
</section>
);
}

View File

@@ -0,0 +1,56 @@
import type { BarkBattleConfigEditorPayload } from '../../../packages/shared/src/contracts/barkBattle';
type BarkBattlePreviewCardProps = {
config: BarkBattleConfigEditorPayload;
};
const THEME_LABELS: Record<string, string> = {
'sunny-yard': '阳光院子',
'neon-park': '霓虹公园',
'moonlight-rooftop': '月光天台',
};
const DOG_LABELS: Record<string, string> = {
corgi: '柯基',
shiba: '柴犬',
husky: '哈士奇',
};
const DIFFICULTY_LABELS = {
easy: '轻松',
normal: '标准',
hard: '硬核',
};
export function BarkBattlePreviewCard({ config }: BarkBattlePreviewCardProps) {
return (
<aside className="rounded-3xl border border-cyan-300/20 bg-gradient-to-br from-slate-900 via-slate-950 to-cyan-950 p-5 text-slate-50 shadow-2xl shadow-cyan-950/40" aria-label="作品预览卡片">
<p className="mb-3 text-xs font-bold uppercase tracking-[0.25em] text-cyan-200">Preview</p>
<div className="rounded-3xl border border-white/10 bg-white/10 p-5">
<div className="mb-5 flex min-h-40 items-center justify-center rounded-3xl bg-cyan-200/10 text-6xl" aria-hidden="true">
🐶 VS 🐺
</div>
<h2 className="text-xl font-black">{config.title || '未命名声浪竞技场'}</h2>
<p className="mt-2 min-h-[42px] text-sm text-slate-300">{config.description || '30 秒声浪拔河,喊出你的能量优势。'}</p>
<dl className="mt-5 grid gap-3 text-sm">
<div className="flex justify-between gap-3 rounded-2xl bg-slate-950/60 px-3 py-2">
<dt className="text-slate-400"></dt>
<dd className="font-bold">{THEME_LABELS[config.themePreset] ?? config.themePreset}</dd>
</div>
<div className="flex justify-between gap-3 rounded-2xl bg-slate-950/60 px-3 py-2">
<dt className="text-slate-400"></dt>
<dd className="font-bold">{DOG_LABELS[config.playerDogSkinPreset] ?? config.playerDogSkinPreset} vs {DOG_LABELS[config.opponentDogSkinPreset] ?? config.opponentDogSkinPreset}</dd>
</div>
<div className="flex justify-between gap-3 rounded-2xl bg-slate-950/60 px-3 py-2">
<dt className="text-slate-400"></dt>
<dd className="font-bold">{DIFFICULTY_LABELS[config.difficultyPreset]}</dd>
</div>
<div className="flex justify-between gap-3 rounded-2xl bg-slate-950/60 px-3 py-2">
<dt className="text-slate-400"></dt>
<dd className="font-bold">{config.leaderboardEnabled ? '开启' : '关闭'}</dd>
</div>
</dl>
</div>
</aside>
);
}

View File

@@ -1,6 +1,7 @@
/* @vitest-environment jsdom */
import { act, fireEvent, render, screen } from '@testing-library/react';
import type { ReactElement } from 'react';
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import { ChildMotionWarmupDemo } from './ChildMotionWarmupDemo';
@@ -13,11 +14,41 @@ const mocapMock = vi.hoisted(() => ({
status: 'connected' as 'idle' | 'connecting' | 'connected' | 'error',
command: null as null | {
actions: string[];
hands?: Array<{ x: number; y: number; state: string; side: string }>;
primaryHand?: { x: number; y: number; state: string; side: string } | null;
leftHand?: { x: number; y: number; state: string; side: string } | null;
rightHand?: { x: number; y: number; state: string; side: string } | null;
hands?: Array<{
x: number;
y: number;
state: string;
side: string;
wrist?: { x: number; y: number } | null;
}>;
primaryHand?: {
x: number;
y: number;
state: string;
side: string;
wrist?: { x: number; y: number } | null;
} | null;
leftHand?: {
x: number;
y: number;
state: string;
side: string;
wrist?: { x: number; y: number } | null;
} | null;
rightHand?: {
x: number;
y: number;
state: string;
side: string;
wrist?: { x: number; y: number } | null;
} | null;
bodyCenter?: { x: number; y: number } | null;
bodyJoints?: {
leftShoulder?: { x: number; y: number } | null;
rightShoulder?: { x: number; y: number } | null;
leftElbow?: { x: number; y: number } | null;
rightElbow?: { x: number; y: number } | null;
};
},
receivedAtMs: 1,
}));
@@ -66,15 +97,170 @@ afterEach(() => {
vi.restoreAllMocks();
});
function setMocapBodyCenter(x: number) {
mocapMock.command = {
actions: [],
bodyCenter: { x, y: 0.6 },
hands: [],
primaryHand: null,
leftHand: null,
rightHand: null,
};
mocapMock.receivedAtMs += 1;
}
async function advanceWarmupTime(ms: number) {
await act(async () => {
vi.advanceTimersByTime(ms);
});
}
async function revealCurrentStepCue() {
await advanceWarmupTime(1100);
}
async function completeCurrentPositionStepByHold() {
await advanceWarmupTime(2200);
await advanceWarmupTime(900);
}
async function completeCurrentNarrationStep() {
await revealCurrentStepCue();
await advanceWarmupTime(1000);
await advanceWarmupTime(900);
}
async function sendMocapLeftHandTrack(
rerender: (ui: ReactElement) => void,
points: number[],
options: { raised?: boolean } = {},
) {
for (const x of points) {
const y = options.raised ? 0.34 : 0.72;
const wrist = { x, y };
mocapMock.command = {
actions: [],
bodyCenter: { x: 0.5, y: 0.7 },
bodyJoints: {
leftShoulder: { x: 0.4, y: 0.42 },
leftElbow: { x: 0.36, y: 0.5 },
},
hands: [{ x, y, state: 'unknown', side: 'left', wrist }],
primaryHand: { x, y, state: 'unknown', side: 'left', wrist },
leftHand: { x, y, state: 'unknown', side: 'left', wrist },
rightHand: null,
};
mocapMock.receivedAtMs += 1;
await act(async () => {
rerender(<ChildMotionWarmupDemo />);
});
}
}
function setMocapCameraHandTrackPoint({
cameraSide,
x,
y,
}: {
cameraSide: 'left' | 'right';
x: number;
y: number;
}) {
const wrist = { x, y };
const hand = { x, y, state: 'unknown', side: cameraSide, wrist };
const command = {
actions: [],
bodyCenter: { x: 0.5, y: 0.7 },
bodyJoints: {
leftShoulder: { x: 0.62, y: 0.48 },
leftElbow: { x: 0.7, y: 0.5 },
rightShoulder: { x: 0.38, y: 0.48 },
rightElbow: { x: 0.3, y: 0.5 },
},
hands: [hand],
primaryHand: hand,
leftHand: null as null | typeof hand,
rightHand: null as null | typeof hand,
};
if (cameraSide === 'left') {
command.leftHand = hand;
} else {
command.rightHand = hand;
}
mocapMock.command = command;
mocapMock.receivedAtMs += 1;
}
async function sendMocapCameraHandTrack(
rerender: (ui: ReactElement) => void,
cameraSide: 'left' | 'right',
points: Array<{ x: number; y: number }>,
) {
for (const point of points) {
setMocapCameraHandTrackPoint({ cameraSide, ...point });
await act(async () => {
rerender(<ChildMotionWarmupDemo />);
});
}
}
async function sendPlayerLeftArmSwingTrack(
rerender: (ui: ReactElement) => void,
) {
await sendMocapCameraHandTrack(rerender, 'right', [
{ x: 0.2, y: 0.5 },
{ x: 0.16, y: 0.42 },
{ x: 0.13, y: 0.34 },
{ x: 0.15, y: 0.43 },
{ x: 0.19, y: 0.51 },
]);
}
async function sendPlayerRightArmSwingTrack(
rerender: (ui: ReactElement) => void,
) {
await sendMocapCameraHandTrack(rerender, 'left', [
{ x: 0.8, y: 0.5 },
{ x: 0.84, y: 0.42 },
{ x: 0.87, y: 0.34 },
{ x: 0.85, y: 0.43 },
{ x: 0.81, y: 0.51 },
]);
}
async function completeGreetingByWaveTrack(
rerender: (ui: ReactElement) => void,
) {
await sendMocapLeftHandTrack(rerender, [0.42, 0.51, 0.58, 0.49, 0.43], {
raised: true,
});
}
test('renders the warmup stage and starts with the center ring step', () => {
render(<ChildMotionWarmupDemo />);
expect(screen.getByTestId('child-motion-demo')).toBeTruthy();
expect(screen.getByText('来到圆圈这里')).toBeTruthy();
expect(screen.getByLabelText('绿色圆环')).toBeTruthy();
expect(screen.queryByLabelText('绿色圆环')).toBeNull();
expect(screen.getByText('请横屏体验')).toBeTruthy();
});
test('shows narration first before revealing the step cue', async () => {
vi.useFakeTimers();
render(<ChildMotionWarmupDemo />);
expect(screen.getByText('来到圆圈这里')).toBeTruthy();
expect(screen.queryByLabelText('绿色圆环')).toBeNull();
expect(screen.getByTestId('child-motion-stage').dataset.stepPhase).toBe('intro');
await advanceWarmupTime(1000);
expect(screen.getByLabelText('绿色圆环')).toBeTruthy();
expect(screen.getByTestId('child-motion-stage').dataset.stepPhase).toBe('active');
});
test('re-entering within the same runtime session opens the start button', () => {
markChildMotionWarmupCompletedInRuntime();
@@ -113,16 +299,35 @@ test('developer keyboard input moves the avatar and triggers jump state', () =>
expect(avatar.className).toContain('child-motion-avatar--jumping');
});
test('mocap body center dampens small jitter before moving the avatar', async () => {
setMocapBodyCenter(0.5);
const { rerender } = render(<ChildMotionWarmupDemo />);
expect(screen.getByTestId('child-motion-avatar').getAttribute('style')).toContain(
'left: 50%',
);
setMocapBodyCenter(0.508);
await act(async () => {
rerender(<ChildMotionWarmupDemo />);
});
expect(screen.getByTestId('child-motion-avatar').getAttribute('style')).toContain(
'left: 50%',
);
setMocapBodyCenter(0.34);
await act(async () => {
rerender(<ChildMotionWarmupDemo />);
});
const style = screen.getByTestId('child-motion-avatar').getAttribute('style');
expect(style).toContain('left: 46.5%');
expect(style).not.toContain('left: 34%');
});
test('mocap body center keeps the warmup flow on the motion data source', async () => {
vi.useFakeTimers();
mocapMock.command = {
actions: [],
bodyCenter: { x: 0.5, y: 0.6 },
hands: [],
primaryHand: null,
leftHand: null,
rightHand: null,
};
setMocapBodyCenter(0.5);
const { rerender, unmount } = render(<ChildMotionWarmupDemo />);
expect(screen.queryByText('摄像头暂不可用,已切换到本地演示')).toBeNull();
@@ -131,63 +336,39 @@ test('mocap body center keeps the warmup flow on the motion data source', async
'left: 50%',
);
await act(async () => {
vi.advanceTimersByTime(2100);
await vi.runOnlyPendingTimersAsync();
});
await revealCurrentStepCue();
await completeCurrentPositionStepByHold();
await vi.waitFor(() => {
expect(screen.getByText('打个招呼')).toBeTruthy();
});
mocapMock.command = {
actions: ['open_palm'],
bodyCenter: { x: 0.5, y: 0.6 },
hands: [{ x: 0.48, y: 0.34, state: 'open_palm', side: 'left' }],
primaryHand: { x: 0.48, y: 0.34, state: 'open_palm', side: 'left' },
leftHand: { x: 0.48, y: 0.34, state: 'open_palm', side: 'left' },
rightHand: null,
};
mocapMock.receivedAtMs += 1;
await act(async () => {
rerender(<ChildMotionWarmupDemo />);
vi.advanceTimersByTime(1000);
await vi.runOnlyPendingTimersAsync();
});
await revealCurrentStepCue();
await completeGreetingByWaveTrack(rerender);
await advanceWarmupTime(900);
await vi.waitFor(() => {
expect(screen.getByText('准备热身')).toBeTruthy();
});
await act(async () => {
vi.advanceTimersByTime(1000);
await vi.runOnlyPendingTimersAsync();
});
await completeCurrentNarrationStep();
await vi.waitFor(() => {
expect(screen.getByRole('heading', { name: '向左一步' })).toBeTruthy();
});
mocapMock.command = {
actions: [],
bodyCenter: { x: 0.34, y: 0.6 },
hands: [],
primaryHand: null,
leftHand: null,
rightHand: null,
};
mocapMock.receivedAtMs += 1;
await act(async () => {
rerender(<ChildMotionWarmupDemo />);
});
await revealCurrentStepCue();
for (const targetX of [0.34, 0.34, 0.34, 0.34, 0.34]) {
setMocapBodyCenter(targetX);
await act(async () => {
rerender(<ChildMotionWarmupDemo />);
});
}
await vi.waitFor(() => {
expect(screen.getByTestId('child-motion-avatar').getAttribute('style')).toContain(
'left: 34%',
'left: 37',
);
});
await act(async () => {
vi.advanceTimersByTime(2100);
await vi.runOnlyPendingTimersAsync();
});
await completeCurrentPositionStepByHold();
await vi.waitFor(() => {
expect(screen.getByRole('heading', { name: '回到中间来' })).toBeTruthy();
@@ -199,18 +380,17 @@ test('mocap body center keeps the warmup flow on the motion data source', async
vi.useRealTimers();
});
test('mocap open palm completes the greeting wave step', async () => {
test('mocap greeting requires a real horizontal wave track', async () => {
vi.useFakeTimers();
const { rerender, unmount } = render(<ChildMotionWarmupDemo />);
await act(async () => {
vi.advanceTimersByTime(2100);
await vi.runOnlyPendingTimersAsync();
});
await revealCurrentStepCue();
await completeCurrentPositionStepByHold();
await vi.waitFor(() => {
expect(screen.getByText('打个招呼')).toBeTruthy();
});
await revealCurrentStepCue();
mocapMock.command = {
actions: ['open_palm'],
hands: [{ x: 0.46, y: 0.34, state: 'open_palm', side: 'left' }],
@@ -222,7 +402,35 @@ test('mocap open palm completes the greeting wave step', async () => {
await act(async () => {
rerender(<ChildMotionWarmupDemo />);
});
await advanceWarmupTime(900);
expect(screen.getByRole('heading', { name: '打个招呼' })).toBeTruthy();
await sendMocapLeftHandTrack(rerender, [0.42, 0.51, 0.58, 0.49, 0.43], {
raised: false,
});
await advanceWarmupTime(900);
expect(screen.getByRole('heading', { name: '打个招呼' })).toBeTruthy();
for (const x of [0.42, 0.51, 0.58, 0.49, 0.43]) {
const wrist = { x, y: 0.34 };
mocapMock.command = {
actions: [],
bodyCenter: { x: 0.5, y: 0.7 },
hands: [{ x, y: 0.34, state: 'unknown', side: 'left', wrist }],
primaryHand: { x, y: 0.34, state: 'unknown', side: 'left', wrist },
leftHand: { x, y: 0.34, state: 'unknown', side: 'left', wrist },
rightHand: null,
};
mocapMock.receivedAtMs += 1;
await act(async () => {
rerender(<ChildMotionWarmupDemo />);
});
}
await advanceWarmupTime(900);
expect(screen.getByRole('heading', { name: '打个招呼' })).toBeTruthy();
await completeGreetingByWaveTrack(rerender);
await advanceWarmupTime(900);
await vi.waitFor(() => {
expect(screen.getByText('准备热身')).toBeTruthy();
});
@@ -232,117 +440,89 @@ test('mocap open palm completes the greeting wave step', async () => {
vi.useRealTimers();
});
test('mocap hand tracks complete left and right wave steps only after movement is visible', async () => {
test('mocap arm swing steps require body-side mapping and vertical open arm motion', async () => {
vi.useFakeTimers();
const { rerender, unmount } = render(<ChildMotionWarmupDemo />);
const advancePositionStep = async (key: string, code: string) => {
await revealCurrentStepCue();
await act(async () => {
fireEvent.keyDown(window, { key, code });
});
await act(async () => {
vi.advanceTimersByTime(2100);
await vi.runOnlyPendingTimersAsync();
});
await completeCurrentPositionStepByHold();
await act(async () => {
fireEvent.keyUp(window, { key, code });
});
};
await act(async () => {
vi.advanceTimersByTime(2100);
await vi.runOnlyPendingTimersAsync();
});
await revealCurrentStepCue();
await completeCurrentPositionStepByHold();
await vi.waitFor(() => {
expect(screen.getByText('打个招呼')).toBeTruthy();
});
mocapMock.command = {
actions: ['open_palm'],
hands: [{ x: 0.48, y: 0.34, state: 'open_palm', side: 'left' }],
primaryHand: { x: 0.48, y: 0.34, state: 'open_palm', side: 'left' },
leftHand: { x: 0.48, y: 0.34, state: 'open_palm', side: 'left' },
rightHand: null,
};
mocapMock.receivedAtMs += 1;
await act(async () => {
rerender(<ChildMotionWarmupDemo />);
});
await revealCurrentStepCue();
await completeGreetingByWaveTrack(rerender);
await act(async () => {
vi.advanceTimersByTime(1000);
await vi.runOnlyPendingTimersAsync();
});
await advanceWarmupTime(900);
await completeCurrentNarrationStep();
await advancePositionStep('a', 'KeyA');
await act(async () => {
vi.advanceTimersByTime(120);
await vi.runOnlyPendingTimersAsync();
vi.advanceTimersByTime(2100);
await vi.runOnlyPendingTimersAsync();
});
await revealCurrentStepCue();
await completeCurrentPositionStepByHold();
await advancePositionStep('d', 'KeyD');
await act(async () => {
vi.advanceTimersByTime(120);
await vi.runOnlyPendingTimersAsync();
vi.advanceTimersByTime(2100);
await vi.runOnlyPendingTimersAsync();
});
await revealCurrentStepCue();
await completeCurrentPositionStepByHold();
await vi.waitFor(() => {
expect(screen.getByRole('heading', { name: '挥动左手' })).toBeTruthy();
});
mocapMock.command = {
actions: [],
leftHand: { x: 0.3, y: 0.38, state: 'unknown', side: 'left' },
primaryHand: { x: 0.3, y: 0.38, state: 'unknown', side: 'left' },
rightHand: null,
};
mocapMock.receivedAtMs += 1;
await act(async () => {
rerender(<ChildMotionWarmupDemo />);
});
mocapMock.command = {
actions: [],
leftHand: { x: 0.39, y: 0.36, state: 'unknown', side: 'left' },
primaryHand: { x: 0.39, y: 0.36, state: 'unknown', side: 'left' },
rightHand: null,
};
mocapMock.receivedAtMs += 1;
await act(async () => {
rerender(<ChildMotionWarmupDemo />);
});
mocapMock.command = {
actions: [],
leftHand: { x: 0.31, y: 0.34, state: 'unknown', side: 'left' },
primaryHand: { x: 0.31, y: 0.34, state: 'unknown', side: 'left' },
rightHand: null,
};
mocapMock.receivedAtMs += 1;
await act(async () => {
rerender(<ChildMotionWarmupDemo />);
});
await revealCurrentStepCue();
await sendMocapCameraHandTrack(rerender, 'left', [
{ x: 0.78, y: 0.5 },
{ x: 0.86, y: 0.5 },
{ x: 0.79, y: 0.5 },
{ x: 0.87, y: 0.5 },
{ x: 0.8, y: 0.5 },
]);
await advanceWarmupTime(900);
expect(screen.getByRole('heading', { name: '挥动左手' })).toBeTruthy();
await sendMocapCameraHandTrack(rerender, 'right', [
{ x: 0.32, y: 0.74 },
{ x: 0.24, y: 0.74 },
{ x: 0.31, y: 0.74 },
{ x: 0.23, y: 0.74 },
{ x: 0.3, y: 0.74 },
]);
await advanceWarmupTime(900);
expect(screen.getByRole('heading', { name: '挥动左手' })).toBeTruthy();
await sendPlayerLeftArmSwingTrack(rerender);
await advanceWarmupTime(900);
await vi.waitFor(() => {
expect(screen.getByRole('heading', { name: '挥动右手' })).toBeTruthy();
});
mocapMock.command = {
actions: ['right_hand_wave'],
leftHand: null,
primaryHand: { x: 0.64, y: 0.35, state: 'unknown', side: 'right' },
rightHand: { x: 0.64, y: 0.35, state: 'unknown', side: 'right' },
};
mocapMock.receivedAtMs += 1;
await act(async () => {
rerender(<ChildMotionWarmupDemo />);
});
await revealCurrentStepCue();
await sendMocapCameraHandTrack(rerender, 'right', [
{ x: 0.2, y: 0.5 },
{ x: 0.16, y: 0.42 },
{ x: 0.13, y: 0.34 },
{ x: 0.15, y: 0.43 },
{ x: 0.19, y: 0.51 },
]);
await advanceWarmupTime(900);
expect(screen.getByRole('heading', { name: '挥动右手' })).toBeTruthy();
await sendPlayerRightArmSwingTrack(rerender);
await advanceWarmupTime(900);
await vi.waitFor(() => {
expect(screen.getByRole('heading', { name: '原地跳一下' })).toBeTruthy();
});
await advanceWarmupTime(720);
await act(async () => {
vi.advanceTimersByTime(720);
await vi.runOnlyPendingTimersAsync();
unmount();
});
vi.useRealTimers();

View File

@@ -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">

View File

@@ -60,14 +60,25 @@ describe('childMotionWarmupModel', () => {
{
type: 'left-hand',
path: [
{ x: 0.3, y: 0.4 },
{ x: 0.34, y: 0.32 },
{ x: 0.3, y: 0.4, armAngleDeg: 12, armReach: 0.2 },
{ x: 0.34, y: 0.32, armAngleDeg: 44, armReach: 0.28 },
],
},
);
const withRightHand = applyChildMotionWarmupCompletion(
'wave_right_hand',
withLeftHand,
{
type: 'right-hand',
path: [
{ x: 0.7, y: 0.42, armAngleDeg: 10, armReach: 0.22 },
{ x: 0.82, y: 0.3, armAngleDeg: 46, armReach: 0.31 },
],
},
);
const completed = applyChildMotionWarmupCompletion(
'jump_once',
withLeftHand,
withRightHand,
{
type: 'jump',
jumpSpace: 0.14,
@@ -77,6 +88,16 @@ describe('childMotionWarmupModel', () => {
expect(completed.leftBoundary).toBeCloseTo(0.16);
expect(completed.rightBoundary).toBeCloseTo(0.16);
expect(completed.leftHandPath).toHaveLength(2);
expect(completed.leftHandSpace).toEqual({
minX: 0.3,
maxX: 0.34,
minY: 0.32,
maxY: 0.4,
minAngleDeg: 12,
maxAngleDeg: 44,
maxReach: 0.28,
});
expect(completed.rightHandSpace?.maxReach).toBe(0.31);
expect(completed.jumpSpace).toBe(0.14);
});
});

View File

@@ -32,6 +32,20 @@ export type ChildMotionWarmupStep = {
export type ChildMotionPoint = {
x: number;
y: number;
isRaised?: boolean;
isArmExtended?: boolean;
armAngleDeg?: number;
armReach?: number;
};
export type ChildMotionHandSpace = {
minX: number;
maxX: number;
minY: number;
maxY: number;
minAngleDeg: number | null;
maxAngleDeg: number | null;
maxReach: number | null;
};
export type ChildMotionWarmupCalibration = {
@@ -39,6 +53,8 @@ export type ChildMotionWarmupCalibration = {
rightBoundary: number | null;
leftHandPath: ChildMotionPoint[];
rightHandPath: ChildMotionPoint[];
leftHandSpace: ChildMotionHandSpace | null;
rightHandSpace: ChildMotionHandSpace | null;
jumpSpace: number | null;
};
@@ -206,10 +222,39 @@ export function createEmptyChildMotionCalibration(): ChildMotionWarmupCalibratio
rightBoundary: null,
leftHandPath: [],
rightHandPath: [],
leftHandSpace: null,
rightHandSpace: null,
jumpSpace: null,
};
}
function resolveChildMotionHandSpace(
path: ChildMotionPoint[],
): ChildMotionHandSpace | null {
if (path.length === 0) {
return null;
}
const xValues = path.map((point) => point.x);
const yValues = path.map((point) => point.y);
const angleValues = path
.map((point) => point.armAngleDeg)
.filter((angle): angle is number => typeof angle === 'number');
const reachValues = path
.map((point) => point.armReach)
.filter((reach): reach is number => typeof reach === 'number');
return {
minX: Math.min(...xValues),
maxX: Math.max(...xValues),
minY: Math.min(...yValues),
maxY: Math.max(...yValues),
minAngleDeg: angleValues.length > 0 ? Math.min(...angleValues) : null,
maxAngleDeg: angleValues.length > 0 ? Math.max(...angleValues) : null,
maxReach: reachValues.length > 0 ? Math.max(...reachValues) : null,
};
}
export function applyChildMotionWarmupCompletion(
stepId: ChildMotionWarmupStepId,
calibration: ChildMotionWarmupCalibration,
@@ -233,6 +278,7 @@ export function applyChildMotionWarmupCompletion(
return {
...calibration,
leftHandPath: completion.path,
leftHandSpace: resolveChildMotionHandSpace(completion.path),
};
}
@@ -240,6 +286,7 @@ export function applyChildMotionWarmupCompletion(
return {
...calibration,
rightHandPath: completion.path,
rightHandSpace: resolveChildMotionHandSpace(completion.path),
};
}

View File

@@ -5,6 +5,7 @@ import userEvent from '@testing-library/user-event';
import { afterEach, expect, test, vi } from 'vitest';
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
import type { CreationEntryConfig } from '../../services/creationEntryConfigService';
import { derivePlatformCreationTypes } from '../platform-entry/platformEntryCreationTypes';
@@ -189,6 +190,40 @@ const hiddenSquareHoleItem: SquareHoleWorkSummary = {
sourceSessionId: 'square-hole-session-hidden',
};
const babyObjectMatchDraftItem: BabyObjectMatchDraft = {
draftId: 'baby-object-draft-delete',
profileId: 'baby-object-profile-delete',
templateId: 'baby-object-match',
templateName: '宝贝识物',
workTitle: '宝贝识物删除测试',
workDescription: '苹果和香蕉识物分类',
itemNames: ['苹果', '香蕉'],
itemAssets: [
{
itemId: 'baby-object-item-1',
itemName: '苹果',
imageSrc: '/apple.png',
assetObjectId: null,
generationProvider: 'placeholder',
prompt: '苹果',
},
{
itemId: 'baby-object-item-2',
itemName: '香蕉',
imageSrc: '/banana.png',
assetObjectId: null,
generationProvider: 'placeholder',
prompt: '香蕉',
},
],
visualPackage: null,
themeTags: ['寓教于乐'],
publicationStatus: 'draft',
createdAt: new Date('2026-05-11T10:00:00.000Z').toISOString(),
updatedAt: new Date('2026-05-11T10:00:00.000Z').toISOString(),
publishedAt: null,
};
test('creation hub reflects updated draft title summary and counts after rerender', async () => {
const user = userEvent.setup();
const onCreateType = vi.fn();
@@ -486,7 +521,38 @@ test('creation hub reveals persisted draft delete action from keyboard', async (
expect(screen.getByRole('button', { name: '删除' })).toBeTruthy();
});
test('creation hub published work delete action is revealed without opening card', async () => {
test('creation hub shows delete action for baby object match drafts', async () => {
const user = userEvent.setup();
const onDeleteBabyObjectMatch = vi.fn();
const onOpenBabyObjectMatchDetail = vi.fn();
render(
<CustomWorldCreationHub
items={[]}
babyObjectMatchItems={[babyObjectMatchDraftItem]}
loading={false}
error={null}
onRetry={() => {}}
onCreateType={noopCreateType}
onOpenDraft={() => {}}
onEnterPublished={() => {}}
onOpenBabyObjectMatchDetail={onOpenBabyObjectMatchDetail}
onDeleteBabyObjectMatch={onDeleteBabyObjectMatch}
entryConfig={testEntryConfig}
creationTypes={testCreationTypes}
/>,
);
screen.getByRole('button', { name: //u }).focus();
await user.keyboard('{ArrowLeft}');
await user.click(screen.getByRole('button', { name: '删除' }));
expect(onDeleteBabyObjectMatch).toHaveBeenCalledWith(babyObjectMatchDraftItem);
expect(onOpenBabyObjectMatchDetail).not.toHaveBeenCalled();
});
test('creation hub published work delete action is available beside share without opening card', async () => {
const user = userEvent.setup();
const onDeletePuzzle = vi.fn();
const onOpenPuzzleDetail = vi.fn();

View File

@@ -67,6 +67,7 @@ type CustomWorldCreationHubProps = {
claimingPuzzleProfileId?: string | null;
babyObjectMatchItems?: BabyObjectMatchDraft[];
onOpenBabyObjectMatchDetail?: ((item: BabyObjectMatchDraft) => void) | null;
onDeleteBabyObjectMatch?: ((item: BabyObjectMatchDraft) => void) | null;
visualNovelItems?: VisualNovelWorkSummary[];
onOpenVisualNovelDetail?: ((item: VisualNovelWorkSummary) => void) | null;
onDeleteVisualNovel?: ((item: VisualNovelWorkSummary) => void) | null;
@@ -171,6 +172,7 @@ export function CustomWorldCreationHub({
claimingPuzzleProfileId = null,
babyObjectMatchItems = [],
onOpenBabyObjectMatchDetail = null,
onDeleteBabyObjectMatch = null,
visualNovelItems = [],
onOpenVisualNovelDetail = null,
onDeleteVisualNovel = null,
@@ -201,6 +203,7 @@ export function CustomWorldCreationHub({
canDeleteSquareHole:
isSquareHoleCreationVisible && Boolean(onDeleteSquareHole),
canDeletePuzzle: Boolean(onDeletePuzzle),
canDeleteBabyObjectMatch: Boolean(onDeleteBabyObjectMatch),
canDeleteVisualNovel: Boolean(onDeleteVisualNovel),
onOpenRpgDraft: onOpenDraft,
onEnterRpgPublished: onEnterPublished,
@@ -215,6 +218,7 @@ export function CustomWorldCreationHub({
onDeletePuzzle: onDeletePuzzle ?? undefined,
onClaimPuzzlePointIncentive: onClaimPuzzlePointIncentive ?? undefined,
onOpenBabyObjectMatchDetail: onOpenBabyObjectMatchDetail ?? undefined,
onDeleteBabyObjectMatch: onDeleteBabyObjectMatch ?? undefined,
onOpenVisualNovelDetail: onOpenVisualNovelDetail ?? undefined,
onDeleteVisualNovel: onDeleteVisualNovel ?? undefined,
getItemState: getWorkState,
@@ -230,6 +234,7 @@ export function CustomWorldCreationHub({
onDeleteSquareHole,
onDeletePublished,
onDeletePuzzle,
onDeleteBabyObjectMatch,
onDeleteVisualNovel,
onClaimPuzzlePointIncentive,
onOpenBigFishDetail,
@@ -267,6 +272,39 @@ export function CustomWorldCreationHub({
[activeFilter, shelfItems],
);
function handleOpenShelfItem(item: CreationWorkShelfItem) {
onOpenShelfItem?.(item);
switch (item.source.kind) {
case 'puzzle':
onOpenPuzzleDetail?.(item.source.item);
return;
case 'baby-object-match':
onOpenBabyObjectMatchDetail?.(item.source.item);
return;
case 'visual-novel':
onOpenVisualNovelDetail?.(item.source.item);
return;
case 'big-fish':
onOpenBigFishDetail?.(item.source.item);
return;
case 'match3d':
onOpenMatch3DDetail?.(item.source.item);
return;
case 'square-hole':
onOpenSquareHoleDetail?.(item.source.item);
return;
case 'rpg':
if (item.status === 'draft') {
onOpenDraft(item.source.item);
return;
}
if (item.source.item.profileId) {
onEnterPublished(item.source.item.profileId);
}
}
}
function buildDeleteAction(item: CreationWorkShelfItem) {
if (!item.canDelete) {
return null;
@@ -346,8 +384,7 @@ export function CustomWorldCreationHub({
metricSnapshot[buildWorkMetricCacheItemKey(item)]
}
onOpen={() => {
onOpenShelfItem?.(item);
item.actions.open();
handleOpenShelfItem(item);
}}
onDelete={buildDeleteAction(item)}
deleteBusy={deletingWorkId === item.id}

View File

@@ -87,6 +87,8 @@ test('buildCreationWorkShelfItems attaches open and delete actions through shelf
});
test('buildCreationWorkShelfItems maps baby object match local drafts', () => {
const onOpenBabyObjectMatchDetail = vi.fn();
const onDeleteBabyObjectMatch = vi.fn();
const baseDraft: BabyObjectMatchDraft = {
draftId: 'baby-object-draft-1',
profileId: 'baby-object-profile-12345678',
@@ -113,6 +115,7 @@ test('buildCreationWorkShelfItems maps baby object match local drafts', () => {
prompt: '香蕉',
},
],
visualPackage: null,
themeTags: ['寓教于乐'],
publicationStatus: 'draft',
createdAt: '2026-05-11T00:00:00.000Z',
@@ -135,14 +138,23 @@ test('buildCreationWorkShelfItems maps baby object match local drafts', () => {
updatedAt: '2026-05-11T01:00:00.000Z',
},
],
canDeleteBabyObjectMatch: true,
onOpenBabyObjectMatchDetail,
onDeleteBabyObjectMatch,
});
items[1]?.actions.open();
items[1]?.actions.delete?.();
expect(items[0]?.kind).toBe('baby-object-match');
expect(items[0]?.status).toBe('published');
expect(items[0]?.publicWorkCode).toBe('BO-87654321');
expect(items[0]?.sharePath).toContain('/works/detail?work=BO-87654321');
expect(items[1]?.status).toBe('draft');
expect(items[1]?.publicWorkCode).toBeNull();
expect(items[1]?.canDelete).toBe(true);
expect(onOpenBabyObjectMatchDetail).toHaveBeenCalledWith(baseDraft);
expect(onDeleteBabyObjectMatch).toHaveBeenCalledWith(baseDraft);
});
test('buildCreationWorkShelfItems sorts works by latest updatedAt across timestamp formats', () => {

View File

@@ -130,6 +130,7 @@ export function buildCreationWorkShelfItems(params: {
canDeleteMatch3D?: boolean;
canDeleteSquareHole?: boolean;
canDeletePuzzle?: boolean;
canDeleteBabyObjectMatch?: boolean;
canDeleteVisualNovel?: boolean;
onOpenRpgDraft?: (item: CustomWorldWorkSummary) => void;
onEnterRpgPublished?: (profileId: string) => void;
@@ -144,6 +145,7 @@ export function buildCreationWorkShelfItems(params: {
onDeletePuzzle?: (item: PuzzleWorkSummary) => void;
onClaimPuzzlePointIncentive?: (item: PuzzleWorkSummary) => void;
onOpenBabyObjectMatchDetail?: (item: BabyObjectMatchDraft) => void;
onDeleteBabyObjectMatch?: (item: BabyObjectMatchDraft) => void;
onOpenVisualNovelDetail?: (item: VisualNovelWorkSummary) => void;
onDeleteVisualNovel?: (item: VisualNovelWorkSummary) => void;
getItemState?: (
@@ -164,6 +166,7 @@ export function buildCreationWorkShelfItems(params: {
canDeleteMatch3D = false,
canDeleteSquareHole = false,
canDeletePuzzle = false,
canDeleteBabyObjectMatch = false,
canDeleteVisualNovel = false,
onOpenRpgDraft,
onEnterRpgPublished,
@@ -178,6 +181,7 @@ export function buildCreationWorkShelfItems(params: {
onDeletePuzzle,
onClaimPuzzlePointIncentive,
onOpenBabyObjectMatchDetail,
onDeleteBabyObjectMatch,
onOpenVisualNovelDetail,
onDeleteVisualNovel,
getItemState,
@@ -217,8 +221,9 @@ export function buildCreationWorkShelfItems(params: {
}),
),
...babyObjectMatchItems.map((item) =>
mapBabyObjectMatchDraftToShelfItem(item, {
mapBabyObjectMatchDraftToShelfItem(item, canDeleteBabyObjectMatch, {
onOpen: onOpenBabyObjectMatchDetail,
onDelete: onDeleteBabyObjectMatch,
}),
),
...visualNovelItems.map((item) =>
@@ -467,6 +472,7 @@ function mapPuzzleWorkToShelfItem(
function mapBabyObjectMatchDraftToShelfItem(
item: BabyObjectMatchDraft,
canDelete: boolean,
adapter: WorkShelfAdapter<BabyObjectMatchDraft>,
): CreationWorkShelfItem {
const status = item.publicationStatus === 'published' ? 'published' : 'draft';
@@ -495,7 +501,7 @@ function mapBabyObjectMatchDraftToShelfItem(
? buildPublicWorkStagePath('work-detail', publicWorkCode)
: null,
openActionLabel: status === 'published' ? '查看详情' : '继续创作',
canDelete: false,
canDelete,
canShare: status === 'published' && Boolean(publicWorkCode),
badges: [
buildStatusBadge(status),

View File

@@ -51,6 +51,7 @@ function createDraft(overrides: Partial<BabyObjectMatchDraft> = {}) {
prompt: '香蕉',
},
],
visualPackage: null,
themeTags: ['宝贝识物'],
publicationStatus: 'draft',
createdAt: '2026-05-11T00:00:00.000Z',
@@ -62,13 +63,81 @@ function createDraft(overrides: Partial<BabyObjectMatchDraft> = {}) {
return draft;
}
function createGeneratedDraft() {
return createDraft({
itemAssets: [
{
itemId: 'baby-object-item-1',
itemName: '苹果',
imageSrc: 'data:image/png;base64,a',
assetObjectId: null,
generationProvider: 'vector-engine-gpt-image-2',
prompt: '苹果',
},
{
itemId: 'baby-object-item-2',
itemName: '香蕉',
imageSrc: 'data:image/png;base64,b',
assetObjectId: null,
generationProvider: 'vector-engine-gpt-image-2',
prompt: '香蕉',
},
],
visualPackage: {
themePrompt: '果园主题',
assets: [
{
assetId: 'baby-object-visual-background',
assetKind: 'background',
imageSrc: 'data:image/png;base64,background',
assetObjectId: null,
generationProvider: 'vector-engine-gpt-image-2',
prompt: 'background',
},
{
assetId: 'baby-object-visual-ui-frame',
assetKind: 'ui-frame',
imageSrc: 'data:image/png;base64,ui',
assetObjectId: null,
generationProvider: 'vector-engine-gpt-image-2',
prompt: 'ui',
},
{
assetId: 'baby-object-visual-gift-box',
assetKind: 'gift-box',
imageSrc: 'data:image/png;base64,gift',
assetObjectId: null,
generationProvider: 'vector-engine-gpt-image-2',
prompt: 'gift',
},
{
assetId: 'baby-object-visual-basket',
assetKind: 'basket',
imageSrc: 'data:image/png;base64,basket',
assetObjectId: null,
generationProvider: 'vector-engine-gpt-image-2',
prompt: 'basket',
},
{
assetId: 'baby-object-visual-smoke-puff',
assetKind: 'smoke-puff',
imageSrc: 'data:image/png;base64,smoke',
assetObjectId: null,
generationProvider: 'vector-engine-gpt-image-2',
prompt: 'smoke',
},
],
},
});
}
test('baby object result publishes with exact edutainment tag', async () => {
const user = userEvent.setup();
const onPublish = vi.fn();
render(
<BabyObjectMatchResultView
draft={createDraft()}
draft={createGeneratedDraft()}
onBack={() => {}}
onPublish={onPublish}
/>,
@@ -90,7 +159,7 @@ test('baby object result exposes save and test run actions', async () => {
render(
<BabyObjectMatchResultView
draft={createDraft()}
draft={createGeneratedDraft()}
onBack={() => {}}
onSaveDraft={onSaveDraft}
onStartTestRun={onStartTestRun}
@@ -103,3 +172,38 @@ test('baby object result exposes save and test run actions', async () => {
expect(onSaveDraft).toHaveBeenCalledTimes(1);
expect(onStartTestRun).toHaveBeenCalledTimes(1);
});
test('baby object result blocks placeholder assets and exposes regeneration', async () => {
const user = userEvent.setup();
const onPublish = vi.fn();
const onStartTestRun = vi.fn();
const onRegenerateAssets = vi.fn();
render(
<BabyObjectMatchResultView
draft={createDraft()}
onBack={() => {}}
onPublish={onPublish}
onStartTestRun={onStartTestRun}
onRegenerateAssets={onRegenerateAssets}
/>,
);
expect(
screen.getByText('当前作品仍是占位资源,请重新生成 image-2 资源后再试玩或发布。'),
).toBeTruthy();
expect(
(screen.getByRole('button', { name: '试玩' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(screen.getByRole('button', { name: '发布' }) as HTMLButtonElement)
.disabled,
).toBe(true);
await user.click(screen.getByRole('button', { name: '重新生成资源' }));
expect(onRegenerateAssets).toHaveBeenCalledTimes(1);
expect(onPublish).not.toHaveBeenCalled();
expect(onStartTestRun).not.toHaveBeenCalled();
});

View File

@@ -1,4 +1,12 @@
import { ArrowLeft, CheckCircle2, Loader2, Play, Save, Tag } from 'lucide-react';
import {
ArrowLeft,
CheckCircle2,
Loader2,
Play,
RefreshCw,
Save,
Tag,
} from 'lucide-react';
import { useMemo } from 'react';
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
@@ -17,6 +25,7 @@ type BabyObjectMatchResultViewProps = {
onSaveDraft?: (draft: BabyObjectMatchDraft) => void;
onPublish?: (draft: BabyObjectMatchDraft) => void;
onStartTestRun?: (draft: BabyObjectMatchDraft) => void;
onRegenerateAssets?: (draft: BabyObjectMatchDraft) => void;
};
function normalizeDraftForAction(draft: BabyObjectMatchDraft) {
@@ -27,6 +36,14 @@ function normalizeDraftForAction(draft: BabyObjectMatchDraft) {
};
}
const REQUIRED_VISUAL_ASSET_KINDS = [
'background',
'ui-frame',
'gift-box',
'basket',
'smoke-puff',
] as const;
export function BabyObjectMatchResultView({
draft,
isBusy = false,
@@ -35,12 +52,29 @@ export function BabyObjectMatchResultView({
onSaveDraft,
onPublish,
onStartTestRun,
onRegenerateAssets,
}: BabyObjectMatchResultViewProps) {
const normalizedDraft = useMemo(() => normalizeDraftForAction(draft), [draft]);
const hasGeneratedAssets =
normalizedDraft.itemAssets.every(
(asset) =>
asset.generationProvider === 'vector-engine-gpt-image-2' &&
asset.imageSrc.startsWith('data:image/png;base64,'),
) &&
Boolean(normalizedDraft.visualPackage) &&
REQUIRED_VISUAL_ASSET_KINDS.every((kind) =>
normalizedDraft.visualPackage!.assets.some(
(asset) =>
asset.assetKind === kind &&
asset.generationProvider === 'vector-engine-gpt-image-2' &&
asset.imageSrc.startsWith('data:image/png;base64,'),
),
);
const publishReady =
normalizedDraft.itemNames.every((itemName) => itemName.trim()) &&
normalizedDraft.itemAssets.every((asset) => asset.imageSrc.trim()) &&
hasBabyObjectMatchRequiredTag(normalizedDraft.themeTags);
hasBabyObjectMatchRequiredTag(normalizedDraft.themeTags) &&
hasGeneratedAssets;
const isPublished = normalizedDraft.publicationStatus === 'published';
return (
@@ -123,9 +157,15 @@ export function BabyObjectMatchResultView({
{error}
</div>
) : null}
{!hasGeneratedAssets ? (
<div className="platform-banner mt-3 rounded-2xl text-sm leading-6">
image-2
</div>
) : null}
</div>
<div className="mt-3 grid shrink-0 gap-2 pb-[max(0.25rem,env(safe-area-inset-bottom))] sm:grid-cols-3">
<div className="mt-3 grid shrink-0 gap-2 pb-[max(0.25rem,env(safe-area-inset-bottom))] sm:grid-cols-4">
<button
type="button"
disabled={isBusy || !onSaveDraft}
@@ -137,7 +177,20 @@ export function BabyObjectMatchResultView({
</button>
<button
type="button"
disabled={isBusy || !onStartTestRun}
disabled={isBusy || !onRegenerateAssets}
onClick={() => onRegenerateAssets?.(normalizedDraft)}
className="platform-button platform-button--secondary justify-center disabled:cursor-not-allowed disabled:opacity-55"
>
{isBusy ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
</button>
<button
type="button"
disabled={isBusy || !hasGeneratedAssets || !onStartTestRun}
onClick={() => onStartTestRun?.(normalizedDraft)}
className="platform-button platform-button--secondary justify-center disabled:cursor-not-allowed disabled:opacity-55"
>

View File

@@ -0,0 +1,246 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import { BabyLoveDrawingRuntimeShell } from './BabyLoveDrawingRuntimeShell';
const saveBabyLoveDrawingMock = vi.fn();
const createBabyLoveDrawingMagicImageMock = vi.fn();
const mocapMock = vi.hoisted(() => ({
command: null as null | {
actions: string[];
leftHand?: {
x: number;
y: number;
state: 'open_palm' | 'grab' | 'unknown';
side: 'left';
} | null;
rightHand?: {
x: number;
y: number;
state: 'open_palm' | 'grab' | 'unknown';
side: 'right';
} | null;
},
}));
vi.mock('../../services/useMocapInput', () => ({
useMocapInput: () => ({
status: 'idle',
latestCommand: mocapMock.command,
rawPacketPreview: null,
error: null,
}),
}));
vi.mock('../../services/edutainment-baby-drawing', () => ({
createBabyLoveDrawingMagicImage: (...args: unknown[]) =>
createBabyLoveDrawingMagicImageMock(...args),
saveBabyLoveDrawing: (...args: unknown[]) => saveBabyLoveDrawingMock(...args),
}));
function installCanvasMock() {
const context = {
save: vi.fn(),
restore: vi.fn(),
beginPath: vi.fn(),
moveTo: vi.fn(),
lineTo: vi.fn(),
stroke: vi.fn(),
fillRect: vi.fn(),
drawImage: vi.fn(),
set fillStyle(_value: string) {},
set strokeStyle(_value: string) {},
set lineWidth(_value: number) {},
set lineCap(_value: CanvasLineCap) {},
set lineJoin(_value: CanvasLineJoin) {},
set globalCompositeOperation(_value: GlobalCompositeOperation) {},
} as unknown as CanvasRenderingContext2D;
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(context);
vi.spyOn(HTMLCanvasElement.prototype, 'toDataURL').mockReturnValue(
'data:image/png;base64,original',
);
}
beforeEach(() => {
installCanvasMock();
mocapMock.command = null;
saveBabyLoveDrawingMock.mockReturnValue({
record: {
drawingId: 'baby-love-drawing-local-1',
templateId: 'baby-love-drawing',
templateName: '宝贝爱画',
originalImageSrc: 'data:image/png;base64,original',
magicImageSrc: null,
strokeTrace: [],
saveMode: 'original-only',
themeTags: ['寓教于乐', '宝贝爱画'],
createdAt: '2026-05-13T08:00:00.000Z',
updatedAt: '2026-05-13T08:00:00.000Z',
},
});
createBabyLoveDrawingMagicImageMock.mockResolvedValue({
magicImageSrc: 'data:image/png;base64,magic',
generationProvider: 'vector-engine-gpt-image-2',
prompt: '绘本风格',
});
});
afterEach(() => {
vi.restoreAllMocks();
});
test('renders drawing board, seven colors and tool buttons', () => {
render(<BabyLoveDrawingRuntimeShell />);
expect(screen.getByTestId('baby-love-drawing-runtime')).toBeTruthy();
expect(screen.getByLabelText('画板')).toBeTruthy();
expect(screen.getByLabelText('红')).toBeTruthy();
expect(screen.getByLabelText('紫')).toBeTruthy();
expect(screen.getAllByRole('button')).toHaveLength(11);
expect(screen.getByLabelText('画笔')).toBeTruthy();
expect(screen.getByLabelText('橡皮')).toBeTruthy();
});
test('finish then save stores original drawing in local demo service', () => {
render(<BabyLoveDrawingRuntimeShell />);
fireEvent.click(screen.getByRole('button', { name: '完成' }));
fireEvent.click(screen.getByRole('button', { name: '保存' }));
expect(saveBabyLoveDrawingMock).toHaveBeenCalledWith(
expect.objectContaining({
originalImageSrc: 'data:image/png;base64,original',
magicImageSrc: null,
}),
);
expect(screen.getByText('已保存')).toBeTruthy();
});
test('back button calls onBack callback', () => {
const onBack = vi.fn();
render(<BabyLoveDrawingRuntimeShell onBack={onBack} />);
fireEvent.click(screen.getByLabelText('返回'));
expect(onBack).toHaveBeenCalledTimes(1);
});
test('mocap camera-left hand drives the player right hand brush cursor', () => {
mocapMock.command = {
actions: [],
leftHand: { x: 0.72, y: 0.34, state: 'open_palm', side: 'left' },
rightHand: null,
};
const { container, rerender } = render(<BabyLoveDrawingRuntimeShell />);
const cursor = container.querySelector(
'.baby-love-drawing-runtime__cursor',
) as HTMLElement;
expect(cursor.style.left).toBe('72%');
expect(cursor.style.top).toBe('34%');
mocapMock.command = {
actions: [],
leftHand: null,
rightHand: { x: 0.18, y: 0.82, state: 'grab', side: 'right' },
};
rerender(<BabyLoveDrawingRuntimeShell />);
expect(cursor.style.left).toBe('72%');
expect(cursor.style.top).toBe('34%');
});
test('mocap camera-right hand renders the player left hand color indicator', () => {
mocapMock.command = {
actions: [],
leftHand: null,
rightHand: { x: 0.16, y: 0.42, state: 'open_palm', side: 'right' },
};
const { container } = render(<BabyLoveDrawingRuntimeShell />);
const indicator = container.querySelector(
'.baby-love-drawing-runtime__left-hand-indicator',
) as HTMLElement;
expect(indicator).toBeTruthy();
expect(indicator.style.left).toBe('16%');
expect(indicator.style.top).toBe('42%');
});
test('left hand indicator stays visible through brief mocap hand loss', () => {
vi.useFakeTimers();
mocapMock.command = {
actions: [],
leftHand: null,
rightHand: { x: 0.16, y: 0.42, state: 'open_palm', side: 'right' },
};
const { container, rerender } = render(<BabyLoveDrawingRuntimeShell />);
vi.advanceTimersByTime(120);
mocapMock.command = {
actions: [],
leftHand: null,
rightHand: null,
};
rerender(<BabyLoveDrawingRuntimeShell />);
const indicator = container.querySelector(
'.baby-love-drawing-runtime__left-hand-indicator',
) as HTMLElement;
expect(indicator).toBeTruthy();
expect(indicator.style.left).toBe('16%');
expect(indicator.style.top).toBe('42%');
});
test('player left hand never takes over the right hand brush cursor', () => {
mocapMock.command = {
actions: [],
leftHand: { x: 0.68, y: 0.32, state: 'open_palm', side: 'left' },
rightHand: { x: 0.18, y: 0.78, state: 'open_palm', side: 'right' },
};
const { container, rerender } = render(<BabyLoveDrawingRuntimeShell />);
const cursor = container.querySelector(
'.baby-love-drawing-runtime__cursor',
) as HTMLElement;
expect(cursor.style.left).toBe('68%');
expect(cursor.style.top).toBe('32%');
mocapMock.command = {
actions: [],
leftHand: null,
rightHand: { x: 0.7, y: 0.3, state: 'grab', side: 'right' },
};
rerender(<BabyLoveDrawingRuntimeShell />);
expect(cursor.style.left).toBe('68%');
expect(cursor.style.top).toBe('32%');
});
test('large camera-left jump is rejected to prevent left hand stealing brush', () => {
mocapMock.command = {
actions: [],
leftHand: { x: 0.72, y: 0.34, state: 'open_palm', side: 'left' },
rightHand: null,
};
const { container, rerender } = render(<BabyLoveDrawingRuntimeShell />);
const cursor = container.querySelector(
'.baby-love-drawing-runtime__cursor',
) as HTMLElement;
expect(cursor.style.left).toBe('72%');
expect(cursor.style.top).toBe('34%');
mocapMock.command = {
actions: [],
leftHand: { x: 0.16, y: 0.82, state: 'grab', side: 'left' },
rightHand: null,
};
rerender(<BabyLoveDrawingRuntimeShell />);
expect(cursor.style.left).toBe('72%');
expect(cursor.style.top).toBe('34%');
});

View File

@@ -0,0 +1,932 @@
import {
ArrowLeft,
Brush,
Check,
Eraser,
ImagePlus,
RotateCcw,
Save,
Sparkles,
} from 'lucide-react';
import {
type CSSProperties,
type PointerEvent as ReactPointerEvent,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import type {
BabyLoveDrawingPoint,
BabyLoveDrawingRecord,
BabyLoveDrawingStroke,
BabyLoveDrawingTool,
} from '../../../packages/shared/src/contracts/edutainmentBabyDrawing';
import { BABY_LOVE_DRAWING_RAINBOW_COLORS } from '../../../packages/shared/src/contracts/edutainmentBabyDrawing';
import {
createBabyLoveDrawingMagicImage,
saveBabyLoveDrawing,
} from '../../services/edutainment-baby-drawing';
import type { MocapHandInput } from '../../services/useMocapInput';
import { useMocapInput } from '../../services/useMocapInput';
import {
appendPointToStroke,
BABY_LOVE_DRAWING_BRUSH_SIZE,
BABY_LOVE_DRAWING_DEFAULT_COLOR,
BABY_LOVE_DRAWING_ERASER_SIZE,
type BabyLoveDrawingBounds,
type BabyLoveDrawingHandPoint,
type BabyLoveDrawingHoverTarget,
type BabyLoveDrawingPhase,
createBabyDrawingStroke,
hasHoverCompleted,
isPointInsideBounds,
resolveHoverProgress,
toCanvasPoint,
} from './babyLoveDrawingModel';
type BabyLoveDrawingRuntimeShellProps = {
onBack?: () => void;
};
type ActionButtonId = 'finish' | 'magic' | 'save' | 'restart' | 'back';
type RectMap = {
canvas: BabyLoveDrawingBounds | null;
colors: Record<string, BabyLoveDrawingBounds>;
tools: Record<BabyLoveDrawingTool, BabyLoveDrawingBounds | null>;
buttons: Record<ActionButtonId, BabyLoveDrawingBounds | null>;
};
type ActiveStrokeState = {
stroke: BabyLoveDrawingStroke;
lastPoint: BabyLoveDrawingPoint;
};
const BABY_LOVE_DRAWING_HAND_LOSS_GRACE_MS = 320;
const BABY_LOVE_DRAWING_HAND_SMOOTHING = 0.38;
const BABY_LOVE_DRAWING_RIGHT_HAND_MAX_FRAME_JUMP = 0.28;
const EMPTY_RECT_MAP: RectMap = {
canvas: null,
colors: {},
tools: {
brush: null,
eraser: null,
},
buttons: {
finish: null,
magic: null,
save: null,
restart: null,
back: null,
},
};
function pointFromPointer(
event: ReactPointerEvent<HTMLElement>,
element: HTMLElement,
): BabyLoveDrawingHandPoint {
const rect = element.getBoundingClientRect();
const width = rect.width || 1;
const height = rect.height || 1;
return {
x: Math.max(0, Math.min(1, (event.clientX - rect.left) / width)),
y: Math.max(0, Math.min(1, (event.clientY - rect.top) / height)),
state: event.buttons ? 'grab' : 'open_palm',
};
}
function handToPoint(hand: MocapHandInput | null | undefined) {
if (!hand) {
return null;
}
return {
x: hand.x,
y: hand.y,
state: hand.state,
} satisfies BabyLoveDrawingHandPoint;
}
function commandToPlayerLeftHand(command: {
rightHand?: MocapHandInput | null;
}) {
// 本地 mocap handedness 当前按摄像头视角输出:画面右侧手对应用户身体左手。
return handToPoint(command.rightHand);
}
function commandToPlayerRightHand(command: {
leftHand?: MocapHandInput | null;
}) {
// 本地 mocap handedness 当前按摄像头视角输出:画面左侧手对应用户身体右手。
return handToPoint(command.leftHand);
}
function smoothHandPoint(
previous: BabyLoveDrawingHandPoint | null,
next: BabyLoveDrawingHandPoint,
): BabyLoveDrawingHandPoint {
if (!previous) {
return next;
}
return {
x:
previous.x +
(next.x - previous.x) * BABY_LOVE_DRAWING_HAND_SMOOTHING,
y:
previous.y +
(next.y - previous.y) * BABY_LOVE_DRAWING_HAND_SMOOTHING,
state: next.state,
};
}
function getHandPointDistance(
left: BabyLoveDrawingHandPoint,
right: BabyLoveDrawingHandPoint,
) {
return Math.hypot(left.x - right.x, left.y - right.y);
}
function canAcceptRightHandPoint(
previous: BabyLoveDrawingHandPoint | null,
next: BabyLoveDrawingHandPoint | null,
) {
if (!next || !previous) {
return Boolean(next);
}
return (
getHandPointDistance(previous, next) <=
BABY_LOVE_DRAWING_RIGHT_HAND_MAX_FRAME_JUMP
);
}
function sameHoverTarget(
left: BabyLoveDrawingHoverTarget,
right: BabyLoveDrawingHoverTarget,
) {
if (!left || !right) {
return left === right;
}
return left.kind === right.kind && left.id === right.id;
}
function findTargetInBounds<T extends string>(
point: BabyLoveDrawingHandPoint | null,
bounds: Record<T, BabyLoveDrawingBounds | null>,
): T | null {
if (!point) {
return null;
}
for (const [id, rect] of Object.entries(bounds) as Array<
[T, BabyLoveDrawingBounds | null]
>) {
if (rect && isPointInsideBounds(point, rect)) {
return id;
}
}
return null;
}
function drawStrokeSegment(
context: CanvasRenderingContext2D,
stroke: BabyLoveDrawingStroke,
from: BabyLoveDrawingPoint,
to: BabyLoveDrawingPoint,
width: number,
height: number,
) {
context.save();
context.lineCap = 'round';
context.lineJoin = 'round';
context.lineWidth =
stroke.tool === 'brush'
? BABY_LOVE_DRAWING_BRUSH_SIZE
: BABY_LOVE_DRAWING_ERASER_SIZE;
if (stroke.tool === 'eraser') {
context.globalCompositeOperation = 'destination-out';
context.strokeStyle = 'rgba(0,0,0,1)';
} else {
context.globalCompositeOperation = 'source-over';
context.strokeStyle = stroke.color;
}
context.beginPath();
context.moveTo(from.x * width, from.y * height);
context.lineTo(to.x * width, to.y * height);
context.stroke();
context.restore();
}
export function BabyLoveDrawingRuntimeShell({
onBack,
}: BabyLoveDrawingRuntimeShellProps) {
const shellRef = useRef<HTMLElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const rectMapRef = useRef<RectMap>(EMPTY_RECT_MAP);
const activeStrokeRef = useRef<ActiveStrokeState | null>(null);
const hoverTargetRef = useRef<BabyLoveDrawingHoverTarget>(null);
const hoverStartedAtRef = useRef<number | null>(null);
const hoverCompletedKeyRef = useRef<string | null>(null);
const previousToolGrabRef = useRef<string | null>(null);
const visibleLeftHandRef = useRef<BabyLoveDrawingHandPoint | null>(null);
const visibleRightHandRef = useRef<BabyLoveDrawingHandPoint | null>(null);
const leftHandSeenAtRef = useRef<number | null>(null);
const rightHandSeenAtRef = useRef<number | null>(null);
const [phase, setPhase] = useState<BabyLoveDrawingPhase>('drawing');
const [selectedColor, setSelectedColor] = useState<string>(
BABY_LOVE_DRAWING_DEFAULT_COLOR,
);
const [selectedTool, setSelectedTool] =
useState<BabyLoveDrawingTool>('brush');
const [strokes, setStrokes] = useState<BabyLoveDrawingStroke[]>([]);
const [rightHandPoint, setRightHandPoint] =
useState<BabyLoveDrawingHandPoint | null>(null);
const [leftHandPoint, setLeftHandPoint] =
useState<BabyLoveDrawingHandPoint | null>(null);
const [hoverTarget, setHoverTarget] =
useState<BabyLoveDrawingHoverTarget>(null);
const [hoverProgress, setHoverProgress] = useState(0);
const [originalImageSrc, setOriginalImageSrc] = useState<string | null>(null);
const [magicImageSrc, setMagicImageSrc] = useState<string | null>(null);
const [savedRecord, setSavedRecord] = useState<BabyLoveDrawingRecord | null>(
null,
);
const [error, setError] = useState<string | null>(null);
const { latestCommand } = useMocapInput({ enabled: true });
const canUseMagic = phase === 'finished' || phase === 'magicReady';
const canSave = phase === 'finished' || phase === 'magicReady';
const actionButtons = useMemo(
() => [
{
id: 'finish' as const,
label: '完成',
icon: Check,
visible: phase === 'drawing',
},
{
id: 'magic' as const,
label: phase === 'magicPending' ? '魔法中' : '使用绘画魔法',
icon: Sparkles,
visible: phase === 'finished' || phase === 'magicReady' || phase === 'magicPending',
},
{
id: 'save' as const,
label: '保存',
icon: Save,
visible: canSave,
},
{
id: 'restart' as const,
label: '再画一张',
icon: RotateCcw,
visible: phase === 'saved',
},
{
id: 'back' as const,
label: '返回',
icon: ArrowLeft,
visible: phase === 'saved',
},
],
[canSave, phase],
);
const updateRectMap = useCallback(() => {
const shell = shellRef.current;
if (!shell) {
return;
}
const shellRect = shell.getBoundingClientRect();
const toUnitBounds = (element: Element | null): BabyLoveDrawingBounds | null => {
if (!(element instanceof HTMLElement)) {
return null;
}
const rect = element.getBoundingClientRect();
return {
left: (rect.left - shellRect.left) / shellRect.width,
top: (rect.top - shellRect.top) / shellRect.height,
width: rect.width / shellRect.width,
height: rect.height / shellRect.height,
};
};
const colors: Record<string, BabyLoveDrawingBounds> = {};
BABY_LOVE_DRAWING_RAINBOW_COLORS.forEach((color) => {
const rect = toUnitBounds(
shell.querySelector(`[data-baby-drawing-color="${color.id}"]`),
);
if (rect) {
colors[color.id] = rect;
}
});
rectMapRef.current = {
canvas: toUnitBounds(shell.querySelector('[data-baby-drawing-canvas]')),
colors,
tools: {
brush: toUnitBounds(shell.querySelector('[data-baby-drawing-tool="brush"]')),
eraser: toUnitBounds(shell.querySelector('[data-baby-drawing-tool="eraser"]')),
},
buttons: {
finish: toUnitBounds(shell.querySelector('[data-baby-drawing-button="finish"]')),
magic: toUnitBounds(shell.querySelector('[data-baby-drawing-button="magic"]')),
save: toUnitBounds(shell.querySelector('[data-baby-drawing-button="save"]')),
restart: toUnitBounds(shell.querySelector('[data-baby-drawing-button="restart"]')),
back: toUnitBounds(shell.querySelector('[data-baby-drawing-button="back"]')),
},
};
}, []);
useEffect(() => {
updateRectMap();
window.addEventListener('resize', updateRectMap);
return () => window.removeEventListener('resize', updateRectMap);
}, [updateRectMap]);
useEffect(() => {
updateRectMap();
}, [actionButtons, phase, updateRectMap]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) {
return;
}
const resizeCanvas = () => {
const rect = canvas.getBoundingClientRect();
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const nextWidth = Math.max(1, Math.floor(rect.width * dpr));
const nextHeight = Math.max(1, Math.floor(rect.height * dpr));
if (canvas.width === nextWidth && canvas.height === nextHeight) {
return;
}
const previousImage = canvas.toDataURL('image/png');
canvas.width = nextWidth;
canvas.height = nextHeight;
const context = canvas.getContext('2d');
if (!context) {
return;
}
context.fillStyle = '#fffdf4';
context.fillRect(0, 0, canvas.width, canvas.height);
if (previousImage) {
const image = new Image();
image.onload = () => {
context.drawImage(image, 0, 0, canvas.width, canvas.height);
};
image.src = previousImage;
}
};
resizeCanvas();
window.addEventListener('resize', resizeCanvas);
return () => window.removeEventListener('resize', resizeCanvas);
}, []);
const clearCanvas = useCallback(() => {
const canvas = canvasRef.current;
const context = canvas?.getContext('2d');
if (!canvas || !context) {
return;
}
context.globalCompositeOperation = 'source-over';
context.fillStyle = '#fffdf4';
context.fillRect(0, 0, canvas.width, canvas.height);
}, []);
useEffect(() => {
clearCanvas();
}, [clearCanvas]);
const captureOriginalImage = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) {
return null;
}
return canvas.toDataURL('image/png');
}, []);
const finishDrawing = useCallback(() => {
const imageSrc = captureOriginalImage();
if (!imageSrc) {
return;
}
activeStrokeRef.current = null;
setOriginalImageSrc(imageSrc);
setPhase('finished');
setError(null);
}, [captureOriginalImage]);
const restartDrawing = useCallback(() => {
activeStrokeRef.current = null;
hoverTargetRef.current = null;
hoverStartedAtRef.current = null;
hoverCompletedKeyRef.current = null;
setPhase('drawing');
setSelectedColor(BABY_LOVE_DRAWING_DEFAULT_COLOR);
setSelectedTool('brush');
setStrokes([]);
setOriginalImageSrc(null);
setMagicImageSrc(null);
setSavedRecord(null);
setError(null);
setHoverTarget(null);
setHoverProgress(0);
clearCanvas();
}, [clearCanvas]);
const saveCurrentDrawing = useCallback(() => {
const imageSrc = originalImageSrc ?? captureOriginalImage();
if (!imageSrc) {
return;
}
const response = saveBabyLoveDrawing({
originalImageSrc: imageSrc,
magicImageSrc,
strokeTrace: strokes,
});
setOriginalImageSrc(imageSrc);
setSavedRecord(response.record);
setPhase('saved');
setError(null);
}, [captureOriginalImage, magicImageSrc, originalImageSrc, strokes]);
const generateMagicImage = useCallback(async () => {
const imageSrc = originalImageSrc ?? captureOriginalImage();
if (!imageSrc || phase === 'magicPending') {
return;
}
setOriginalImageSrc(imageSrc);
setPhase('magicPending');
setError(null);
try {
const response = await createBabyLoveDrawingMagicImage({
originalImageSrc: imageSrc,
strokeTrace: strokes,
});
setMagicImageSrc(response.magicImageSrc);
setPhase('magicReady');
} catch (magicError) {
setError(
magicError instanceof Error
? magicError.message
: '生成宝贝爱画魔法图片失败。',
);
setPhase('finished');
}
}, [captureOriginalImage, originalImageSrc, phase, strokes]);
const triggerButton = useCallback(
(buttonId: string) => {
if (buttonId === 'finish' && phase === 'drawing') {
finishDrawing();
return;
}
if (buttonId === 'magic' && canUseMagic) {
void generateMagicImage();
return;
}
if (buttonId === 'save' && canSave) {
saveCurrentDrawing();
return;
}
if (buttonId === 'restart' && phase === 'saved') {
restartDrawing();
return;
}
if (buttonId === 'back' && phase === 'saved') {
onBack?.();
}
},
[
canSave,
canUseMagic,
finishDrawing,
generateMagicImage,
onBack,
phase,
restartDrawing,
saveCurrentDrawing,
],
);
const applyHoverTarget = useCallback(
(nextTarget: BabyLoveDrawingHoverTarget) => {
const currentTarget = hoverTargetRef.current;
const now = Date.now();
if (!sameHoverTarget(currentTarget, nextTarget)) {
hoverTargetRef.current = nextTarget;
hoverStartedAtRef.current = nextTarget ? now : null;
hoverCompletedKeyRef.current = null;
setHoverTarget(nextTarget);
setHoverProgress(0);
return;
}
const startedAt = hoverStartedAtRef.current;
const progress = resolveHoverProgress(nextTarget, startedAt, now);
setHoverProgress(progress);
if (!hasHoverCompleted(nextTarget, startedAt, now) || !nextTarget) {
return;
}
const completeKey = `${nextTarget.kind}:${nextTarget.id}`;
if (hoverCompletedKeyRef.current === completeKey) {
return;
}
hoverCompletedKeyRef.current = completeKey;
if (nextTarget.kind === 'color') {
const color = BABY_LOVE_DRAWING_RAINBOW_COLORS.find(
(item) => item.id === nextTarget.id,
);
if (color) {
setSelectedColor(color.value);
setSelectedTool('brush');
}
return;
}
triggerButton(nextTarget.id);
},
[triggerButton],
);
const updateToolFromRightHand = useCallback((point: BabyLoveDrawingHandPoint | null) => {
if (!point || point.state !== 'grab') {
previousToolGrabRef.current = null;
return;
}
const tool = findTargetInBounds(point, rectMapRef.current.tools);
if (!tool) {
previousToolGrabRef.current = null;
return;
}
if (previousToolGrabRef.current === tool) {
return;
}
previousToolGrabRef.current = tool;
setSelectedTool(tool);
}, []);
const drawWithRightHand = useCallback(
(point: BabyLoveDrawingHandPoint | null) => {
const canvasBounds = rectMapRef.current.canvas;
const canvas = canvasRef.current;
const context = canvas?.getContext('2d');
if (
phase !== 'drawing' ||
!point ||
point.state !== 'grab' ||
!canvasBounds ||
!canvas ||
!context ||
!isPointInsideBounds(point, canvasBounds)
) {
activeStrokeRef.current = null;
return;
}
const nextPoint = toCanvasPoint(point, canvasBounds);
const activeStroke = activeStrokeRef.current;
if (!activeStroke) {
const stroke = createBabyDrawingStroke(
selectedTool,
selectedColor,
nextPoint,
);
activeStrokeRef.current = {
stroke,
lastPoint: nextPoint,
};
setStrokes((current) => [...current, stroke]);
return;
}
const nextStroke = appendPointToStroke(activeStroke.stroke, nextPoint);
drawStrokeSegment(
context,
nextStroke,
activeStroke.lastPoint,
nextPoint,
canvas.width,
canvas.height,
);
activeStrokeRef.current = {
stroke: nextStroke,
lastPoint: nextPoint,
};
setStrokes((current) =>
current.map((stroke) =>
stroke.strokeId === nextStroke.strokeId ? nextStroke : stroke,
),
);
},
[phase, selectedColor, selectedTool],
);
const updateInteraction = useCallback(
(
nextLeftHand: BabyLoveDrawingHandPoint | null,
nextRightHand: BabyLoveDrawingHandPoint | null,
) => {
const now = Date.now();
const previousLeftHand = visibleLeftHandRef.current;
const previousRightHand = visibleRightHandRef.current;
const acceptedRightHand = canAcceptRightHandPoint(
previousRightHand,
nextRightHand,
)
? nextRightHand
: null;
const visibleLeftHand = nextLeftHand
? smoothHandPoint(previousLeftHand, nextLeftHand)
: previousLeftHand &&
leftHandSeenAtRef.current !== null &&
now - leftHandSeenAtRef.current <=
BABY_LOVE_DRAWING_HAND_LOSS_GRACE_MS
? previousLeftHand
: null;
const visibleRightHand = acceptedRightHand
? smoothHandPoint(previousRightHand, acceptedRightHand)
: previousRightHand &&
rightHandSeenAtRef.current !== null &&
now - rightHandSeenAtRef.current <=
BABY_LOVE_DRAWING_HAND_LOSS_GRACE_MS
? previousRightHand
: null;
const activeRightHand = acceptedRightHand ? visibleRightHand : null;
if (nextLeftHand) {
leftHandSeenAtRef.current = now;
}
if (acceptedRightHand) {
rightHandSeenAtRef.current = now;
}
visibleLeftHandRef.current = visibleLeftHand;
visibleRightHandRef.current = visibleRightHand;
setLeftHandPoint(visibleLeftHand);
setRightHandPoint(visibleRightHand);
updateToolFromRightHand(activeRightHand);
drawWithRightHand(activeRightHand);
const colorId = findTargetInBounds(
visibleLeftHand,
rectMapRef.current.colors,
);
const buttonId =
findTargetInBounds(visibleLeftHand, rectMapRef.current.buttons) ??
findTargetInBounds(visibleRightHand, rectMapRef.current.buttons);
const nextHoverTarget: BabyLoveDrawingHoverTarget = colorId
? { kind: 'color', id: colorId }
: buttonId
? { kind: 'button', id: buttonId }
: null;
applyHoverTarget(nextHoverTarget);
},
[applyHoverTarget, drawWithRightHand, updateToolFromRightHand],
);
useEffect(() => {
if (!latestCommand) {
return;
}
updateInteraction(
commandToPlayerLeftHand(latestCommand),
commandToPlayerRightHand(latestCommand),
);
}, [latestCommand, updateInteraction]);
const handlePointerDown = (event: ReactPointerEvent<HTMLElement>) => {
const point = pointFromPointer(event, event.currentTarget);
if (event.button === 2) {
updateInteraction(leftHandPoint, { ...point, state: 'grab' as const });
return;
}
updateInteraction({ ...point, state: 'open_palm' as const }, null);
};
const handlePointerMove = (event: ReactPointerEvent<HTMLElement>) => {
const point = pointFromPointer(event, event.currentTarget);
const nextState: BabyLoveDrawingHandPoint['state'] = event.buttons
? 'grab'
: 'open_palm';
const nextPoint: BabyLoveDrawingHandPoint = {
...point,
state: nextState,
};
if (event.buttons === 2) {
updateInteraction(leftHandPoint, nextPoint);
return;
}
updateInteraction(nextPoint, null);
};
const handlePointerUp = (event: ReactPointerEvent<HTMLElement>) => {
const point = pointFromPointer(event, event.currentTarget);
if (event.button === 2) {
updateInteraction(leftHandPoint, { ...point, state: 'open_palm' as const });
return;
}
updateInteraction({ ...point, state: 'open_palm' as const }, null);
};
return (
<main
ref={shellRef}
className="baby-love-drawing-runtime"
data-testid="baby-love-drawing-runtime"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
onContextMenu={(event) => event.preventDefault()}
>
<button
type="button"
className="baby-love-drawing-runtime__back"
onClick={onBack}
aria-label="返回"
title="返回"
>
<ArrowLeft className="h-5 w-5" />
</button>
<div className="baby-love-drawing-runtime__colors" aria-label="颜色">
{BABY_LOVE_DRAWING_RAINBOW_COLORS.map((color) => (
<button
key={color.id}
type="button"
data-baby-drawing-color={color.id}
className={`baby-love-drawing-runtime__color${
selectedColor === color.value
? ' baby-love-drawing-runtime__color--active'
: ''
}`}
style={{ '--baby-drawing-color': color.value } as CSSProperties}
aria-label={color.label}
title={color.label}
/>
))}
</div>
<section className="baby-love-drawing-runtime__board">
<canvas
ref={canvasRef}
data-baby-drawing-canvas
className="baby-love-drawing-runtime__canvas"
aria-label="画板"
/>
{magicImageSrc && phase !== 'drawing' ? (
<img
src={magicImageSrc}
alt="绘画魔法结果"
className="baby-love-drawing-runtime__magic-image"
/>
) : null}
{phase === 'magicPending' ? (
<div className="baby-love-drawing-runtime__magic-pending">
<Sparkles className="h-7 w-7" />
</div>
) : null}
</section>
<div className="baby-love-drawing-runtime__tools" aria-label="工具">
<button
type="button"
data-baby-drawing-tool="brush"
className={`baby-love-drawing-runtime__tool${
selectedTool === 'brush'
? ' baby-love-drawing-runtime__tool--active'
: ''
}`}
aria-label="画笔"
title="画笔"
>
<Brush className="h-7 w-7" />
</button>
<button
type="button"
data-baby-drawing-tool="eraser"
className={`baby-love-drawing-runtime__tool${
selectedTool === 'eraser'
? ' baby-love-drawing-runtime__tool--active'
: ''
}`}
aria-label="橡皮"
title="橡皮"
>
<Eraser className="h-7 w-7" />
</button>
</div>
<div className="baby-love-drawing-runtime__actions">
{actionButtons
.filter((button) => button.visible)
.map((button) => {
const Icon = button.icon;
const isHovering =
hoverTarget?.kind === 'button' && hoverTarget.id === button.id;
return (
<button
key={button.id}
type="button"
data-baby-drawing-button={button.id}
className="baby-love-drawing-runtime__action"
disabled={button.id === 'magic' && phase === 'magicPending'}
onClick={() => triggerButton(button.id)}
>
<Icon className="h-4 w-4" />
<span>{button.label}</span>
{isHovering ? (
<span
className="baby-love-drawing-runtime__action-progress"
style={
{
'--baby-drawing-hover-progress': `${hoverProgress * 100}%`,
} as CSSProperties
}
/>
) : null}
</button>
);
})}
</div>
{error ? (
<div className="baby-love-drawing-runtime__error">{error}</div>
) : null}
{savedRecord ? (
<div className="baby-love-drawing-runtime__saved" role="status">
<ImagePlus className="h-5 w-5" />
</div>
) : null}
{leftHandPoint ? (
<div
className="baby-love-drawing-runtime__left-hand-indicator"
aria-hidden="true"
style={
{
left: `${leftHandPoint.x * 100}%`,
top: `${leftHandPoint.y * 100}%`,
} as CSSProperties
}
>
<span />
</div>
) : null}
<div
className={`baby-love-drawing-runtime__cursor baby-love-drawing-runtime__cursor--${selectedTool}`}
style={
{
left: `${(rightHandPoint?.x ?? 0.5) * 100}%`,
top: `${(rightHandPoint?.y ?? 0.5) * 100}%`,
'--baby-drawing-color': selectedColor,
} as CSSProperties
}
>
{selectedTool === 'brush' ? (
<Brush className="h-5 w-5" />
) : (
<Eraser className="h-5 w-5" />
)}
</div>
</main>
);
}
export default BabyLoveDrawingRuntimeShell;

View File

@@ -1,6 +1,6 @@
/* @vitest-environment jsdom */
import { act, fireEvent, render, screen, within } from '@testing-library/react';
import { act, render, screen, within } from '@testing-library/react';
import { expect, test, vi } from 'vitest';
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
@@ -17,11 +17,21 @@ vi.mock('../ResolvedAssetImage', () => ({
src,
alt,
className,
'data-testid': dataTestId,
}: {
src?: string | null;
alt?: string;
className?: string;
}) => (src ? <img src={src} alt={alt} className={className} /> : null),
'data-testid'?: string;
}) =>
src ? (
<img
src={src}
alt={alt}
className={className}
data-testid={dataTestId}
/>
) : null,
}));
vi.mock('../../services/useMocapInput', () => ({
@@ -60,6 +70,7 @@ function createDraft(): BabyObjectMatchDraft {
prompt: '香蕉',
},
],
visualPackage: null,
themeTags: [BABY_OBJECT_MATCH_EDUTAINMENT_TAG, '宝贝识物'],
publicationStatus: 'published',
createdAt: '2026-05-11T00:00:00.000Z',
@@ -68,6 +79,57 @@ function createDraft(): BabyObjectMatchDraft {
};
}
function createVisualPackageDraft(): BabyObjectMatchDraft {
return {
...createDraft(),
visualPackage: {
themePrompt: '果园主题',
assets: [
{
assetId: 'baby-object-visual-background',
assetKind: 'background',
imageSrc: 'data:image/png;base64,background',
assetObjectId: null,
generationProvider: 'vector-engine-gpt-image-2',
prompt: '背景',
},
{
assetId: 'baby-object-visual-ui-frame',
assetKind: 'ui-frame',
imageSrc: 'data:image/png;base64,ui',
assetObjectId: null,
generationProvider: 'vector-engine-gpt-image-2',
prompt: 'UI',
},
{
assetId: 'baby-object-visual-gift-box',
assetKind: 'gift-box',
imageSrc: 'data:image/png;base64,gift',
assetObjectId: null,
generationProvider: 'vector-engine-gpt-image-2',
prompt: '礼盒',
},
{
assetId: 'baby-object-visual-basket',
assetKind: 'basket',
imageSrc: 'data:image/png;base64,basket',
assetObjectId: null,
generationProvider: 'vector-engine-gpt-image-2',
prompt: '篮子',
},
{
assetId: 'baby-object-visual-smoke-puff',
assetKind: 'smoke-puff',
imageSrc: 'data:image/png;base64,smoke',
assetObjectId: null,
generationProvider: 'vector-engine-gpt-image-2',
prompt: '烟雾',
},
],
},
};
}
function createMocapInput(
overrides: Partial<UseMocapInputResult> = {},
): UseMocapInputResult {
@@ -146,7 +208,26 @@ function dragHand(stage: HTMLElement, button: 0 | 2) {
});
}
test('opens the gift box with F and shows the next item', () => {
async function advanceRoundIntro() {
await act(async () => {
await vi.advanceTimersByTimeAsync(620);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(640);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(620);
});
}
async function advanceFeedback() {
await act(async () => {
await vi.advanceTimersByTimeAsync(1200);
});
}
test('shows the first gift item after gift and item animations', async () => {
vi.useFakeTimers();
render(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
@@ -154,23 +235,92 @@ test('opens the gift box with F and shows the next item', () => {
/>,
);
expect(
within(screen.getByTestId('baby-object-current-item')).queryByAltText(
'苹果',
),
).toBeNull();
fireEvent.keyDown(window, { key: 'f', code: 'KeyF' });
expect(screen.getByText('将物品放入对应的篮子里')).toBeTruthy();
expect(screen.getByTestId('baby-object-current-item').textContent).toBe('');
await advanceRoundIntro();
expect(
within(screen.getByTestId('baby-object-current-item')).getByAltText(
'苹果',
),
within(screen.getByTestId('baby-object-current-item')).getByAltText('苹果'),
).toBeTruthy();
vi.useRealTimers();
});
test('keeps left and right baskets fixed while only the gift item is random', () => {
test('applies generated visual package to stage, gift box, baskets, smoke and hud', async () => {
vi.useFakeTimers();
const { container } = render(
<BabyObjectMatchRuntimeShell draft={createVisualPackageDraft()} />,
);
const stage = container.querySelector('.baby-object-runtime__stage');
if (!(stage instanceof HTMLElement)) {
throw new Error('Missing baby object runtime stage');
}
expect(stage.classList.contains('baby-object-runtime__stage--skinned')).toBe(
true,
);
expect(
screen
.getByTestId('baby-object-background-image')
.getAttribute('src'),
).toBe('data:image/png;base64,background');
expect(
stage.style.getPropertyValue('--baby-object-ui-frame-image'),
).toContain('ui');
expect(stage.style.getPropertyValue('--baby-object-smoke-image')).toContain(
'smoke',
);
expect(screen.getByAltText('礼物盒')).toBeTruthy();
expect(
container.querySelector('.baby-object-runtime__basket-shell-image'),
).toBeTruthy();
await act(async () => {
await vi.advanceTimersByTimeAsync(620);
});
expect(screen.getByTestId('baby-object-smoke-effect')).toBeTruthy();
vi.useRealTimers();
});
test('removes the gift box after smoke releases the current item', async () => {
vi.useFakeTimers();
render(
<BabyObjectMatchRuntimeShell
draft={createVisualPackageDraft()}
random={createRandomSequence([0])}
/>,
);
expect(screen.getByLabelText('礼物盒')).toBeTruthy();
await act(async () => {
await vi.advanceTimersByTimeAsync(620);
});
expect(screen.getByLabelText('礼物盒')).toBeTruthy();
expect(screen.getByTestId('baby-object-smoke-effect')).toBeTruthy();
await act(async () => {
await vi.advanceTimersByTimeAsync(640);
});
expect(screen.queryByLabelText('礼物盒')).toBeNull();
expect(screen.getByTestId('baby-object-smoke-effect')).toBeTruthy();
await act(async () => {
await vi.advanceTimersByTimeAsync(620);
});
expect(screen.queryByLabelText('礼物盒')).toBeNull();
expect(
within(screen.getByTestId('baby-object-current-item')).getByAltText('苹果'),
).toBeTruthy();
vi.useRealTimers();
});
test('keeps left and right baskets fixed while only the gift item is random', async () => {
vi.useFakeTimers();
render(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
@@ -178,109 +328,28 @@ test('keeps left and right baskets fixed while only the gift item is random', ()
/>,
);
fireEvent.keyDown(window, { key: 'f', code: 'KeyF' });
await advanceRoundIntro();
expect(
within(screen.getByTestId('baby-object-current-item')).getByAltText(
'香蕉',
),
within(screen.getByTestId('baby-object-current-item')).getByAltText('香蕉'),
).toBeTruthy();
expect(screen.getByLabelText('左侧篮子 苹果')).toBeTruthy();
expect(screen.getByLabelText('右侧篮子 香蕉')).toBeTruthy();
vi.useRealTimers();
});
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', () => {
test('mocap camera-right hand movement sends the player left hand item into the left basket', async () => {
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 },
})}
mocapInput={createMocapInput()}
/>,
);
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 },
})}
/>,
);
await advanceRoundIntro();
rerender(
<BabyObjectMatchRuntimeShell
@@ -294,7 +363,10 @@ test('mocap camera-right hand movement sends the player left hand item into the
leftHand: null,
rightHand: { x: 0.22, y: 0.45, state: 'open_palm', side: 'right' },
},
rawPacketPreview: { text: 'camera-right-horizontal-1', receivedAtMs: 3 },
rawPacketPreview: {
text: 'camera-right-horizontal-1',
receivedAtMs: 1,
},
})}
/>,
);
@@ -311,7 +383,10 @@ test('mocap camera-right hand movement sends the player left hand item into the
leftHand: null,
rightHand: { x: 0.24, y: 0.45, state: 'open_palm', side: 'right' },
},
rawPacketPreview: { text: 'camera-right-horizontal-2', receivedAtMs: 4 },
rawPacketPreview: {
text: 'camera-right-horizontal-2',
receivedAtMs: 2,
},
})}
/>,
);
@@ -328,7 +403,10 @@ test('mocap camera-right hand movement sends the player left hand item into the
leftHand: null,
rightHand: { x: 0.22, y: 0.45, state: 'open_palm', side: 'right' },
},
rawPacketPreview: { text: 'camera-right-horizontal-3', receivedAtMs: 5 },
rawPacketPreview: {
text: 'camera-right-horizontal-3',
receivedAtMs: 3,
},
})}
/>,
);
@@ -347,7 +425,10 @@ test('mocap camera-right hand movement sends the player left hand item into the
leftHand: null,
rightHand: { x: 0.31, y: 0.45, state: 'open_palm', side: 'right' },
},
rawPacketPreview: { text: 'camera-right-horizontal-4', receivedAtMs: 6 },
rawPacketPreview: {
text: 'camera-right-horizontal-4',
receivedAtMs: 4,
},
})}
/>,
);
@@ -357,42 +438,18 @@ test('mocap camera-right hand movement sends the player left hand item into the
vi.useRealTimers();
});
test('mocap camera-left hand movement sends the player right hand item into the right basket', () => {
test('mocap camera-left hand movement sends the player right hand item into the right basket', async () => {
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 },
})}
mocapInput={createMocapInput()}
/>,
);
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 },
})}
/>,
);
await advanceRoundIntro();
rerender(
<BabyObjectMatchRuntimeShell
@@ -406,7 +463,7 @@ test('mocap camera-left hand movement sends the player right hand item into the
leftHand: { x: 0.82, y: 0.45, state: 'open_palm', side: 'left' },
rightHand: null,
},
rawPacketPreview: { text: 'camera-left-horizontal-1', receivedAtMs: 3 },
rawPacketPreview: { text: 'camera-left-horizontal-1', receivedAtMs: 1 },
})}
/>,
);
@@ -423,7 +480,7 @@ test('mocap camera-left hand movement sends the player right hand item into the
leftHand: { x: 0.8, y: 0.45, state: 'open_palm', side: 'left' },
rightHand: null,
},
rawPacketPreview: { text: 'camera-left-horizontal-2', receivedAtMs: 4 },
rawPacketPreview: { text: 'camera-left-horizontal-2', receivedAtMs: 2 },
})}
/>,
);
@@ -440,7 +497,7 @@ test('mocap camera-left hand movement sends the player right hand item into the
leftHand: { x: 0.82, y: 0.45, state: 'open_palm', side: 'left' },
rightHand: null,
},
rawPacketPreview: { text: 'camera-left-horizontal-3', receivedAtMs: 5 },
rawPacketPreview: { text: 'camera-left-horizontal-3', receivedAtMs: 3 },
})}
/>,
);
@@ -459,7 +516,7 @@ test('mocap camera-left hand movement sends the player right hand item into the
leftHand: { x: 0.73, y: 0.45, state: 'open_palm', side: 'left' },
rightHand: null,
},
rawPacketPreview: { text: 'camera-left-horizontal-4', receivedAtMs: 6 },
rawPacketPreview: { text: 'camera-left-horizontal-4', receivedAtMs: 4 },
})}
/>,
);
@@ -469,41 +526,18 @@ test('mocap camera-left hand movement sends the player right hand item into the
vi.useRealTimers();
});
test('mocap action names do not select a basket without horizontal hand movement', () => {
test('mocap action names do not select a basket without horizontal hand movement', async () => {
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-left', receivedAtMs: 1 },
})}
mocapInput={createMocapInput()}
/>,
);
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 },
})}
/>,
);
await advanceRoundIntro();
rerender(
<BabyObjectMatchRuntimeShell
@@ -517,7 +551,7 @@ test('mocap action names do not select a basket without horizontal hand movement
leftHand: { x: 0.5, y: 0.5, state: 'open_palm', side: 'left' },
rightHand: null,
},
rawPacketPreview: { text: 'action-only-wave', receivedAtMs: 3 },
rawPacketPreview: { text: 'action-only-wave', receivedAtMs: 1 },
})}
/>,
);
@@ -525,47 +559,23 @@ test('mocap action names do not select a basket without horizontal hand movement
expect(screen.queryByText('真棒')).toBeNull();
expect(screen.queryByText('再想一想吧')).toBeNull();
expect(
within(screen.getByTestId('baby-object-current-item')).getByAltText(
'苹果',
),
within(screen.getByTestId('baby-object-current-item')).getByAltText('苹果'),
).toBeTruthy();
vi.useRealTimers();
});
test('mocap unknown hand horizontal movement does not select a basket', () => {
test('mocap unknown hand horizontal movement does not select a basket', async () => {
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: 'unknown' }],
primaryHand: { x: 0.5, y: 0.5, state: 'open_palm', side: 'unknown' },
leftHand: null,
rightHand: null,
},
rawPacketPreview: { text: 'open-unknown', receivedAtMs: 1 },
})}
mocapInput={createMocapInput()}
/>,
);
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 },
})}
/>,
);
await advanceRoundIntro();
for (let index = 0; index < 4; index += 1) {
const x = [0.22, 0.24, 0.22, 0.31][index] ?? 0.22;
@@ -583,7 +593,7 @@ test('mocap unknown hand horizontal movement does not select a basket', () => {
},
rawPacketPreview: {
text: `unknown-horizontal-${index + 1}`,
receivedAtMs: index + 3,
receivedAtMs: index + 1,
},
})}
/>,
@@ -593,13 +603,12 @@ test('mocap unknown hand horizontal movement does not select a basket', () => {
expect(screen.queryByText('真棒')).toBeNull();
expect(screen.queryByText('再想一想吧')).toBeNull();
expect(
within(screen.getByTestId('baby-object-current-item')).getByAltText(
'苹果',
),
within(screen.getByTestId('baby-object-current-item')).getByAltText('苹果'),
).toBeTruthy();
vi.useRealTimers();
});
test('left hand horizontal drag sends a correct item into the left basket', () => {
test('left hand horizontal drag sends a correct item into the left basket', async () => {
vi.useFakeTimers();
const { container } = render(
<BabyObjectMatchRuntimeShell
@@ -612,26 +621,30 @@ test('left hand horizontal drag sends a correct item into the left basket', () =
throw new Error('Missing baby object runtime stage');
}
fireEvent.keyDown(window, { key: 'f', code: 'KeyF' });
await advanceRoundIntro();
dragHand(stage, 0);
expect(screen.getByText('真棒')).toBeTruthy();
expect(screen.getByLabelText('成功次数').textContent).toBe('1/20');
expect(screen.getByLabelText('左侧篮子 苹果').className).toContain(
'baby-object-runtime__basket--correct',
);
act(() => {
vi.advanceTimersByTime(800);
});
await advanceFeedback();
expect(screen.queryByText('真棒')).toBeNull();
expect(screen.getByTestId('baby-object-current-item').textContent).toBe('');
expect(screen.getByLabelText('礼物盒')).toBeTruthy();
await advanceRoundIntro();
expect(
within(screen.getByTestId('baby-object-current-item')).queryByAltText(
'苹果',
),
).toBeNull();
within(screen.getByTestId('baby-object-current-item')).getByAltText('苹果'),
).toBeTruthy();
vi.useRealTimers();
});
test('wrong basket keeps the item active after feedback', () => {
test('ignores drag input until the item animation finishes', async () => {
vi.useFakeTimers();
const { container } = render(
<BabyObjectMatchRuntimeShell
@@ -644,26 +657,86 @@ test('wrong basket keeps the item active after feedback', () => {
throw new Error('Missing baby object runtime stage');
}
fireEvent.keyDown(window, { key: 'f', code: 'KeyF' });
dragHand(stage, 0);
expect(screen.queryByText('真棒')).toBeNull();
expect(screen.getByLabelText('成功次数').textContent).toBe('0/20');
await advanceRoundIntro();
dragHand(stage, 0);
expect(screen.getByText('真棒')).toBeTruthy();
expect(screen.getByLabelText('成功次数').textContent).toBe('1/20');
vi.useRealTimers();
});
test('correct placement automatically shows the next gift item', async () => {
vi.useFakeTimers();
const { container } = render(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={createRandomSequence([0, 0.99])}
/>,
);
const stage = container.querySelector('.baby-object-runtime__stage');
if (!(stage instanceof HTMLElement)) {
throw new Error('Missing baby object runtime stage');
}
expect(screen.getByTestId('baby-object-current-item').textContent).toBe('');
await advanceRoundIntro();
expect(
within(screen.getByTestId('baby-object-current-item')).getByAltText('苹果'),
).toBeTruthy();
dragHand(stage, 0);
expect(screen.getByText('真棒')).toBeTruthy();
await advanceFeedback();
expect(screen.queryByText('真棒')).toBeNull();
expect(screen.getByTestId('baby-object-current-item').textContent).toBe('');
await advanceRoundIntro();
expect(
within(screen.getByTestId('baby-object-current-item')).getByAltText('香蕉'),
).toBeTruthy();
vi.useRealTimers();
});
test('wrong basket keeps the item active after feedback', async () => {
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');
}
await advanceRoundIntro();
dragHand(stage, 2);
expect(screen.getByText('再想一想吧')).toBeTruthy();
expect(screen.getByLabelText('成功次数').textContent).toBe('0/20');
act(() => {
vi.advanceTimersByTime(800);
});
await advanceFeedback();
expect(screen.queryByText('再想一想吧')).toBeNull();
expect(
within(screen.getByTestId('baby-object-current-item')).getByAltText(
'苹果',
),
within(screen.getByTestId('baby-object-current-item')).getByAltText('苹果'),
).toBeTruthy();
vi.useRealTimers();
});
test('twenty correct placements completes the level', () => {
test('twenty correct placements completes the level', async () => {
vi.useFakeTimers();
const randomValues = Array.from({ length: 40 }, () => 0);
const { container } = render(
@@ -678,11 +751,9 @@ test('twenty correct placements completes the level', () => {
}
for (let index = 0; index < 20; index += 1) {
fireEvent.keyDown(window, { key: 'f', code: 'KeyF' });
await advanceRoundIntro();
dragHand(stage, 0);
act(() => {
vi.advanceTimersByTime(800);
});
await advanceFeedback();
}
expect(screen.getAllByText('恭喜你!小朋友!').length).toBeGreaterThan(0);

View File

@@ -6,6 +6,7 @@ import {
SkipForward,
} from 'lucide-react';
import {
type CSSProperties,
type PointerEvent as ReactPointerEvent,
useCallback,
useEffect,
@@ -16,6 +17,8 @@ import {
import type {
BabyObjectMatchDraft,
BabyObjectMatchItemAsset,
BabyObjectMatchVisualAsset,
BabyObjectMatchVisualAssetKind,
} from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import type {
MocapHandInput,
@@ -26,7 +29,10 @@ 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_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;
@@ -41,7 +47,14 @@ type BabyObjectMatchRuntimeShellProps = {
};
type BasketSide = 'left' | 'right';
type RuntimePhase = 'waiting' | 'active' | 'correct' | 'wrong' | 'complete';
type RuntimePhase =
| 'gift-entering'
| 'gift-opening'
| 'item-appearing'
| 'active'
| 'correct'
| 'wrong'
| 'complete';
type RuntimeRound = {
item: BabyObjectMatchItemAsset;
@@ -65,23 +78,16 @@ type RuntimeMocapHandPaths = {
};
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',
];
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) {
@@ -114,10 +120,6 @@ function isHorizontalDrag(dragState: DragState) {
);
}
function hasMocapAction(command: MocapInputCommand, actions: string[]) {
return command.actions.some((action) => actions.includes(action));
}
function mocapHandToRuntimePoint(
hand: MocapHandInput | null | undefined,
): RuntimeHandPoint | null {
@@ -165,26 +167,6 @@ function resolveMocapHandPaths(
} 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 {
@@ -208,6 +190,20 @@ function buildMocapPacketKey(
: 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,
@@ -217,33 +213,92 @@ export function BabyObjectMatchRuntimeShell({
onBack,
onNextLevel,
}: BabyObjectMatchRuntimeShellProps) {
const randomRef = useRef<BabyObjectMatchRandom>(random ?? (() => Math.random()));
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 hasOpenPalmBeforeGrabRef = useRef(false);
const latestMocapPacketKeyRef = useRef<string | null>(null);
const mocapHandPathsRef = useRef<RuntimeMocapHandPaths>({
left: [],
right: [],
});
const [phase, setPhase] = useState<RuntimePhase>('waiting');
const [phase, setPhase] = useState<RuntimePhase>('gift-entering');
const [successCount, setSuccessCount] = useState(0);
const [round, setRound] = useState<RuntimeRound | null>(null);
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);
@@ -251,33 +306,65 @@ export function BabyObjectMatchRuntimeShell({
}
}, []);
const openGiftBox = useCallback(() => {
if (phase !== 'waiting') {
return;
const clearIntroTimer = useCallback(() => {
if (introTimerRef.current !== null) {
window.clearTimeout(introTimerRef.current);
introTimerRef.current = null;
}
}, []);
clearFeedbackTimer();
setFeedbackText(null);
setLastTargetSide(null);
setRound(buildRuntimeRound(draft, randomRef.current));
setPhase('active');
}, [clearFeedbackTimer, draft, phase]);
const resetRuntime = useCallback(() => {
clearFeedbackTimer();
const resetInputPaths = useCallback(() => {
dragStateRef.current = null;
handledMocapPacketKeyRef.current = null;
hasOpenPalmBeforeGrabRef.current = false;
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(null);
setRound(buildRuntimeRound(draft, randomRef.current));
setFeedbackText(null);
setLastTargetSide(null);
setPhase('waiting');
}, [clearFeedbackTimer]);
setPhase('gift-entering');
}, [clearFeedbackTimer, clearIntroTimer, draft, resetInputPaths]);
const finishFeedback = useCallback(
(nextSuccessCount: number, wasCorrect: boolean) => {
clearIntroTimer();
clearFeedbackTimer();
feedbackTimerRef.current = window.setTimeout(() => {
feedbackTimerRef.current = null;
@@ -289,25 +376,26 @@ export function BabyObjectMatchRuntimeShell({
return;
}
setRound(null);
setRound(buildRuntimeRound(draft, randomRef.current));
setFeedbackText(null);
setLastTargetSide(null);
setPhase('waiting');
resetInputPaths();
setPhase('gift-entering');
return;
}
setFeedbackText(null);
setLastTargetSide(null);
mocapHandPathsRef.current = { left: [], right: [] };
resetInputPaths();
setPhase('active');
}, BABY_OBJECT_MATCH_FEEDBACK_DURATION_MS);
},
[clearFeedbackTimer],
[clearFeedbackTimer, clearIntroTimer, draft, resetInputPaths],
);
const sendItemToBasket = useCallback(
(side: BasketSide) => {
if (phase !== 'active' || !round) {
if (!isJudgementOpen || !round) {
return;
}
@@ -326,18 +414,16 @@ export function BabyObjectMatchRuntimeShell({
setPhase('wrong');
finishFeedback(successCount, false);
},
[finishFeedback, phase, round, successCount],
[finishFeedback, isJudgementOpen, round, successCount],
);
useEffect(() => clearFeedbackTimer, [clearFeedbackTimer]);
useEffect(() => {
if (phase === 'waiting') {
mocapHandPathsRef.current = { left: [], right: [] };
return;
}
hasOpenPalmBeforeGrabRef.current = false;
}, [phase]);
useEffect(
() => () => {
clearIntroTimer();
clearFeedbackTimer();
},
[clearFeedbackTimer, clearIntroTimer],
);
useEffect(() => {
const command = resolvedMocapInput.latestCommand;
@@ -354,60 +440,28 @@ export function BabyObjectMatchRuntimeShell({
}
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;
}
if (!isJudgementOpen) {
resetInputPaths();
return;
}
if (phase !== 'active') {
mocapHandPathsRef.current = { left: [], right: [] };
return;
}
const nextPaths = resolveMocapHandPaths(
command,
mocapHandPathsRef.current,
);
const nextPaths = resolveMocapHandPaths(command, mocapHandPathsRef.current);
mocapHandPathsRef.current = nextPaths;
const targetSide = resolveMocapHorizontalMoveSide(nextPaths);
if (targetSide) {
sendItemToBasket(targetSide);
mocapHandPathsRef.current = { left: [], right: [] };
resetInputPaths();
}
}, [
isComplete,
openGiftBox,
phase,
isJudgementOpen,
resetInputPaths,
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,
@@ -418,6 +472,10 @@ export function BabyObjectMatchRuntimeShell({
};
const handlePointerDown = (event: ReactPointerEvent<HTMLElement>) => {
if (!isJudgementOpen) {
return;
}
if (event.button !== 0 && event.button !== 2) {
return;
}
@@ -436,6 +494,11 @@ export function BabyObjectMatchRuntimeShell({
};
const handlePointerMove = (event: ReactPointerEvent<HTMLElement>) => {
if (!isJudgementOpen) {
dragStateRef.current = null;
return;
}
if (!dragStateRef.current) {
return;
}
@@ -469,13 +532,26 @@ export function BabyObjectMatchRuntimeShell({
data-testid="baby-object-match-runtime"
>
<section
className="baby-object-runtime__stage"
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"
@@ -496,25 +572,65 @@ export function BabyObjectMatchRuntimeShell({
{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>
{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"
>
{currentItem ? (
{shouldShowCurrentItem ? (
<>
<ResolvedAssetImage
src={currentItem.imageSrc}
@@ -555,12 +671,17 @@ export function BabyObjectMatchRuntimeShell({
<div className="baby-object-runtime__baskets">
{(['left', 'right'] as const).map((side) => {
const basketItem = round?.baskets[side] ?? draft.itemAssets[side === 'left' ? 0 : 1];
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}`}
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">
@@ -570,7 +691,21 @@ export function BabyObjectMatchRuntimeShell({
className="baby-object-runtime__basket-image"
/>
</div>
<div className="baby-object-runtime__basket-body" />
<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>
);
})}

View File

@@ -0,0 +1,96 @@
import { describe, expect, test } from 'vitest';
import {
appendPointToStroke,
BABY_LOVE_DRAWING_BUTTON_HOVER_MS,
BABY_LOVE_DRAWING_COLOR_HOVER_MS,
createBabyDrawingStroke,
hasHoverCompleted,
isPointInsideBounds,
resolveHoverProgress,
toCanvasPoint,
} from './babyLoveDrawingModel';
describe('babyLoveDrawingModel', () => {
test('completes color hover after 1.5 seconds', () => {
const target = { kind: 'color' as const, id: 'red' };
expect(
hasHoverCompleted(
target,
1000,
1000 + BABY_LOVE_DRAWING_COLOR_HOVER_MS - 1,
),
).toBe(false);
expect(
hasHoverCompleted(
target,
1000,
1000 + BABY_LOVE_DRAWING_COLOR_HOVER_MS,
),
).toBe(true);
});
test('completes button hover after 2 seconds', () => {
const target = { kind: 'button' as const, id: 'finish' };
expect(
hasHoverCompleted(
target,
1000,
1000 + BABY_LOVE_DRAWING_BUTTON_HOVER_MS - 1,
),
).toBe(false);
expect(
hasHoverCompleted(
target,
1000,
1000 + BABY_LOVE_DRAWING_BUTTON_HOVER_MS,
),
).toBe(true);
});
test('clamps hover progress and canvas point into unit bounds', () => {
const bounds = {
left: 0.25,
top: 0.2,
width: 0.5,
height: 0.4,
};
expect(resolveHoverProgress(null, null, 1000)).toBe(0);
expect(resolveHoverProgress({ kind: 'color', id: 'red' }, 0, 999999)).toBe(
1,
);
expect(isPointInsideBounds({ x: 0.4, y: 0.3 }, bounds)).toBe(true);
expect(isPointInsideBounds({ x: 0.1, y: 0.3 }, bounds)).toBe(false);
expect(toCanvasPoint({ x: 0.5, y: 0.4 }, bounds)).toMatchObject({
x: 0.5,
y: 0.5,
});
expect(toCanvasPoint({ x: 0.9, y: 0.9 }, bounds)).toMatchObject({
x: 1,
y: 1,
});
});
test('creates and extends stroke trace without mutating previous stroke', () => {
const stroke = createBabyDrawingStroke('brush', '#ef4444', {
x: 0.1,
y: 0.2,
t: 1,
});
const nextStroke = appendPointToStroke(stroke, {
x: 0.3,
y: 0.4,
t: 2,
});
expect(stroke.points).toHaveLength(1);
expect(nextStroke.points).toHaveLength(2);
expect(nextStroke).toMatchObject({
tool: 'brush',
color: '#ef4444',
});
});
});

View File

@@ -0,0 +1,135 @@
import type {
BabyLoveDrawingPoint,
BabyLoveDrawingStroke,
BabyLoveDrawingTool,
} from '../../../packages/shared/src/contracts/edutainmentBabyDrawing';
import { BABY_LOVE_DRAWING_RAINBOW_COLORS } from '../../../packages/shared/src/contracts/edutainmentBabyDrawing';
export const BABY_LOVE_DRAWING_COLOR_HOVER_MS = 1500;
export const BABY_LOVE_DRAWING_BUTTON_HOVER_MS = 2000;
export const BABY_LOVE_DRAWING_BRUSH_SIZE = 10;
export const BABY_LOVE_DRAWING_ERASER_SIZE = 30;
export type BabyLoveDrawingPhase =
| 'drawing'
| 'finished'
| 'magicPending'
| 'magicReady'
| 'saved';
export type BabyLoveDrawingHoverTarget =
| {
kind: 'color';
id: string;
}
| {
kind: 'button';
id: string;
}
| null;
export type BabyLoveDrawingHandPoint = {
x: number;
y: number;
state: 'open_palm' | 'grab' | 'unknown';
};
export type BabyLoveDrawingBounds = {
left: number;
top: number;
width: number;
height: number;
};
export const BABY_LOVE_DRAWING_DEFAULT_COLOR =
BABY_LOVE_DRAWING_RAINBOW_COLORS[0].value;
export function clampBabyDrawingUnit(value: number) {
if (!Number.isFinite(value)) {
return 0;
}
return Math.max(0, Math.min(1, value));
}
export function normalizeBabyDrawingPoint(
point: Pick<BabyLoveDrawingHandPoint, 'x' | 'y'>,
): BabyLoveDrawingPoint {
return {
x: clampBabyDrawingUnit(point.x),
y: clampBabyDrawingUnit(point.y),
t: Date.now(),
};
}
export function isPointInsideBounds(
point: Pick<BabyLoveDrawingHandPoint, 'x' | 'y'>,
bounds: BabyLoveDrawingBounds,
) {
return (
point.x >= bounds.left &&
point.x <= bounds.left + bounds.width &&
point.y >= bounds.top &&
point.y <= bounds.top + bounds.height
);
}
export function toCanvasPoint(
point: Pick<BabyLoveDrawingHandPoint, 'x' | 'y'>,
bounds: BabyLoveDrawingBounds,
) {
return {
x: clampBabyDrawingUnit((point.x - bounds.left) / bounds.width),
y: clampBabyDrawingUnit((point.y - bounds.top) / bounds.height),
t: Date.now(),
};
}
export function appendPointToStroke(
stroke: BabyLoveDrawingStroke,
point: BabyLoveDrawingPoint,
): BabyLoveDrawingStroke {
return {
...stroke,
points: [...stroke.points, point],
};
}
export function createBabyDrawingStroke(
tool: BabyLoveDrawingTool,
color: string,
point: BabyLoveDrawingPoint,
): BabyLoveDrawingStroke {
return {
strokeId: `baby-drawing-stroke-${Date.now()}-${Math.random()
.toString(36)
.slice(2, 8)}`,
tool,
color,
points: [point],
};
}
export function resolveHoverProgress(
target: BabyLoveDrawingHoverTarget,
startedAtMs: number | null,
nowMs: number,
) {
if (!target || startedAtMs === null) {
return 0;
}
const duration =
target.kind === 'color'
? BABY_LOVE_DRAWING_COLOR_HOVER_MS
: BABY_LOVE_DRAWING_BUTTON_HOVER_MS;
return clampBabyDrawingUnit((nowMs - startedAtMs) / duration);
}
export function hasHoverCompleted(
target: BabyLoveDrawingHoverTarget,
startedAtMs: number | null,
nowMs: number,
) {
return resolveHoverProgress(target, startedAtMs, nowMs) >= 1;
}

View File

@@ -20,6 +20,7 @@ export interface PlatformEntryCreationTypeModalProps {
onSelectSquareHole: () => void;
onSelectPuzzle: () => void;
onSelectCreativeAgent: () => void;
onSelectBarkBattle: () => void;
onSelectVisualNovel: () => void;
onSelectBabyObjectMatch: () => void;
}
@@ -101,6 +102,7 @@ export function PlatformEntryCreationTypeModal({
onSelectSquareHole,
onSelectPuzzle,
onSelectCreativeAgent,
onSelectBarkBattle,
onSelectVisualNovel,
onSelectBabyObjectMatch,
}: PlatformEntryCreationTypeModalProps) {
@@ -146,6 +148,9 @@ export function PlatformEntryCreationTypeModal({
if (item.id === 'creative-agent') {
onSelectCreativeAgent();
}
if (item.id === 'bark-battle') {
onSelectBarkBattle();
}
if (item.id === 'visual-novel') {
onSelectVisualNovel();
}

View File

@@ -13,6 +13,10 @@ import {
} from 'react';
import type { PublicUserSummary } from '../../../packages/shared/src/contracts/auth';
import type {
BarkBattleConfigEditorPayload,
BarkBattlePublishedConfig,
} from '../../../packages/shared/src/contracts/barkBattle';
import type {
BigFishRuntimeSnapshotResponse,
BigFishSessionSnapshotResponse,
@@ -113,6 +117,10 @@ import {
getPublicAuthUserByCode,
getPublicAuthUserById,
} from '../../services/authService';
import {
createBarkBattleDraft,
publishBarkBattleWork,
} from '../../services/bark-battle-creation';
import {
createBigFishCreationSession,
executeBigFishCreationAction,
@@ -150,8 +158,11 @@ import {
} from '../../services/customWorldAgentUiState';
import {
createBabyObjectMatchDraft,
deleteLocalBabyObjectMatchDraft,
hasBabyObjectMatchPlaceholderAssets,
listLocalBabyObjectMatchDrafts,
publishBabyObjectMatchWork,
regenerateBabyObjectMatchDraftAssets,
saveBabyObjectMatchDraft,
} from '../../services/edutainment-baby-object';
import { match3dCreationClient } from '../../services/match3d-creation';
@@ -323,6 +334,7 @@ import type { PlatformCreationTypeId } from './platformEntryCreationTypes';
import {
derivePlatformCreationTypes,
getVisiblePlatformCreationTypes,
isPlatformCreationTypeOpen,
isPlatformCreationTypeVisible,
} from './platformEntryCreationTypes';
import {
@@ -1933,6 +1945,20 @@ const SquareHoleRuntimeShell = lazy(async () => {
};
});
const BarkBattleConfigEditor = lazy(async () => {
const module = await import('../bark-battle-creation/BarkBattleConfigEditor');
return {
default: module.BarkBattleConfigEditor,
};
});
const BarkBattleRuntimeShell = lazy(async () => {
const module = await import('../../games/bark-battle/ui/BarkBattleRuntimeShell');
return {
default: module.BarkBattleRuntimeShell,
};
});
const CustomWorldCreationHub = lazy(async () => {
const module = await import('../custom-world-home/CustomWorldCreationHub');
return {
@@ -1990,6 +2016,15 @@ const BabyObjectMatchRuntimeShell = lazy(async () => {
};
});
const BabyLoveDrawingRuntimeShell = lazy(async () => {
const module = await import(
'../edutainment-runtime/BabyLoveDrawingRuntimeShell'
);
return {
default: module.BabyLoveDrawingRuntimeShell,
};
});
const VisualNovelResultView = lazy(async () => {
const module = await import('../visual-novel-result/VisualNovelResultView');
return {
@@ -2142,6 +2177,10 @@ export function PlatformEntryFlowShellImpl({
useState(false);
const [squareHoleGenerationState, setSquareHoleGenerationState] =
useState<MiniGameDraftGenerationState | null>(null);
const [barkBattlePublishedConfig, setBarkBattlePublishedConfig] =
useState<BarkBattlePublishedConfig | null>(null);
const [barkBattleError, setBarkBattleError] = useState<string | null>(null);
const [isBarkBattleBusy, setIsBarkBattleBusy] = useState(false);
const [bigFishRun, setBigFishRun] =
useState<BigFishRuntimeSnapshotResponse | null>(null);
const [bigFishRuntimeShare, setBigFishRuntimeShare] = useState<{
@@ -2328,6 +2367,10 @@ export function PlatformEntryFlowShellImpl({
creationEntryTypes,
'baby-object-match',
);
const isVisualNovelCreationOpen = isPlatformCreationTypeOpen(
creationEntryTypes,
'visual-novel',
);
const [profilePlayStats, setProfilePlayStats] =
useState<ProfilePlayStatsResponse | null>(null);
const [profilePlayStatsError, setProfilePlayStatsError] = useState<
@@ -2745,6 +2788,12 @@ export function PlatformEntryFlowShellImpl({
}, [resolvePuzzleErrorMessage]);
const refreshVisualNovelShelf = useCallback(async () => {
if (!isVisualNovelCreationOpen) {
setVisualNovelWorks([]);
visualNovelErrorSetterRef.current(null);
return [];
}
setIsVisualNovelLoadingLibrary(true);
try {
@@ -2760,9 +2809,15 @@ export function PlatformEntryFlowShellImpl({
} finally {
setIsVisualNovelLoadingLibrary(false);
}
}, [resolvePuzzleErrorMessage]);
}, [isVisualNovelCreationOpen, resolvePuzzleErrorMessage]);
const refreshVisualNovelGallery = useCallback(async () => {
if (!isVisualNovelCreationOpen) {
setVisualNovelGalleryEntries([]);
visualNovelErrorSetterRef.current(null);
return [];
}
try {
const galleryResponse = await listVisualNovelGallery();
setVisualNovelGalleryEntries(galleryResponse.works);
@@ -2774,7 +2829,7 @@ export function PlatformEntryFlowShellImpl({
);
return [];
}
}, [resolvePuzzleErrorMessage]);
}, [isVisualNovelCreationOpen, resolvePuzzleErrorMessage]);
const handleRpgDraftGenerationStarted = useCallback(
(sessionId: string) => {
@@ -2802,8 +2857,8 @@ export function PlatformEntryFlowShellImpl({
[markDraftReady, platformBootstrap],
);
const refreshBabyObjectMatchShelf = useCallback(() => {
setBabyObjectMatchDrafts(listLocalBabyObjectMatchDrafts());
const refreshBabyObjectMatchShelf = useCallback(async () => {
setBabyObjectMatchDrafts(await listLocalBabyObjectMatchDrafts());
}, []);
const sessionController = useRpgCreationSessionController({
@@ -3031,7 +3086,7 @@ export function PlatformEntryFlowShellImpl({
...match3dPublicEntries,
...puzzlePublicEntries,
...squareHolePublicEntries,
...visualNovelPublicEntries,
...(isVisualNovelCreationOpen ? visualNovelPublicEntries : []),
...babyObjectMatchPublicEntries,
],
).slice(0, 6);
@@ -3039,6 +3094,7 @@ export function PlatformEntryFlowShellImpl({
babyObjectMatchDrafts,
isBigFishCreationVisible,
isBabyObjectMatchVisible,
isVisualNovelCreationOpen,
bigFishGalleryEntries,
match3dGalleryEntries,
platformBootstrap.publishedGalleryEntries,
@@ -3059,9 +3115,11 @@ export function PlatformEntryFlowShellImpl({
...squareHoleGalleryEntries.map(
mapSquareHoleWorkToPlatformGalleryCard,
),
...visualNovelGalleryEntries.map(
mapVisualNovelWorkToPlatformGalleryCard,
),
...(isVisualNovelCreationOpen
? visualNovelGalleryEntries.map(
mapVisualNovelWorkToPlatformGalleryCard,
)
: []),
...(isBabyObjectMatchVisible
? babyObjectMatchDrafts
.filter((draft) => draft.publicationStatus === 'published')
@@ -3073,6 +3131,7 @@ export function PlatformEntryFlowShellImpl({
babyObjectMatchDrafts,
isBabyObjectMatchVisible,
isBigFishCreationVisible,
isVisualNovelCreationOpen,
bigFishGalleryEntries,
match3dGalleryEntries,
platformBootstrap.publishedGalleryEntries,
@@ -3139,14 +3198,17 @@ export function PlatformEntryFlowShellImpl({
[pendingDraftShelfItems, puzzleWorks],
);
const visualNovelShelfItems = useMemo(
() => [
...buildPendingVisualNovelWorks(
pendingDraftShelfItems['visual-novel'],
visualNovelWorks,
),
...visualNovelWorks,
],
[pendingDraftShelfItems, visualNovelWorks],
() =>
isVisualNovelCreationOpen
? [
...buildPendingVisualNovelWorks(
pendingDraftShelfItems['visual-novel'],
visualNovelWorks,
),
...visualNovelWorks,
]
: [],
[isVisualNovelCreationOpen, pendingDraftShelfItems, visualNovelWorks],
);
const getCreationWorkShelfState = useCallback(
(item: CreationWorkShelfItem) => {
@@ -4913,7 +4975,7 @@ export function PlatformEntryFlowShellImpl({
try {
const response = await createBabyObjectMatchDraft(payload);
setBabyObjectMatchDraft(response.draft);
refreshBabyObjectMatchShelf();
void refreshBabyObjectMatchShelf();
setBabyObjectMatchGenerationPhase('ready');
setBabyObjectMatchGenerationState((current) =>
current
@@ -5167,6 +5229,15 @@ export function PlatformEntryFlowShellImpl({
return;
}
if (type === 'bark-battle') {
enterCreateTab();
setShowCreationTypeModal(false);
setActiveCreationFormType('bark-battle');
setBarkBattleError(null);
setSelectionStage('bark-battle-config');
return;
}
if (type === 'visual-novel') {
enterCreateTab();
setShowCreationTypeModal(false);
@@ -5196,9 +5267,12 @@ export function PlatformEntryFlowShellImpl({
prepareCreationLaunch,
runProtectedAction,
sessionController,
setActiveCreationFormType,
setBarkBattleError,
setMatch3DError,
setPuzzleCreationError,
setPuzzleError,
setSelectionStage,
setVisualNovelError,
],
);
@@ -5228,6 +5302,37 @@ export function PlatformEntryFlowShellImpl({
squareHoleFlow.leaveFlow();
}, [squareHoleFlow]);
const leaveBarkBattleFlow = useCallback(() => {
setBarkBattlePublishedConfig(null);
setBarkBattleError(null);
setIsBarkBattleBusy(false);
setSelectionStage('platform');
}, [setSelectionStage]);
const publishBarkBattleConfig = useCallback(
async (payload: BarkBattleConfigEditorPayload) => {
setBarkBattleError(null);
setIsBarkBattleBusy(true);
try {
const draft = await createBarkBattleDraft(payload);
const published = await publishBarkBattleWork({
draftId: draft.draftId,
workId: draft.workId,
publishedSnapshot: payload,
});
setBarkBattlePublishedConfig(published);
setSelectionStage('bark-battle-runtime');
} catch (error) {
setBarkBattleError(
resolvePuzzleErrorMessage(error, '发布汪汪声浪大作战作品失败。'),
);
} finally {
setIsBarkBattleBusy(false);
}
},
[resolvePuzzleErrorMessage, setSelectionStage],
);
const leavePuzzleFlow = useCallback(() => {
setPuzzleOperation(null);
setPuzzleRun(null);
@@ -5266,7 +5371,7 @@ export function PlatformEntryFlowShellImpl({
try {
const response = await saveBabyObjectMatchDraft({ draft });
setBabyObjectMatchDraft(response.draft);
refreshBabyObjectMatchShelf();
void refreshBabyObjectMatchShelf();
} catch (error) {
setBabyObjectMatchError(
resolvePuzzleErrorMessage(error, '保存宝贝识物草稿失败。'),
@@ -5278,14 +5383,52 @@ export function PlatformEntryFlowShellImpl({
[refreshBabyObjectMatchShelf, resolvePuzzleErrorMessage],
);
const ensureBabyObjectMatchGeneratedAssets = useCallback(
async (draft: BabyObjectMatchDraft) => {
if (!hasBabyObjectMatchPlaceholderAssets(draft)) {
return draft;
}
const response = await regenerateBabyObjectMatchDraftAssets(draft);
setBabyObjectMatchDraft(response.draft);
void refreshBabyObjectMatchShelf();
return response.draft;
},
[refreshBabyObjectMatchShelf],
);
const regenerateBabyObjectMatchResultAssets = useCallback(
async (draft: BabyObjectMatchDraft) => {
setBabyObjectMatchError(null);
setIsBabyObjectMatchBusy(true);
try {
await ensureBabyObjectMatchGeneratedAssets(draft);
} catch (error) {
setBabyObjectMatchError(
resolvePuzzleErrorMessage(
error,
'重新生成宝贝识物 image-2 资源失败。',
),
);
} finally {
setIsBabyObjectMatchBusy(false);
}
},
[ensureBabyObjectMatchGeneratedAssets, resolvePuzzleErrorMessage],
);
const publishBabyObjectMatchResultDraft = useCallback(
async (draft: BabyObjectMatchDraft) => {
setBabyObjectMatchError(null);
setIsBabyObjectMatchBusy(true);
try {
const response = await publishBabyObjectMatchWork({ draft });
const generatedDraft = await ensureBabyObjectMatchGeneratedAssets(draft);
const response = await publishBabyObjectMatchWork({
draft: generatedDraft,
});
setBabyObjectMatchDraft(response.draft);
refreshBabyObjectMatchShelf();
void refreshBabyObjectMatchShelf();
openPublishShareModal({
title: response.draft.workTitle,
publicWorkCode:
@@ -5295,13 +5438,17 @@ export function PlatformEntryFlowShellImpl({
});
} catch (error) {
setBabyObjectMatchError(
resolvePuzzleErrorMessage(error, '发布宝贝识物作品失败。'),
resolvePuzzleErrorMessage(
error,
'生成宝贝识物 image-2 资源失败,请重试后再发布。',
),
);
} finally {
setIsBabyObjectMatchBusy(false);
}
},
[
ensureBabyObjectMatchGeneratedAssets,
openPublishShareModal,
refreshBabyObjectMatchShelf,
resolvePuzzleErrorMessage,
@@ -5309,40 +5456,66 @@ export function PlatformEntryFlowShellImpl({
);
const startBabyObjectMatchRuntimeFromDraft = useCallback(
(
async (
draft: BabyObjectMatchDraft,
returnStage: BabyObjectMatchRuntimeReturnStage = 'baby-object-match-result',
options: { embedded?: boolean } = {},
) => {
setBabyObjectMatchDraft(draft);
setBabyObjectMatchFormPayload({
itemAName: draft.itemNames[0],
itemBName: draft.itemNames[1],
});
setBabyObjectMatchRuntimeReturnStage(returnStage);
setBabyObjectMatchError(null);
if (!options.embedded) {
setSelectionStage('baby-object-match-runtime');
const publicWorkCode =
draft.publicationStatus === 'published'
? buildBabyObjectMatchPublicWorkCode(draft.profileId)
: null;
if (publicWorkCode) {
pushAppHistoryPath(
buildPublicWorkStagePath(
'baby-object-match-runtime',
publicWorkCode,
),
);
setIsBabyObjectMatchBusy(true);
try {
const generatedDraft =
await ensureBabyObjectMatchGeneratedAssets(draft);
setBabyObjectMatchDraft(generatedDraft);
setBabyObjectMatchFormPayload({
itemAName: generatedDraft.itemNames[0],
itemBName: generatedDraft.itemNames[1],
});
setBabyObjectMatchRuntimeReturnStage(returnStage);
if (!options.embedded) {
setSelectionStage('baby-object-match-runtime');
const publicWorkCode =
generatedDraft.publicationStatus === 'published'
? buildBabyObjectMatchPublicWorkCode(generatedDraft.profileId)
: null;
if (publicWorkCode) {
pushAppHistoryPath(
buildPublicWorkStagePath(
'baby-object-match-runtime',
publicWorkCode,
),
);
}
}
return true;
} catch (error) {
const message = resolvePuzzleErrorMessage(
error,
'生成宝贝识物 image-2 资源失败,请重试后再试玩。',
);
setBabyObjectMatchError(message);
if (options.embedded) {
setActiveRecommendRuntimeError(message);
}
return false;
} finally {
setIsBabyObjectMatchBusy(false);
}
return true;
},
[setSelectionStage],
[
ensureBabyObjectMatchGeneratedAssets,
resolvePuzzleErrorMessage,
setSelectionStage,
],
);
const startBabyLoveDrawingRuntime = useCallback(() => {
setSelectionStage('baby-love-drawing-runtime');
pushAppHistoryPath('/runtime/baby-love-drawing');
}, [setSelectionStage]);
const resolveBabyObjectMatchRuntimeDraft = useCallback(
(entry: PlatformPublicGalleryCard) => {
async (entry: PlatformPublicGalleryCard) => {
if (!isEdutainmentGalleryEntry(entry)) {
return null;
}
@@ -5351,7 +5524,7 @@ export function PlatformEntryFlowShellImpl({
babyObjectMatchDrafts.find(
(draft) => draft.profileId === entry.profileId,
) ??
listLocalBabyObjectMatchDrafts().find(
(await listLocalBabyObjectMatchDrafts()).find(
(draft) => draft.profileId === entry.profileId,
) ??
null
@@ -5361,12 +5534,12 @@ export function PlatformEntryFlowShellImpl({
);
const startBabyObjectMatchRuntimeFromEntry = useCallback(
(
async (
entry: PlatformPublicGalleryCard,
returnStage: BabyObjectMatchRuntimeReturnStage = 'work-detail',
options: { embedded?: boolean } = {},
) => {
const draft = resolveBabyObjectMatchRuntimeDraft(entry);
const draft = await resolveBabyObjectMatchRuntimeDraft(entry);
if (!draft) {
setPublicWorkDetailError(
'当前宝贝识物作品缺少本地草稿,暂时无法进入玩法。',
@@ -5374,7 +5547,11 @@ export function PlatformEntryFlowShellImpl({
return false;
}
return startBabyObjectMatchRuntimeFromDraft(draft, returnStage, options);
return await startBabyObjectMatchRuntimeFromDraft(
draft,
returnStage,
options,
);
},
[resolveBabyObjectMatchRuntimeDraft, startBabyObjectMatchRuntimeFromDraft],
);
@@ -6995,7 +7172,7 @@ export function PlatformEntryFlowShellImpl({
return;
}
runProtectedAction(() => {
runProtectedAction(async () => {
setIsPublicWorkDetailBusy(true);
setIsPuzzleBusy(true);
setPuzzleError(null);
@@ -7474,6 +7651,67 @@ export function PlatformEntryFlowShellImpl({
],
);
const handleDeleteBabyObjectMatchWork = useCallback(
(work: BabyObjectMatchDraft) => {
if (deletingCreationWorkId) {
return;
}
const noticeKeys = collectDraftNoticeKeys('baby-object-match', [
work.profileId,
work.draftId,
]);
const displayName = work.workTitle.trim() || work.templateName;
requestDeleteCreationWork({
id: work.profileId,
title: displayName,
detail:
work.publicationStatus === 'published'
? '删除后会从你的作品列表和寓教于乐板块中移除。'
: '删除后会从你的作品列表中移除。',
run: () => {
setDeletingCreationWorkId(work.profileId);
setBabyObjectMatchError(null);
void deleteLocalBabyObjectMatchDraft(work.profileId)
.then((nextDrafts) => {
markDraftNoticeSeen(noticeKeys);
setBabyObjectMatchDrafts(nextDrafts);
setBabyObjectMatchDraft((current) =>
current?.profileId === work.profileId ? null : current,
);
if (
babyObjectMatchDraft?.profileId === work.profileId &&
(selectionStage === 'baby-object-match-result' ||
selectionStage === 'baby-object-match-runtime')
) {
enterCreateTab();
setSelectionStage('platform');
}
})
.catch((error) => {
setBabyObjectMatchError(
resolvePuzzleErrorMessage(error, '删除宝贝识物作品失败。'),
);
})
.finally(() => {
setDeletingCreationWorkId(null);
});
},
});
},
[
babyObjectMatchDraft?.profileId,
deletingCreationWorkId,
enterCreateTab,
markDraftNoticeSeen,
requestDeleteCreationWork,
resolvePuzzleErrorMessage,
selectionStage,
setSelectionStage,
],
);
const clearSelectedPublicWorkAuthor = useCallback(() => {
publicWorkAuthorRequestKeyRef.current += 1;
setSelectedPublicWorkAuthor(null);
@@ -8668,7 +8906,7 @@ export function PlatformEntryFlowShellImpl({
if (isEdutainmentGalleryEntry(selectedPublicWorkDetail)) {
setPublicWorkDetailError(null);
startBabyObjectMatchRuntimeFromEntry(
void startBabyObjectMatchRuntimeFromEntry(
selectedPublicWorkDetail,
'work-detail',
);
@@ -8800,9 +9038,13 @@ export function PlatformEntryFlowShellImpl({
{ embedded: true },
);
} else if (isEdutainmentGalleryEntry(entry)) {
started = startBabyObjectMatchRuntimeFromEntry(entry, 'platform', {
embedded: true,
});
started = await startBabyObjectMatchRuntimeFromEntry(
entry,
'platform',
{
embedded: true,
},
);
} else {
started = true;
}
@@ -9360,7 +9602,7 @@ export function PlatformEntryFlowShellImpl({
return;
}
runProtectedAction(() => {
runProtectedAction(async () => {
setPublicWorkDetailError(null);
// 中文注释:自有公开作品必须恢复原草稿,不能复用 remix 复制链路。
@@ -9428,7 +9670,7 @@ export function PlatformEntryFlowShellImpl({
}
if (isEdutainmentGalleryEntry(entry)) {
const matchedDraft = resolveBabyObjectMatchRuntimeDraft(entry);
const matchedDraft = await resolveBabyObjectMatchRuntimeDraft(entry);
if (!matchedDraft) {
setPublicWorkDetailError('这份宝贝识物缺少可编辑草稿。');
return;
@@ -9655,8 +9897,8 @@ export function PlatformEntryFlowShellImpl({
mapVisualNovelWorkToPublicWorkDetail(matchedEntry),
);
};
const tryOpenBabyObjectMatchGalleryEntry = () => {
const entries = listLocalBabyObjectMatchDrafts().filter(
const tryOpenBabyObjectMatchGalleryEntry = async () => {
const entries = (await listLocalBabyObjectMatchDrafts()).filter(
(draft) => draft.publicationStatus === 'published',
);
const matchedDraft = entries.find((draft) => {
@@ -9699,7 +9941,7 @@ export function PlatformEntryFlowShellImpl({
}
if (shouldSearchBabyObjectFirst) {
tryOpenBabyObjectMatchGalleryEntry();
await tryOpenBabyObjectMatchGalleryEntry();
return;
}
@@ -9959,11 +10201,14 @@ export function PlatformEntryFlowShellImpl({
if (isSquareHoleCreationVisible) {
void refreshSquareHoleGallery();
}
void refreshVisualNovelGallery();
if (isVisualNovelCreationOpen) {
void refreshVisualNovelGallery();
}
}
}, [
isBigFishCreationVisible,
isSquareHoleCreationVisible,
isVisualNovelCreationOpen,
refreshBigFishGallery,
refreshMatch3DGallery,
refreshPuzzleGallery,
@@ -9983,11 +10228,14 @@ export function PlatformEntryFlowShellImpl({
if (isSquareHoleCreationVisible) {
void refreshSquareHoleShelf();
}
void refreshVisualNovelShelf();
refreshBabyObjectMatchShelf();
if (isVisualNovelCreationOpen) {
void refreshVisualNovelShelf();
}
void refreshBabyObjectMatchShelf();
}
}, [
isSquareHoleCreationVisible,
isVisualNovelCreationOpen,
platformBootstrap.canReadProtectedData,
platformBootstrap.platformTab,
refreshBabyObjectMatchShelf,
@@ -10030,7 +10278,7 @@ export function PlatformEntryFlowShellImpl({
isMatch3DLoadingLibrary ||
(isSquareHoleCreationVisible && isSquareHoleLoadingLibrary) ||
isPuzzleLoadingLibrary ||
isVisualNovelLoadingLibrary ||
(isVisualNovelCreationOpen && isVisualNovelLoadingLibrary) ||
isBabyObjectMatchBusy
}
error={
@@ -10039,7 +10287,7 @@ export function PlatformEntryFlowShellImpl({
isMatch3DLoadingLibrary ||
(isSquareHoleCreationVisible && isSquareHoleLoadingLibrary) ||
isPuzzleLoadingLibrary ||
isVisualNovelLoadingLibrary ||
(isVisualNovelCreationOpen && isVisualNovelLoadingLibrary) ||
isBabyObjectMatchBusy
? null
: (platformBootstrap.platformError ??
@@ -10049,7 +10297,7 @@ export function PlatformEntryFlowShellImpl({
(isSquareHoleCreationVisible ? squareHoleError : null) ??
puzzleShelfError ??
puzzleError ??
visualNovelError ??
(isVisualNovelCreationOpen ? visualNovelError : null) ??
babyObjectMatchError)
}
onRetry={() => {
@@ -10085,8 +10333,10 @@ export function PlatformEntryFlowShellImpl({
void refreshSquareHoleShelf();
}
void refreshPuzzleShelf();
void refreshVisualNovelShelf();
refreshBabyObjectMatchShelf();
if (isVisualNovelCreationOpen) {
void refreshVisualNovelShelf();
}
void refreshBabyObjectMatchShelf();
}}
createError={
creationEntryConfigError ??
@@ -10096,7 +10346,7 @@ export function PlatformEntryFlowShellImpl({
(isSquareHoleCreationVisible ? squareHoleError : null) ??
puzzleCreationError ??
puzzleError ??
visualNovelError ??
(isVisualNovelCreationOpen ? visualNovelError : null) ??
babyObjectMatchError
}
createBusy={
@@ -10108,8 +10358,8 @@ export function PlatformEntryFlowShellImpl({
isMatch3DBusy ||
(isSquareHoleCreationVisible && isSquareHoleBusy) ||
isPuzzleBusy ||
isVisualNovelBusy ||
isVisualNovelStreamingReply ||
(isVisualNovelCreationOpen && isVisualNovelBusy) ||
(isVisualNovelCreationOpen && isVisualNovelStreamingReply) ||
isBabyObjectMatchBusy
}
entryConfig={creationEntryConfig}
@@ -10206,6 +10456,9 @@ export function PlatformEntryFlowShellImpl({
openBabyObjectMatchDraft(item);
});
}}
onDeleteBabyObjectMatch={(item) => {
handleDeleteBabyObjectMatchWork(item);
}}
visualNovelItems={visualNovelShelfItems}
onOpenVisualNovelDetail={(item) => {
runProtectedAction(() => {
@@ -10440,6 +10693,7 @@ export function PlatformEntryFlowShellImpl({
onOpenCreateWorld={openCreationTypePicker}
onOpenCreateTypePicker={openCreationTypePicker}
onOpenGalleryDetail={openPublicGalleryDetail}
onOpenBabyLoveDrawing={startBabyLoveDrawingRuntime}
onOpenRecommendGalleryDetail={openRecommendGalleryDetail}
recommendRuntimeContent={recommendRuntimeContent}
activeRecommendEntryKey={activeRecommendEntryKey}
@@ -11196,8 +11450,11 @@ export function PlatformEntryFlowShellImpl({
onPublish={(draft) => {
void publishBabyObjectMatchResultDraft(draft);
}}
onRegenerateAssets={(draft) => {
void regenerateBabyObjectMatchResultAssets(draft);
}}
onStartTestRun={(draft) => {
startBabyObjectMatchRuntimeFromDraft(
void startBabyObjectMatchRuntimeFromDraft(
draft,
'baby-object-match-result',
);
@@ -11229,6 +11486,26 @@ export function PlatformEntryFlowShellImpl({
</motion.div>
)}
{selectionStage === 'baby-love-drawing-runtime' && (
<motion.div
key="baby-love-drawing-runtime"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[100]"
>
<Suspense
fallback={<LazyPanelFallback label="正在加载宝贝爱画..." />}
>
<BabyLoveDrawingRuntimeShell
onBack={() => {
setSelectionStage('platform');
}}
/>
</Suspense>
</motion.div>
)}
{selectionStage === 'square-hole-agent-workspace' && (
<motion.div
key="square-hole-agent-workspace"
@@ -11938,6 +12215,56 @@ export function PlatformEntryFlowShellImpl({
</motion.div>
)}
{selectionStage === 'bark-battle-config' && (
<motion.div
key="bark-battle-config"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
className="flex h-full min-h-0 flex-col"
>
<Suspense
fallback={<LazyPanelFallback label="正在加载汪汪声浪配置..." />}
>
<BarkBattleConfigEditor
isBusy={isBarkBattleBusy}
onBack={leaveBarkBattleFlow}
onPublish={(payload) => {
void publishBarkBattleConfig(payload);
}}
/>
{barkBattleError ? (
<div className="platform-subpanel mx-auto mt-3 max-w-5xl rounded-2xl px-4 py-3 text-sm text-rose-200">
{barkBattleError}
</div>
) : null}
</Suspense>
</motion.div>
)}
{selectionStage === 'bark-battle-runtime' && barkBattlePublishedConfig && (
<motion.div
key="bark-battle-runtime"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
className="flex h-full min-h-0 flex-col"
>
<Suspense
fallback={<LazyPanelFallback label="正在加载汪汪声浪试玩..." />}
>
<BarkBattleRuntimeShell
title={barkBattlePublishedConfig.title}
workId={barkBattlePublishedConfig.workId}
publishedConfig={barkBattlePublishedConfig}
onExit={() => {
setSelectionStage('bark-battle-config');
}}
/>
</Suspense>
</motion.div>
)}
{selectionStage === 'custom-world-result' &&
sessionController.generatedCustomWorldProfile && (
<motion.div
@@ -12131,7 +12458,18 @@ export function PlatformEntryFlowShellImpl({
{creationEntryConfig ? (
<PlatformEntryCreationTypeModal
isOpen={showCreationTypeModal}
isBusy={sessionController.isCreatingAgentSession}
isBusy={
sessionController.isCreatingAgentSession ||
isCreativeAgentBusy ||
isCreativeAgentStreaming ||
isBigFishBusy ||
isMatch3DBusy ||
isSquareHoleBusy ||
isPuzzleBusy ||
(isVisualNovelCreationOpen && isVisualNovelBusy) ||
(isVisualNovelCreationOpen && isVisualNovelStreamingReply) ||
isBabyObjectMatchBusy
}
error={
creationEntryConfigError ??
bigFishError ??
@@ -12139,7 +12477,7 @@ export function PlatformEntryFlowShellImpl({
match3dError ??
squareHoleError ??
puzzleCreationError ??
visualNovelError ??
(isVisualNovelCreationOpen ? visualNovelError : null) ??
babyObjectMatchError ??
puzzleError ??
sessionController.creationTypeError
@@ -12147,7 +12485,18 @@ export function PlatformEntryFlowShellImpl({
entryConfig={creationEntryConfig}
creationTypes={creationEntryTypes}
onClose={() => {
if (sessionController.isCreatingAgentSession) {
if (
sessionController.isCreatingAgentSession ||
isCreativeAgentBusy ||
isCreativeAgentStreaming ||
isBigFishBusy ||
isMatch3DBusy ||
isSquareHoleBusy ||
isPuzzleBusy ||
(isVisualNovelCreationOpen && isVisualNovelBusy) ||
(isVisualNovelCreationOpen && isVisualNovelStreamingReply) ||
isBabyObjectMatchBusy
) {
return;
}
setShowCreationTypeModal(false);
@@ -12178,6 +12527,9 @@ export function PlatformEntryFlowShellImpl({
void openCreativeAgentWorkspace();
});
}}
onSelectBarkBattle={() => {
handleCreationHubCreateType('bark-battle');
}}
onSelectVisualNovel={() => {
handleCreationHubCreateType('visual-novel');
}}

View File

@@ -3,6 +3,7 @@ import { afterEach, expect, test, vi } from 'vitest';
import {
derivePlatformCreationTypes,
getVisiblePlatformCreationTypes,
isPlatformCreationTypeOpen,
isPlatformCreationTypeVisible,
} from './platformEntryCreationTypes';
@@ -109,6 +110,9 @@ test('visible platform creation types hide invisible cards and put locked cards
);
expect(isPlatformCreationTypeVisible(cards, 'hidden')).toBe(false);
expect(isPlatformCreationTypeVisible(cards, 'open')).toBe(true);
expect(isPlatformCreationTypeOpen(cards, 'hidden')).toBe(false);
expect(isPlatformCreationTypeOpen(cards, 'locked')).toBe(false);
expect(isPlatformCreationTypeOpen(cards, 'open')).toBe(true);
expect(
cards.every((item) =>
item.imageSrc.startsWith('/creation-type-references/'),
@@ -123,7 +127,7 @@ test('edutainment switch hides baby object match creation entry from database co
title: '宝贝识物',
subtitle: '亲子识物分类',
badge: '可创建',
imageSrc: '/creation-type-references/baby-object-match.webp',
imageSrc: '/child-motion-demo/picture-book-grass-stage.png',
visible: true,
open: true,
sortOrder: 1,
@@ -152,7 +156,7 @@ test('edutainment switch hides baby object match creation entry from database co
title: '宝贝识物',
subtitle: '亲子识物分类',
badge: '可创建',
imageSrc: '/creation-type-references/baby-object-match.webp',
imageSrc: '/child-motion-demo/picture-book-grass-stage.png',
visible: true,
open: true,
sortOrder: 1,

View File

@@ -32,6 +32,15 @@ export function isPlatformCreationTypeVisible(
return creationTypes.some((item) => item.id === id && !item.hidden);
}
export function isPlatformCreationTypeOpen(
creationTypes: readonly PlatformCreationTypeCard[],
id: PlatformCreationTypeId,
) {
return creationTypes.some(
(item) => item.id === id && !item.hidden && !item.locked,
);
}
/**
* 创作入口卡片只做展示派生;配置事实源来自后端 API / SpacetimeDB前端不再保留入口默认配置。
*/

View File

@@ -31,6 +31,8 @@ export type SelectionStage =
| 'square-hole-generating'
| 'square-hole-result'
| 'square-hole-runtime'
| 'bark-battle-config'
| 'bark-battle-runtime'
| 'creative-agent-workspace'
| 'visual-novel-agent-workspace'
| 'visual-novel-generating'
@@ -41,6 +43,7 @@ export type SelectionStage =
| 'baby-object-match-generating'
| 'baby-object-match-result'
| 'baby-object-match-runtime'
| 'baby-love-drawing-runtime'
| 'puzzle-agent-workspace'
| 'puzzle-generating'
| 'puzzle-onboarding'

View File

@@ -143,6 +143,8 @@ import {
listSquareHoleGallery,
listSquareHoleWorks,
} from '../../services/square-hole-works';
import { listVisualNovelGallery } from '../../services/visual-novel-runtime';
import { listVisualNovelWorks } from '../../services/visual-novel-works';
import { type CustomWorldProfile, WorldType } from '../../types';
import {
AuthUiContext,
@@ -319,6 +321,17 @@ const testCreationEntryConfig = {
sortOrder: 80,
updatedAtMicros: 1,
},
{
id: 'baby-object-match',
title: '宝贝识物',
subtitle: '亲子识物分类',
badge: '可创建',
imageSrc: '/child-motion-demo/picture-book-grass-stage.png',
visible: true,
open: true,
sortOrder: 90,
updatedAtMicros: 1,
},
],
} satisfies CreationEntryConfig;
@@ -527,6 +540,28 @@ vi.mock('../../services/square-hole-works', () => ({
listSquareHoleWorks: vi.fn(),
}));
vi.mock('../../services/visual-novel-runtime', () => ({
listVisualNovelGallery: vi.fn(),
startVisualNovelRun: vi.fn(),
streamVisualNovelRuntimeAction: vi.fn(),
}));
vi.mock('../../services/visual-novel-works', () => ({
deleteVisualNovelWork: vi.fn(),
getVisualNovelWorkDetail: vi.fn(),
listVisualNovelWorks: vi.fn(),
publishVisualNovelWork: vi.fn(),
updateVisualNovelWork: vi.fn(),
}));
vi.mock('../../services/visual-novel-creation', () => ({
compileVisualNovelWorkProfile: vi.fn(),
createVisualNovelSession: vi.fn(),
executeVisualNovelAction: vi.fn(),
getVisualNovelSession: vi.fn(),
streamVisualNovelMessage: vi.fn(),
}));
vi.mock('../../services/creative-agent', () => ({
cancelCreativeAgentSession: vi.fn(),
confirmCreativePuzzleTemplate: vi.fn(),
@@ -1969,6 +2004,8 @@ beforeEach(() => {
vi.mocked(upsertProfileBrowseHistory).mockResolvedValue([]);
vi.mocked(clearProfileBrowseHistory).mockResolvedValue([]);
vi.mocked(deleteRpgEntryWorldProfile).mockResolvedValue([]);
vi.mocked(listVisualNovelGallery).mockResolvedValue({ works: [] });
vi.mocked(listVisualNovelWorks).mockResolvedValue({ works: [] });
vi.mocked(recordBigFishPlay).mockResolvedValue({ items: [] });
vi.mocked(recordRpgEntryWorldGalleryPlay).mockImplementation(
async (ownerUserId, profileId) => ({
@@ -2848,6 +2885,9 @@ test('create tab shows template tabs and embeds puzzle form by default', async (
expect(
screen.getByRole('tab', { name: '抓大鹅' }).querySelector('img')?.src,
).toContain('/creation-type-references/match3d.webp');
expect(
screen.getByRole('tab', { name: '宝贝识物' }).querySelector('img')?.src,
).toContain('/child-motion-demo/picture-book-grass-stage.png');
expect(
screen.getByRole('tab', { name: '拼图' }).querySelector('.text-white'),
).toBeTruthy();
@@ -2860,6 +2900,7 @@ test('create tab shows template tabs and embeds puzzle form by default', async (
expect(screen.queryByRole('tab', { name: /方洞挑战/u })).toBeNull();
expect(screen.queryByRole('tab', { name: '视觉小说' })).toBeNull();
expect(screen.getByRole('tab', { name: /抓大鹅/u })).toBeTruthy();
expect(screen.getByRole('tab', { name: /宝贝识物/u })).toBeTruthy();
expect(createRpgCreationSession).not.toHaveBeenCalled();
expect(match3dCreationClient.createSession).not.toHaveBeenCalled();
expect(createPuzzleAgentSession).not.toHaveBeenCalled();
@@ -4769,6 +4810,30 @@ test('creation hub clears all private work shelves immediately after logout stat
});
});
test('creation draft hub skips visual novel shelves when entry is not open', async () => {
const user = userEvent.setup();
vi.mocked(fetchCreationEntryConfig).mockResolvedValue({
...testCreationEntryConfig,
creationTypes: testCreationEntryConfig.creationTypes.map((entry) =>
entry.id === 'visual-novel' ? { ...entry, open: false } : entry,
),
});
vi.mocked(listVisualNovelGallery).mockRejectedValue(
new Error('该玩法入口暂不可用'),
);
vi.mocked(listVisualNovelWorks).mockRejectedValue(
new Error('该玩法入口暂不可用'),
);
render(<TestWrapper withAuth />);
await openDraftHub(user);
expect(listVisualNovelGallery).not.toHaveBeenCalled();
expect(listVisualNovelWorks).not.toHaveBeenCalled();
expect(screen.queryByText('该玩法入口暂不可用')).toBeNull();
});
test('published puzzle works appear on home and mobile game category channel', async () => {
const user = userEvent.setup();
const publishedPuzzleWork = {

View File

@@ -15,6 +15,7 @@ import {
LogIn,
MessageCircle,
Pencil,
Palette,
Plus,
Search,
Settings,
@@ -152,6 +153,7 @@ export interface RpgEntryHomeViewProps {
onOpenCreateWorld: () => void;
onOpenCreateTypePicker: () => void;
onOpenGalleryDetail: (entry: PlatformPublicGalleryCard) => void;
onOpenBabyLoveDrawing?: () => void;
onOpenRecommendGalleryDetail?: (entry: PlatformPublicGalleryCard) => void;
recommendRuntimeContent?: ReactNode;
activeRecommendEntryKey?: string | null;
@@ -249,6 +251,11 @@ const EDUTAINMENT_DISCOVER_CHANNEL = {
id: 'edutainment',
label: EDUTAINMENT_WORK_TAG,
} as const;
const BABY_LOVE_DRAWING_DEFAULT_CARD = {
title: '宝贝爱画',
subtitle: '空白画板',
summary: '挥动小手画一张画。',
};
const PLATFORM_RANKING_TABS: Array<{
id: PlatformRankingTab;
@@ -3218,6 +3225,7 @@ export function RpgEntryHomeView({
onResumeSave,
onOpenCreateTypePicker,
onOpenGalleryDetail,
onOpenBabyLoveDrawing,
onOpenRecommendGalleryDetail,
recommendRuntimeContent,
activeRecommendEntryKey = null,
@@ -4735,7 +4743,7 @@ export function RpgEntryHomeView({
<section className="platform-mobile-home-feed">
{isLoadingPlatform ? (
<EmptyShelf text="正在读取公开作品..." />
) : edutainmentFeedEntries.length > 0 ? (
) : edutainmentFeedEntries.length > 0 || onOpenBabyLoveDrawing ? (
<div className="grid min-w-0 gap-3">
{edutainmentFeedEntries.map((entry) => {
const cardKey = buildPublicGalleryCardKey(entry);
@@ -4751,6 +4759,24 @@ export function RpgEntryHomeView({
/>
);
})}
{onOpenBabyLoveDrawing ? (
<button
type="button"
className="platform-edutainment-level-card"
onClick={onOpenBabyLoveDrawing}
>
<span className="platform-edutainment-level-card__icon">
<Palette className="h-7 w-7" />
</span>
<span className="platform-edutainment-level-card__body">
<strong>{BABY_LOVE_DRAWING_DEFAULT_CARD.title}</strong>
<span>{BABY_LOVE_DRAWING_DEFAULT_CARD.subtitle}</span>
</span>
<span className="platform-edutainment-level-card__summary">
{BABY_LOVE_DRAWING_DEFAULT_CARD.summary}
</span>
</button>
) : null}
</div>
) : (
<EmptyShelf text="暂时还没有可展示的作品。" />
@@ -4867,7 +4893,7 @@ export function RpgEntryHomeView({
<SectionHeader title={EDUTAINMENT_WORK_TAG} detail="EDUTAINMENT" />
{isLoadingPlatform ? (
<EmptyShelf text="正在读取公开作品..." />
) : edutainmentFeedEntries.length > 0 ? (
) : edutainmentFeedEntries.length > 0 || onOpenBabyLoveDrawing ? (
<div className="grid gap-4 xl:grid-cols-3">
{edutainmentFeedEntries.map((entry) => (
<WorldCard
@@ -4878,6 +4904,24 @@ export function RpgEntryHomeView({
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(entry)}
/>
))}
{onOpenBabyLoveDrawing ? (
<button
type="button"
className="platform-edutainment-level-card"
onClick={onOpenBabyLoveDrawing}
>
<span className="platform-edutainment-level-card__icon">
<Palette className="h-7 w-7" />
</span>
<span className="platform-edutainment-level-card__body">
<strong>{BABY_LOVE_DRAWING_DEFAULT_CARD.title}</strong>
<span>{BABY_LOVE_DRAWING_DEFAULT_CARD.subtitle}</span>
</span>
<span className="platform-edutainment-level-card__summary">
{BABY_LOVE_DRAWING_DEFAULT_CARD.summary}
</span>
</button>
) : null}
</div>
) : (
<EmptyShelf text="暂时还没有可展示的作品。" />

View File

@@ -41,10 +41,9 @@ test('platform work display text limits names and tags by character count', () =
expect(formatPlatformWorkDisplayName('热门高分拼图超长标题')).toBe(
'热门高分拼图超长',
);
expect(formatPlatformWorkDisplayTags(['超长机关标签', '星桥', '超长机关标签'])).toEqual([
'超长机关',
'星桥',
]);
expect(
formatPlatformWorkDisplayTags(['超长机关标签', '星桥', '超长机关标签']),
).toEqual(['超长机关', '星桥']);
});
test('buildPuzzleWorkCoverSlides prefers each level formal image', () => {
@@ -195,6 +194,7 @@ test('maps baby object match draft to edutainment public card', () => {
prompt: '香蕉',
},
],
visualPackage: null,
themeTags: ['寓教于乐', '宝贝识物'],
publicationStatus: 'published',
createdAt: '2026-05-11T10:00:00.000Z',