Merge remote-tracking branch 'origin/master' into hermes/wechat
# Conflicts: # .hermes/shared-memory/pitfalls.md # .hermes/todos/【后端架构】api-server能力模块化与图片资产Adapter收口计划-2026-05-14.md
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { ResolvedAssetImage } from './ResolvedAssetImage';
|
||||
import type { CustomWorldCoverRenderMode } from '../services/customWorldCover';
|
||||
import { ResolvedAssetImage } from './ResolvedAssetImage';
|
||||
|
||||
const COVER_PORTRAIT_CLASS_NAMES = [
|
||||
'h-[54%] w-[24%] translate-y-[8%]',
|
||||
@@ -11,6 +11,7 @@ const COVER_PORTRAIT_CLASS_NAMES = [
|
||||
|
||||
type CustomWorldCoverArtworkProps = {
|
||||
imageSrc?: string | null;
|
||||
fallbackImageSrc?: string | null;
|
||||
title: string;
|
||||
fallbackLabel: string;
|
||||
renderMode?: CustomWorldCoverRenderMode;
|
||||
@@ -21,6 +22,7 @@ type CustomWorldCoverArtworkProps = {
|
||||
|
||||
export function CustomWorldCoverArtwork({
|
||||
imageSrc,
|
||||
fallbackImageSrc,
|
||||
title,
|
||||
fallbackLabel,
|
||||
renderMode = 'image',
|
||||
@@ -31,24 +33,24 @@ export function CustomWorldCoverArtwork({
|
||||
const coverCharacterImageSrcs = characterImageSrcs.slice(0, 3);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative overflow-hidden bg-[radial-gradient(circle_at_top,rgba(255,244,214,0.3),transparent_38%),linear-gradient(180deg,rgba(34,40,55,0.92),rgba(10,12,18,0.96))] ${className}`}
|
||||
>
|
||||
{imageSrc ? (
|
||||
<div className={`custom-world-cover-artwork relative overflow-hidden ${className}`}>
|
||||
{imageSrc || fallbackImageSrc ? (
|
||||
<ResolvedAssetImage
|
||||
src={imageSrc}
|
||||
fallbackSrc={fallbackImageSrc}
|
||||
alt={title}
|
||||
loading="lazy"
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
/>
|
||||
) : null}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(8,10,14,0.04),rgba(8,10,14,0.26)_46%,rgba(8,10,14,0.82)_100%)]" />
|
||||
{!imageSrc ? (
|
||||
{!imageSrc && !fallbackImageSrc ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center px-4 text-center text-sm font-semibold tracking-[0.18em] text-zinc-300">
|
||||
{fallbackLabel}
|
||||
</div>
|
||||
) : null}
|
||||
{renderMode === 'scene_with_roles' && coverCharacterImageSrcs.length > 0 ? (
|
||||
{renderMode === 'scene_with_roles' &&
|
||||
coverCharacterImageSrcs.length > 0 ? (
|
||||
<>
|
||||
<div className="absolute inset-x-0 bottom-0 h-[42%] bg-[linear-gradient(180deg,rgba(8,10,14,0)_0%,rgba(8,10,14,0.88)_100%)]" />
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex items-end justify-center gap-2 px-3 pb-2 sm:pb-3">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ImgHTMLAttributes } from 'react';
|
||||
import { type ImgHTMLAttributes, useEffect, useState } from 'react';
|
||||
|
||||
import { useResolvedAssetReadUrl } from '../hooks/useResolvedAssetReadUrl';
|
||||
|
||||
@@ -16,16 +16,42 @@ export function ResolvedAssetImage({
|
||||
fallbackSrc,
|
||||
alt,
|
||||
refreshKey,
|
||||
onError,
|
||||
...rest
|
||||
}: ResolvedAssetImageProps) {
|
||||
const { resolvedUrl } = useResolvedAssetReadUrl(src, {
|
||||
refreshKey,
|
||||
});
|
||||
const finalSrc = resolvedUrl || fallbackSrc?.trim() || '';
|
||||
const normalizedFallbackSrc = fallbackSrc?.trim() ?? '';
|
||||
const [useFallbackSrc, setUseFallbackSrc] = useState(false);
|
||||
const finalSrc =
|
||||
useFallbackSrc && normalizedFallbackSrc
|
||||
? normalizedFallbackSrc
|
||||
: resolvedUrl || normalizedFallbackSrc;
|
||||
|
||||
useEffect(() => {
|
||||
setUseFallbackSrc(false);
|
||||
}, [normalizedFallbackSrc, resolvedUrl]);
|
||||
|
||||
if (!finalSrc) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <img {...rest} src={finalSrc} alt={alt} />;
|
||||
return (
|
||||
<img
|
||||
{...rest}
|
||||
src={finalSrc}
|
||||
alt={alt}
|
||||
onError={(event) => {
|
||||
if (
|
||||
normalizedFallbackSrc &&
|
||||
!useFallbackSrc &&
|
||||
finalSrc !== normalizedFallbackSrc
|
||||
) {
|
||||
setUseFallbackSrc(true);
|
||||
}
|
||||
onError?.(event);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
161
src/components/bark-battle-creation/BarkBattleConfigEditor.tsx
Normal file
161
src/components/bark-battle-creation/BarkBattleConfigEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -5,9 +5,10 @@ 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 { derivePlatformCreationTypes } from '../platform-entry/platformEntryCreationTypes';
|
||||
import type { CreationEntryConfig } from '../../services/creationEntryConfigService';
|
||||
import { derivePlatformCreationTypes } from '../platform-entry/platformEntryCreationTypes';
|
||||
import { CustomWorldCreationHub } from './CustomWorldCreationHub';
|
||||
|
||||
const noopCreateType = () => {};
|
||||
@@ -61,10 +62,10 @@ const testEntryConfig = {
|
||||
id: 'visual-novel',
|
||||
title: '视觉小说',
|
||||
subtitle: '分支叙事体验',
|
||||
badge: '可创建',
|
||||
badge: '敬请期待',
|
||||
imageSrc: '/creation-type-references/visual-novel.webp',
|
||||
visible: true,
|
||||
open: true,
|
||||
visible: false,
|
||||
open: false,
|
||||
sortOrder: 60,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
@@ -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();
|
||||
@@ -443,7 +478,28 @@ test('creation hub shows RPG public work code from published library entry', ()
|
||||
expect(screen.queryByText('CW-00000001')).toBeNull();
|
||||
});
|
||||
|
||||
test('creation hub shows delete action for persisted rpg drafts', () => {
|
||||
test('creation hub hides persisted draft delete action behind swipe underlay', () => {
|
||||
const { container } = render(
|
||||
<CustomWorldCreationHub
|
||||
items={[{ ...baseDraftItem, profileId: 'profile-1' }]}
|
||||
loading={false}
|
||||
error={null}
|
||||
onRetry={() => {}}
|
||||
onCreateType={noopCreateType}
|
||||
onOpenDraft={() => {}}
|
||||
onEnterPublished={() => {}}
|
||||
onDeletePublished={() => {}}
|
||||
entryConfig={testEntryConfig}
|
||||
creationTypes={testCreationTypes}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.querySelector('.creation-work-card__swipe-underlay')).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: '删除' })).toBeNull();
|
||||
});
|
||||
|
||||
test('creation hub reveals persisted draft delete action from keyboard', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<CustomWorldCreationHub
|
||||
items={[{ ...baseDraftItem, profileId: 'profile-1' }]}
|
||||
@@ -459,9 +515,43 @@ test('creation hub shows delete action for persisted rpg drafts', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
screen.getByRole('button', { name: /继续完善《潮雾列岛》/u }).focus();
|
||||
await user.keyboard('{ArrowLeft}');
|
||||
|
||||
expect(screen.getByRole('button', { name: '删除' })).toBeTruthy();
|
||||
});
|
||||
|
||||
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();
|
||||
@@ -502,6 +592,12 @@ test('creation hub published work delete action is available beside share withou
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole('button', { name: '删除' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '分享' })).toBeNull();
|
||||
|
||||
screen.getByRole('button', { name: /查看详情《待删拼图》/u }).focus();
|
||||
await user.keyboard('{ArrowLeft}');
|
||||
|
||||
expect(screen.getByRole('button', { name: '删除' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '分享' })).toBeTruthy();
|
||||
|
||||
@@ -548,7 +644,7 @@ test('creation hub opens persisted rpg drafts by card click', async () => {
|
||||
expect(openedItems).toEqual([persistedDraft]);
|
||||
});
|
||||
|
||||
test('creation hub published share button copies share text without opening the card', async () => {
|
||||
test('creation hub published swipe share button copies share text without opening the card', async () => {
|
||||
const user = userEvent.setup();
|
||||
const writeText = vi.fn(async () => undefined);
|
||||
const onOpenPuzzleDetail = vi.fn();
|
||||
@@ -591,6 +687,8 @@ test('creation hub published share button copies share text without opening the
|
||||
/>,
|
||||
);
|
||||
|
||||
screen.getByRole('button', { name: /查看详情《沉钟拼图》/u }).focus();
|
||||
await user.keyboard('{ArrowLeft}');
|
||||
await user.click(screen.getByRole('button', { name: '分享' }));
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith(
|
||||
|
||||
@@ -56,10 +56,10 @@ const testEntryConfig = {
|
||||
id: 'visual-novel',
|
||||
title: '视觉小说',
|
||||
subtitle: '分支叙事体验',
|
||||
badge: '可创建',
|
||||
badge: '敬请期待',
|
||||
imageSrc: '/creation-type-references/visual-novel.webp',
|
||||
visible: true,
|
||||
open: true,
|
||||
visible: false,
|
||||
open: false,
|
||||
sortOrder: 60,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
@@ -217,9 +217,11 @@ test('creation hub marks generating and newly completed drafts', () => {
|
||||
|
||||
expect(html).toContain('生成中');
|
||||
expect(html).toContain('aria-label="新生成完成"');
|
||||
expect(html).toContain('生成中...');
|
||||
expect(html).toContain('creation-work-card__spinner');
|
||||
});
|
||||
|
||||
test('creation hub published work spans full mobile row', () => {
|
||||
test('creation hub published work uses unified list card layout', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<CustomWorldCreationHub
|
||||
items={[]}
|
||||
@@ -253,9 +255,10 @@ test('creation hub published work spans full mobile row', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('grid-cols-2');
|
||||
expect(html).toContain('col-span-2 sm:col-span-1');
|
||||
expect(html).not.toContain('grid-cols-1 gap-3 md:grid-cols-2');
|
||||
expect(html).toContain('creation-work-list');
|
||||
expect(html).toContain('platform-category-game-item');
|
||||
expect(html).toContain('creation-work-card__side-cover');
|
||||
expect(html).not.toContain('col-span-2 sm:col-span-1');
|
||||
});
|
||||
|
||||
test('creation hub draft cards use cover background and hide updated time', () => {
|
||||
@@ -318,9 +321,109 @@ test('creation hub draft cards use cover background and hide updated time', () =
|
||||
expect(html).toContain(
|
||||
'class="absolute inset-0 h-full w-full object-cover" src="/covers/new-draft.webp"',
|
||||
);
|
||||
expect(html).toContain('creation-work-card__side-cover');
|
||||
expect(html).toContain('src="/covers/new-draft.webp"');
|
||||
expect(html).toContain(
|
||||
'--creation-work-card-cover-fallback:url(/creation-type-references/puzzle.webp)',
|
||||
);
|
||||
expect(html).not.toContain('1778457601.234567Z');
|
||||
expect(html).not.toContain('2026-05-07');
|
||||
expect(html).not.toContain('更新于');
|
||||
expect(html).not.toContain('最后修改');
|
||||
});
|
||||
|
||||
test('creation hub draft cards fall back to creation type cover when cover is missing', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<CustomWorldCreationHub
|
||||
mode="works-only"
|
||||
items={[]}
|
||||
puzzleItems={[
|
||||
{
|
||||
workId: 'puzzle:no-cover-draft',
|
||||
profileId: 'puzzle-profile-no-cover',
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '测试作者',
|
||||
workTitle: '缺少封面的拼图草稿',
|
||||
workDescription: '没有生成封面时也需要保留图像背景。',
|
||||
levelName: '缺少封面的拼图草稿',
|
||||
summary: '没有生成封面时也需要保留图像背景。',
|
||||
themeTags: [],
|
||||
coverImageSrc: null,
|
||||
publicationStatus: 'draft',
|
||||
updatedAt: '2026-05-07T00:00:00.000Z',
|
||||
publishedAt: null,
|
||||
publishReady: false,
|
||||
},
|
||||
]}
|
||||
loading={false}
|
||||
error={null}
|
||||
onRetry={() => {}}
|
||||
onCreateType={noopCreateType}
|
||||
onOpenDraft={() => {}}
|
||||
onEnterPublished={() => {}}
|
||||
entryConfig={testEntryConfig}
|
||||
creationTypes={testCreationTypes}
|
||||
onOpenPuzzleDetail={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('缺少封面的拼图草稿');
|
||||
expect(html).toContain(
|
||||
'class="absolute inset-0 h-full w-full object-cover" src="/creation-type-references/puzzle.webp"',
|
||||
);
|
||||
expect(html).toContain(
|
||||
'--creation-work-card-cover-fallback:url(/creation-type-references/puzzle.webp)',
|
||||
);
|
||||
expect(html).not.toContain('>封面</div>');
|
||||
});
|
||||
|
||||
test('creation hub published card keeps publish info without fixed action text', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<CustomWorldCreationHub
|
||||
mode="works-only"
|
||||
items={[]}
|
||||
puzzleItems={[
|
||||
{
|
||||
workId: 'puzzle:published-card',
|
||||
profileId: 'puzzle-profile-published',
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '测试作者',
|
||||
workTitle: '统一卡片作品',
|
||||
workDescription: '作品卡仍要保留原有的积分与统计信息。',
|
||||
levelName: '统一卡片作品',
|
||||
summary: '作品卡仍要保留原有的积分与统计信息。',
|
||||
themeTags: ['潮雾'],
|
||||
coverImageSrc: '/covers/unified-card.webp',
|
||||
publicationStatus: 'published',
|
||||
updatedAt: '2026-05-07T00:00:00.000Z',
|
||||
publishedAt: '2026-05-07T00:00:00.000Z',
|
||||
playCount: 88,
|
||||
remixCount: 9,
|
||||
likeCount: 6,
|
||||
publishReady: true,
|
||||
pointIncentiveTotalPoints: 4,
|
||||
pointIncentiveClaimablePoints: 1,
|
||||
},
|
||||
]}
|
||||
loading={false}
|
||||
error={null}
|
||||
onRetry={() => {}}
|
||||
onCreateType={noopCreateType}
|
||||
onOpenDraft={() => {}}
|
||||
onEnterPublished={() => {}}
|
||||
entryConfig={testEntryConfig}
|
||||
creationTypes={testCreationTypes}
|
||||
onOpenPuzzleDetail={() => {}}
|
||||
onClaimPuzzlePointIncentive={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('积分激励');
|
||||
expect(html).toContain('待领取');
|
||||
expect(html).toContain('游玩');
|
||||
expect(html).toContain('改造');
|
||||
expect(html).toContain('点赞');
|
||||
expect(html).toContain('creation-work-card__side-cover');
|
||||
expect(html).not.toContain('creation-work-card__action');
|
||||
expect(html).not.toContain('>查看详情<');
|
||||
});
|
||||
|
||||
@@ -27,9 +27,8 @@ import {
|
||||
CustomWorldWorkTabs,
|
||||
} from './CustomWorldWorkTabs';
|
||||
|
||||
// 中文注释:草稿在手机端保持双列,已发布卡片由卡片自身跨两列展示公开指标。
|
||||
const WORK_GRID_CLASS =
|
||||
'grid grid-cols-2 gap-2.5 sm:gap-3 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4';
|
||||
'creation-work-list grid min-w-0 gap-3 sm:gap-3.5 xl:gap-4';
|
||||
const WORK_METRIC_CACHE_KEY = 'genarrative.creationHub.publishedMetrics.v1';
|
||||
|
||||
type WorkMetricSnapshot = Record<
|
||||
@@ -68,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;
|
||||
@@ -172,6 +172,7 @@ export function CustomWorldCreationHub({
|
||||
claimingPuzzleProfileId = null,
|
||||
babyObjectMatchItems = [],
|
||||
onOpenBabyObjectMatchDetail = null,
|
||||
onDeleteBabyObjectMatch = null,
|
||||
visualNovelItems = [],
|
||||
onOpenVisualNovelDetail = null,
|
||||
onDeleteVisualNovel = null,
|
||||
@@ -202,6 +203,7 @@ export function CustomWorldCreationHub({
|
||||
canDeleteSquareHole:
|
||||
isSquareHoleCreationVisible && Boolean(onDeleteSquareHole),
|
||||
canDeletePuzzle: Boolean(onDeletePuzzle),
|
||||
canDeleteBabyObjectMatch: Boolean(onDeleteBabyObjectMatch),
|
||||
canDeleteVisualNovel: Boolean(onDeleteVisualNovel),
|
||||
onOpenRpgDraft: onOpenDraft,
|
||||
onEnterRpgPublished: onEnterPublished,
|
||||
@@ -216,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,
|
||||
@@ -231,6 +234,7 @@ export function CustomWorldCreationHub({
|
||||
onDeleteSquareHole,
|
||||
onDeletePublished,
|
||||
onDeletePuzzle,
|
||||
onDeleteBabyObjectMatch,
|
||||
onDeleteVisualNovel,
|
||||
onClaimPuzzlePointIncentive,
|
||||
onOpenBigFishDetail,
|
||||
@@ -269,6 +273,7 @@ export function CustomWorldCreationHub({
|
||||
);
|
||||
|
||||
function handleOpenShelfItem(item: CreationWorkShelfItem) {
|
||||
onOpenShelfItem?.(item);
|
||||
switch (item.source.kind) {
|
||||
case 'puzzle':
|
||||
onOpenPuzzleDetail?.(item.source.item);
|
||||
@@ -379,8 +384,7 @@ export function CustomWorldCreationHub({
|
||||
metricSnapshot[buildWorkMetricCacheItemKey(item)]
|
||||
}
|
||||
onOpen={() => {
|
||||
onOpenShelfItem?.(item);
|
||||
item.actions.open();
|
||||
handleOpenShelfItem(item);
|
||||
}}
|
||||
onDelete={buildDeleteAction(item)}
|
||||
deleteBusy={deletingWorkId === item.id}
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { Share2, Trash2 } from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
type CSSProperties,
|
||||
type KeyboardEvent as ReactKeyboardEvent,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { copyTextToClipboard } from '../../services/clipboard';
|
||||
import { CustomWorldCoverArtwork } from '../CustomWorldCoverArtwork';
|
||||
@@ -10,6 +18,7 @@ import {
|
||||
import {
|
||||
type CreationWorkShelfBadgeTone,
|
||||
type CreationWorkShelfItem,
|
||||
type CreationWorkShelfKind,
|
||||
type CreationWorkShelfMetric,
|
||||
type CreationWorkShelfMetricId,
|
||||
formatCreationMetricCount,
|
||||
@@ -33,7 +42,20 @@ const BADGE_TONE_CLASS: Record<CreationWorkShelfBadgeTone, string> = {
|
||||
};
|
||||
|
||||
const METRIC_ANIMATION_DURATION_MS = 820;
|
||||
const SWIPE_ACTION_WIDTH_PX = 76;
|
||||
const SWIPE_REVEAL_THRESHOLD_PX = 42;
|
||||
const SWIPE_DIRECTION_LOCK_PX = 8;
|
||||
const EMPTY_PUBLISHED_METRICS: CreationWorkShelfMetric[] = [];
|
||||
const CREATION_WORK_KIND_FALLBACK_COVER: Record<CreationWorkShelfKind, string> =
|
||||
{
|
||||
rpg: '/creation-type-references/rpg.webp',
|
||||
'big-fish': '/creation-type-references/big-fish.webp',
|
||||
match3d: '/creation-type-references/match3d.webp',
|
||||
'square-hole': '/creation-type-references/square-hole.webp',
|
||||
puzzle: '/creation-type-references/puzzle.webp',
|
||||
'baby-object-match': '/creation-type-references/creative-agent.webp',
|
||||
'visual-novel': '/creation-type-references/visual-novel.webp',
|
||||
};
|
||||
|
||||
function easeOutCubic(progress: number) {
|
||||
return 1 - (1 - progress) ** 3;
|
||||
@@ -68,6 +90,10 @@ function shouldAnimatePublishedMetrics() {
|
||||
return !window.navigator.userAgent.toLowerCase().includes('jsdom');
|
||||
}
|
||||
|
||||
function clampSwipeOffset(value: number, revealWidth: number) {
|
||||
return Math.min(0, Math.max(-revealWidth, value));
|
||||
}
|
||||
|
||||
function usePublishedMetricAnimation(
|
||||
metrics: CreationWorkShelfMetric[],
|
||||
previousMetricValues?: Partial<Record<CreationWorkShelfMetricId, number>>,
|
||||
@@ -199,16 +225,82 @@ export function CustomWorldWorkCard({
|
||||
'idle',
|
||||
);
|
||||
const shareResetTimerRef = useRef<number | null>(null);
|
||||
const suppressOpenResetTimerRef = useRef<number | null>(null);
|
||||
const suppressOpenRef = useRef(false);
|
||||
const swipeGestureRef = useRef<{
|
||||
pointerId: number;
|
||||
startX: number;
|
||||
startY: number;
|
||||
startOffset: number;
|
||||
isDragging: boolean;
|
||||
} | null>(null);
|
||||
const lastSwipeOffsetRef = useRef(0);
|
||||
const [isSwipeDragging, setIsSwipeDragging] = useState(false);
|
||||
const [isSwipeActionRevealed, setIsSwipeActionRevealed] = useState(false);
|
||||
const [swipeOffset, setSwipeOffset] = useState(0);
|
||||
const isPublished = item.status === 'published';
|
||||
const canUseShareAction = isPublished && item.canShare && Boolean(item.sharePath);
|
||||
const swipeActionCount = (canUseShareAction ? 1 : 0) + (onDelete ? 1 : 0);
|
||||
const swipeRevealWidth = swipeActionCount * SWIPE_ACTION_WIDTH_PX;
|
||||
const canClaimPointIncentive =
|
||||
Boolean(onClaimPointIncentive) &&
|
||||
(item.pointIncentive?.claimablePoints ?? 0) > 0;
|
||||
const displayTitle = formatPlatformWorkDisplayName(item.title);
|
||||
const fallbackCoverImageSrc = CREATION_WORK_KIND_FALLBACK_COVER[item.kind];
|
||||
const { cardRef, deltas, displayValues, showGrowth } =
|
||||
usePublishedMetricAnimation(
|
||||
isPublished ? item.metrics : EMPTY_PUBLISHED_METRICS,
|
||||
previousMetricValues,
|
||||
);
|
||||
const surfaceOffset = isSwipeDragging
|
||||
? swipeOffset
|
||||
: isSwipeActionRevealed
|
||||
? -swipeRevealWidth
|
||||
: 0;
|
||||
const swipeActionOpacity =
|
||||
swipeRevealWidth > 0 ? Math.min(1, Math.abs(surfaceOffset) / swipeRevealWidth) : 0;
|
||||
const swipeSurfaceStyle = {
|
||||
'--creation-work-card-swipe-offset': `${surfaceOffset}px`,
|
||||
} as CSSProperties;
|
||||
const swipeShellStyle = {
|
||||
'--creation-work-card-action-opacity': `${swipeActionOpacity}`,
|
||||
} as CSSProperties;
|
||||
const sideCoverStyle = {
|
||||
'--creation-work-card-cover-fallback': `url(${fallbackCoverImageSrc})`,
|
||||
} as CSSProperties;
|
||||
|
||||
const closeSwipeActions = () => {
|
||||
setIsSwipeActionRevealed(false);
|
||||
setSwipeOffset(0);
|
||||
lastSwipeOffsetRef.current = 0;
|
||||
};
|
||||
|
||||
const revealSwipeActions = () => {
|
||||
if (swipeRevealWidth <= 0) {
|
||||
closeSwipeActions();
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSwipeActionRevealed(true);
|
||||
setSwipeOffset(-swipeRevealWidth);
|
||||
lastSwipeOffsetRef.current = -swipeRevealWidth;
|
||||
};
|
||||
|
||||
const scheduleOpenSuppressReset = () => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (suppressOpenResetTimerRef.current !== null) {
|
||||
window.clearTimeout(suppressOpenResetTimerRef.current);
|
||||
}
|
||||
|
||||
suppressOpenResetTimerRef.current = window.setTimeout(() => {
|
||||
suppressOpenResetTimerRef.current = null;
|
||||
suppressOpenRef.current = false;
|
||||
}, 260);
|
||||
};
|
||||
|
||||
const copyShareText = () => {
|
||||
const publicWorkCode = item.publicWorkCode?.trim();
|
||||
const sharePath = item.sharePath?.trim();
|
||||
@@ -237,210 +329,399 @@ export function CustomWorldWorkCard({
|
||||
if (shareResetTimerRef.current !== null) {
|
||||
window.clearTimeout(shareResetTimerRef.current);
|
||||
}
|
||||
if (suppressOpenResetTimerRef.current !== null) {
|
||||
window.clearTimeout(suppressOpenResetTimerRef.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (swipeActionCount > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
closeSwipeActions();
|
||||
}, [swipeActionCount]);
|
||||
|
||||
const beginSwipeGesture = (
|
||||
event: ReactPointerEvent<HTMLDivElement>,
|
||||
) => {
|
||||
if (swipeRevealWidth <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.pointerType === 'mouse' && event.button !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
swipeGestureRef.current = {
|
||||
pointerId: event.pointerId,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
startOffset: isSwipeActionRevealed ? -swipeRevealWidth : 0,
|
||||
isDragging: false,
|
||||
};
|
||||
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||||
};
|
||||
|
||||
const updateSwipeGesture = (
|
||||
event: ReactPointerEvent<HTMLDivElement>,
|
||||
) => {
|
||||
const gesture = swipeGestureRef.current;
|
||||
if (!gesture || gesture.pointerId !== event.pointerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const deltaX = event.clientX - gesture.startX;
|
||||
const deltaY = event.clientY - gesture.startY;
|
||||
if (!gesture.isDragging) {
|
||||
if (
|
||||
Math.abs(deltaX) < SWIPE_DIRECTION_LOCK_PX &&
|
||||
Math.abs(deltaY) < SWIPE_DIRECTION_LOCK_PX
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Math.abs(deltaY) > Math.abs(deltaX)) {
|
||||
swipeGestureRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
gesture.isDragging = true;
|
||||
setIsSwipeDragging(true);
|
||||
}
|
||||
|
||||
// 中文注释:横向手势只移动卡片表层,删除动作保持在底层,避免列表滚动时误触。
|
||||
event.preventDefault();
|
||||
suppressOpenRef.current = true;
|
||||
const nextOffset = clampSwipeOffset(
|
||||
gesture.startOffset + deltaX,
|
||||
swipeRevealWidth,
|
||||
);
|
||||
lastSwipeOffsetRef.current = nextOffset;
|
||||
setSwipeOffset(nextOffset);
|
||||
};
|
||||
|
||||
const endSwipeGesture = (
|
||||
event: ReactPointerEvent<HTMLDivElement>,
|
||||
) => {
|
||||
const gesture = swipeGestureRef.current;
|
||||
if (!gesture || gesture.pointerId !== event.pointerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.currentTarget.releasePointerCapture?.(event.pointerId);
|
||||
swipeGestureRef.current = null;
|
||||
setIsSwipeDragging(false);
|
||||
|
||||
if (!gesture.isDragging) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldReveal =
|
||||
lastSwipeOffsetRef.current <=
|
||||
-Math.min(SWIPE_REVEAL_THRESHOLD_PX, swipeRevealWidth * 0.45);
|
||||
if (shouldReveal) {
|
||||
revealSwipeActions();
|
||||
} else {
|
||||
closeSwipeActions();
|
||||
}
|
||||
suppressOpenRef.current = true;
|
||||
scheduleOpenSuppressReset();
|
||||
};
|
||||
|
||||
const cancelSwipeGesture = (
|
||||
event: ReactPointerEvent<HTMLDivElement>,
|
||||
) => {
|
||||
const gesture = swipeGestureRef.current;
|
||||
if (gesture?.pointerId === event.pointerId) {
|
||||
event.currentTarget.releasePointerCapture?.(event.pointerId);
|
||||
}
|
||||
|
||||
swipeGestureRef.current = null;
|
||||
setIsSwipeDragging(false);
|
||||
if (isSwipeActionRevealed) {
|
||||
revealSwipeActions();
|
||||
} else {
|
||||
closeSwipeActions();
|
||||
}
|
||||
};
|
||||
|
||||
const handleCardOpen = () => {
|
||||
if (isSwipeActionRevealed) {
|
||||
closeSwipeActions();
|
||||
return;
|
||||
}
|
||||
|
||||
onOpen();
|
||||
};
|
||||
|
||||
const handleCardKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === 'ArrowLeft' && swipeRevealWidth > 0) {
|
||||
event.preventDefault();
|
||||
revealSwipeActions();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Escape' && isSwipeActionRevealed) {
|
||||
event.preventDefault();
|
||||
closeSwipeActions();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key !== 'Enter' && event.key !== ' ') {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
handleCardOpen();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={cardRef}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`${item.openActionLabel}《${item.title}》`}
|
||||
onClick={onOpen}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
onOpen();
|
||||
}}
|
||||
className={`creation-work-card platform-surface platform-interactive-card relative min-h-[9.5rem] cursor-pointer overflow-hidden px-2.5 py-2.5 text-left sm:min-h-[12.5rem] sm:px-4 sm:py-4 xl:min-h-[11.25rem] xl:px-4 xl:py-3.5 ${isPublished ? 'col-span-2 sm:col-span-1' : ''}`}
|
||||
style={swipeShellStyle}
|
||||
className={`creation-work-card-shell ${
|
||||
isSwipeDragging || isSwipeActionRevealed
|
||||
? 'creation-work-card-shell--actions-visible'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<CustomWorldCoverArtwork
|
||||
imageSrc={item.coverImageSrc}
|
||||
title={item.title}
|
||||
fallbackLabel="封面"
|
||||
renderMode={item.coverRenderMode}
|
||||
characterImageSrcs={item.coverCharacterImageSrcs}
|
||||
className="platform-cover-artwork absolute inset-0"
|
||||
/>
|
||||
<div className="creation-work-card__overlay absolute inset-0" />
|
||||
{item.hasUnreadUpdate ? (
|
||||
<span
|
||||
aria-label="新生成完成"
|
||||
className="pointer-events-none absolute right-2 top-2 z-30 h-2.5 w-2.5 rounded-full bg-red-500 shadow-[0_0_0_3px_rgba(255,255,255,0.26),0_0_14px_rgba(239,68,68,0.75)]"
|
||||
/>
|
||||
) : null}
|
||||
<div className="pointer-events-none relative z-20 flex min-h-[8rem] flex-col sm:min-h-[10.5rem] xl:min-h-[9.75rem]">
|
||||
<div className="pointer-events-auto absolute right-0 top-0 z-30 flex items-center gap-1">
|
||||
{onDelete ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
disabled={deleteBusy}
|
||||
aria-label={deleteBusy ? '删除中' : '删除'}
|
||||
title={deleteBusy ? '删除中' : '删除作品'}
|
||||
className="grid h-7 w-7 place-items-center rounded-full bg-black/22 text-white/78 transition hover:bg-red-500/22 hover:text-white disabled:cursor-not-allowed disabled:opacity-55 sm:h-8 sm:w-8"
|
||||
>
|
||||
{deleteBusy ? (
|
||||
<span className="text-xs leading-none">…</span>
|
||||
) : (
|
||||
<Trash2 aria-hidden="true" className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
{isPublished ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
copyShareText();
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
disabled={!item.canShare || !item.sharePath}
|
||||
title={
|
||||
!item.canShare || !item.sharePath
|
||||
? '暂不可分享'
|
||||
: shareState === 'copied'
|
||||
{swipeActionCount > 0 ? (
|
||||
<div
|
||||
aria-hidden={!isSwipeActionRevealed}
|
||||
className="creation-work-card__swipe-underlay"
|
||||
>
|
||||
<div className="creation-work-card__swipe-actions">
|
||||
{canUseShareAction ? (
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={isSwipeActionRevealed ? 0 : -1}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
suppressOpenRef.current = false;
|
||||
copyShareText();
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
title={
|
||||
shareState === 'copied'
|
||||
? '已复制'
|
||||
: shareState === 'failed'
|
||||
? '复制失败'
|
||||
: '分享作品'
|
||||
}
|
||||
aria-label={
|
||||
!item.canShare || !item.sharePath
|
||||
? '暂不可分享'
|
||||
: shareState === 'copied'
|
||||
}
|
||||
aria-label={
|
||||
shareState === 'copied'
|
||||
? '分享内容已复制'
|
||||
: shareState === 'failed'
|
||||
? '分享内容复制失败'
|
||||
: '分享'
|
||||
}
|
||||
className="inline-flex h-7 min-w-7 items-center justify-center gap-1 whitespace-nowrap rounded-full bg-black/22 px-1.5 text-white/78 transition hover:bg-white/18 hover:text-white disabled:cursor-not-allowed disabled:opacity-55 sm:h-8 sm:min-w-8"
|
||||
}
|
||||
className="creation-work-card__swipe-button creation-work-card__swipe-button--share"
|
||||
>
|
||||
{shareState === 'idle' ? (
|
||||
<Share2 aria-hidden="true" className="h-4 w-4" />
|
||||
) : (
|
||||
<span className="text-[10px] font-semibold leading-none">
|
||||
{shareState === 'copied' ? '已复制' : '复制失败'}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
{onDelete ? (
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={isSwipeActionRevealed ? 0 : -1}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
suppressOpenRef.current = false;
|
||||
closeSwipeActions();
|
||||
onDelete();
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
disabled={deleteBusy}
|
||||
aria-label={deleteBusy ? '删除中' : '删除'}
|
||||
title={deleteBusy ? '删除中' : '删除作品'}
|
||||
className="creation-work-card__swipe-button creation-work-card__swipe-button--danger"
|
||||
>
|
||||
{deleteBusy ? (
|
||||
<span className="text-xs leading-none">...</span>
|
||||
) : (
|
||||
<Trash2 aria-hidden="true" className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`${item.openActionLabel}《${item.title}》${item.isGenerating ? ',生成中' : ''}`}
|
||||
onClick={(event) => {
|
||||
if (suppressOpenRef.current) {
|
||||
event.preventDefault();
|
||||
suppressOpenRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
handleCardOpen();
|
||||
}}
|
||||
onKeyDown={handleCardKeyDown}
|
||||
onPointerDown={beginSwipeGesture}
|
||||
onPointerMove={updateSwipeGesture}
|
||||
onPointerUp={endSwipeGesture}
|
||||
onPointerCancel={cancelSwipeGesture}
|
||||
style={swipeSurfaceStyle}
|
||||
className={`creation-work-card platform-category-game-item platform-interactive-card cursor-pointer overflow-hidden text-left ${isPublished ? 'creation-work-card--published' : 'creation-work-card--draft'} ${item.isGenerating ? 'creation-work-card--generating' : ''} ${isSwipeDragging ? 'creation-work-card--swiping' : ''}`}
|
||||
>
|
||||
<div className="creation-work-card__body platform-category-game-item__body">
|
||||
<div className="creation-work-card__title-row platform-category-game-item__title-row">
|
||||
<span className="creation-work-card__title platform-category-game-item__title">
|
||||
{displayTitle}
|
||||
</span>
|
||||
<span
|
||||
className={`creation-work-card__status-pill creation-work-card__status-pill--${
|
||||
item.isGenerating ? 'generating' : item.status
|
||||
}`}
|
||||
>
|
||||
{shareState === 'idle' ? (
|
||||
<Share2 aria-hidden="true" className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<span className="text-[10px] font-semibold leading-none">
|
||||
{shareState === 'copied' ? '已复制' : '复制失败'}
|
||||
{item.isGenerating
|
||||
? '生成中'
|
||||
: item.status === 'published'
|
||||
? '已发布'
|
||||
: '草稿'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="creation-work-card__meta platform-category-game-item__meta">
|
||||
{item.badges
|
||||
.slice(1)
|
||||
.map((badge) => (
|
||||
<span
|
||||
key={`${item.id}-${badge.id}`}
|
||||
className={`creation-work-card__badge platform-pill ${BADGE_TONE_CLASS[badge.tone]}`}
|
||||
>
|
||||
{formatPlatformWorkDisplayTag(badge.label)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="creation-work-card__summary platform-category-game-item__summary">
|
||||
{item.summary}
|
||||
</div>
|
||||
|
||||
{isPublished ? (
|
||||
<div className="creation-work-card__published-info">
|
||||
{item.pointIncentive ? (
|
||||
<div className="creation-work-card-incentive">
|
||||
<div
|
||||
aria-label={`积分激励总数 ${formatCreationPointIncentiveTotal(item.pointIncentive.totalPoints)} 泥点`}
|
||||
className="creation-work-card-incentive__metric"
|
||||
>
|
||||
<span className="creation-work-card-incentive__label">
|
||||
积分激励
|
||||
</span>
|
||||
<span className="creation-work-card-incentive__value">
|
||||
{formatCreationPointIncentiveTotal(
|
||||
item.pointIncentive.totalPoints,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
aria-label={`待领取积分 ${item.pointIncentive.claimablePoints} 泥点`}
|
||||
className="creation-work-card-incentive__metric"
|
||||
>
|
||||
<span className="creation-work-card-incentive__label">
|
||||
待领取
|
||||
</span>
|
||||
<span className="creation-work-card-incentive__value">
|
||||
{formatCreationMetricCount(
|
||||
item.pointIncentive.claimablePoints,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canClaimPointIncentive || pointIncentiveBusy}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onClaimPointIncentive?.();
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
className="creation-work-card-incentive__button"
|
||||
>
|
||||
{pointIncentiveBusy ? '领取中' : '领取积分'}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="creation-work-card__metrics">
|
||||
{item.metrics.map((metric) => (
|
||||
<div
|
||||
key={`${item.id}-${metric.id}`}
|
||||
aria-label={`${metric.label} ${displayValues[metric.id] ?? metric.value}${metric.unit}`}
|
||||
className={`creation-work-card-stat creation-work-card-stat--${metric.tone}`}
|
||||
>
|
||||
<span className="creation-work-card-stat__label">
|
||||
{metric.label}
|
||||
</span>
|
||||
<span className="creation-work-card-stat__value">
|
||||
<span className="creation-work-card-stat__number">
|
||||
{formatCreationMetricCount(
|
||||
displayValues[metric.id] ?? metric.value,
|
||||
)}
|
||||
</span>
|
||||
<span className="creation-work-card-stat__unit">
|
||||
{metric.unit}
|
||||
</span>
|
||||
</span>
|
||||
{showGrowth && deltas[metric.id] > 0 ? (
|
||||
<span className="creation-work-card-stat__growth">
|
||||
<span aria-hidden="true">↑</span>
|
||||
{formatCreationMetricCount(deltas[metric.id])}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between gap-2 pr-12 sm:gap-3 sm:pr-14">
|
||||
<div className="flex max-h-[3rem] min-w-0 flex-wrap gap-1 overflow-hidden sm:max-h-none sm:gap-2">
|
||||
{item.isGenerating ? (
|
||||
<span className="platform-pill platform-pill--cool max-w-full truncate px-2 py-0.5 text-[9px] sm:px-3 sm:py-1 sm:text-[10px]">
|
||||
生成中
|
||||
</span>
|
||||
) : null}
|
||||
{item.badges.map((badge) => (
|
||||
<span
|
||||
key={`${item.id}-${badge.id}`}
|
||||
className={`platform-pill ${BADGE_TONE_CLASS[badge.tone]} max-w-full truncate px-2 py-0.5 text-[9px] sm:px-3 sm:py-1 sm:text-[10px]`}
|
||||
>
|
||||
{formatPlatformWorkDisplayTag(badge.label)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
className="creation-work-card__side-cover"
|
||||
style={sideCoverStyle}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<CustomWorldCoverArtwork
|
||||
imageSrc={item.coverImageSrc}
|
||||
fallbackImageSrc={fallbackCoverImageSrc}
|
||||
title={item.title}
|
||||
fallbackLabel="封面"
|
||||
renderMode={item.coverRenderMode}
|
||||
characterImageSrcs={item.coverCharacterImageSrcs}
|
||||
className="absolute inset-0"
|
||||
/>
|
||||
</div>
|
||||
{item.hasUnreadUpdate ? (
|
||||
<span
|
||||
aria-label="新生成完成"
|
||||
className="creation-work-card__unread-dot"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="mt-3 min-h-0 sm:mt-4 xl:mt-3">
|
||||
<div className="line-clamp-1 break-words text-base font-black leading-tight text-white [text-shadow:0_2px_12px_rgba(0,0,0,0.52)] sm:text-2xl xl:text-xl">
|
||||
{displayTitle}
|
||||
</div>
|
||||
<div className="mt-2 line-clamp-2 break-words text-[11px] leading-4 text-white/84 [text-shadow:0_1px_8px_rgba(0,0,0,0.5)] sm:mt-3 sm:text-sm sm:leading-6 xl:mt-2">
|
||||
{item.summary}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isPublished ? (
|
||||
<div className="mt-auto space-y-2 pt-3 sm:pt-4 xl:pt-3">
|
||||
{item.pointIncentive ? (
|
||||
<div className="creation-work-card-incentive">
|
||||
<div
|
||||
aria-label={`积分激励总数 ${formatCreationPointIncentiveTotal(item.pointIncentive.totalPoints)} 泥点`}
|
||||
className="creation-work-card-incentive__metric"
|
||||
>
|
||||
<span className="creation-work-card-incentive__label">
|
||||
积分激励
|
||||
</span>
|
||||
<span className="creation-work-card-incentive__value">
|
||||
{formatCreationPointIncentiveTotal(
|
||||
item.pointIncentive.totalPoints,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
aria-label={`待领取积分 ${item.pointIncentive.claimablePoints} 泥点`}
|
||||
className="creation-work-card-incentive__metric"
|
||||
>
|
||||
<span className="creation-work-card-incentive__label">
|
||||
待领取
|
||||
</span>
|
||||
<span className="creation-work-card-incentive__value">
|
||||
{formatCreationMetricCount(
|
||||
item.pointIncentive.claimablePoints,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canClaimPointIncentive || pointIncentiveBusy}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onClaimPointIncentive?.();
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
className="pointer-events-auto creation-work-card-incentive__button"
|
||||
>
|
||||
{pointIncentiveBusy ? '领取中' : '领取积分'}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid grid-cols-3 gap-1.5 sm:gap-2">
|
||||
{item.metrics.map((metric) => (
|
||||
<div
|
||||
key={`${item.id}-${metric.id}`}
|
||||
aria-label={`${metric.label} ${displayValues[metric.id] ?? metric.value}${metric.unit}`}
|
||||
className={`creation-work-card-stat creation-work-card-stat--${metric.tone}`}
|
||||
>
|
||||
<span className="creation-work-card-stat__label">
|
||||
{metric.label}
|
||||
</span>
|
||||
<span className="creation-work-card-stat__value">
|
||||
<span className="creation-work-card-stat__number">
|
||||
{formatCreationMetricCount(
|
||||
displayValues[metric.id] ?? metric.value,
|
||||
)}
|
||||
</span>
|
||||
<span className="creation-work-card-stat__unit">
|
||||
{metric.unit}
|
||||
</span>
|
||||
</span>
|
||||
{showGrowth && deltas[metric.id] > 0 ? (
|
||||
<span className="creation-work-card-stat__growth">
|
||||
<span aria-hidden="true">↑</span>
|
||||
{formatCreationMetricCount(deltas[metric.id])}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{item.isGenerating ? (
|
||||
<div className="creation-work-card__generating-mask" aria-hidden="true">
|
||||
<span className="creation-work-card__spinner" />
|
||||
<span>生成中...</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -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', () => {
|
||||
@@ -290,6 +302,148 @@ test('buildCreationWorkShelfItems falls back to available gameplay images as cov
|
||||
);
|
||||
});
|
||||
|
||||
test('buildCreationWorkShelfItems uses generated object keys as cover sources', () => {
|
||||
const items = buildCreationWorkShelfItems({
|
||||
rpgItems: [],
|
||||
bigFishItems: [],
|
||||
puzzleItems: [
|
||||
{
|
||||
workId: 'puzzle:level-object-key',
|
||||
profileId: 'puzzle-profile-level-object-key',
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '测试作者',
|
||||
levelName: '关卡对象拼图',
|
||||
summary: '作品摘要带关卡图对象路径时用关卡图做卡片背景。',
|
||||
themeTags: [],
|
||||
coverImageSrc: null,
|
||||
publicationStatus: 'draft',
|
||||
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||
publishedAt: null,
|
||||
publishReady: false,
|
||||
levels: [
|
||||
{
|
||||
levelId: 'level-1',
|
||||
levelName: '第一关',
|
||||
pictureDescription: '港口雨夜。',
|
||||
candidates: [
|
||||
{
|
||||
candidateId: 'candidate-1',
|
||||
imageSrc: '',
|
||||
assetId: 'asset-1',
|
||||
prompt: '港口雨夜',
|
||||
sourceType: 'generated',
|
||||
selected: true,
|
||||
},
|
||||
],
|
||||
selectedCandidateId: 'candidate-1',
|
||||
coverImageSrc:
|
||||
'generated-puzzle-assets/session/profile/level-cover.png',
|
||||
coverAssetId: null,
|
||||
generationStatus: 'ready',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
match3dItems: [
|
||||
{
|
||||
workId: 'match3d:object-key-cover',
|
||||
profileId: 'match3d-profile-object-key-cover',
|
||||
ownerUserId: 'user-1',
|
||||
gameName: '对象路径抓鹅',
|
||||
themeText: '糖果厨房',
|
||||
summary: '背景图或物品图只有 object key 时也应展示。',
|
||||
tags: [],
|
||||
coverImageSrc: null,
|
||||
clearCount: 18,
|
||||
difficulty: 1,
|
||||
publicationStatus: 'draft',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-05-07T00:00:00.000Z',
|
||||
publishReady: false,
|
||||
generatedBackgroundAsset: {
|
||||
prompt: '糖果厨房背景',
|
||||
imageObjectKey:
|
||||
'generated-match3d-assets/session/profile/background/image.png',
|
||||
containerImageObjectKey:
|
||||
'generated-match3d-assets/session/profile/background/container.png',
|
||||
status: 'ready',
|
||||
},
|
||||
generatedItemAssets: [
|
||||
{
|
||||
itemId: 'item-1',
|
||||
itemName: '糖果',
|
||||
imageObjectKey:
|
||||
'generated-match3d-assets/session/profile/items/item-1/image.png',
|
||||
imageViews: [
|
||||
{
|
||||
viewId: 'view-1',
|
||||
viewIndex: 1,
|
||||
imageObjectKey:
|
||||
'generated-match3d-assets/session/profile/items/item-1/views/view-1.png',
|
||||
},
|
||||
],
|
||||
status: 'image_ready',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(items.find((item) => item.kind === 'puzzle')?.coverImageSrc).toBe(
|
||||
'generated-puzzle-assets/session/profile/level-cover.png',
|
||||
);
|
||||
expect(items.find((item) => item.kind === 'match3d')?.coverImageSrc).toBe(
|
||||
'generated-match3d-assets/session/profile/background/image.png',
|
||||
);
|
||||
});
|
||||
|
||||
test('buildCreationWorkShelfItems falls back to match3d item object key without background', () => {
|
||||
const items = buildCreationWorkShelfItems({
|
||||
rpgItems: [],
|
||||
bigFishItems: [],
|
||||
puzzleItems: [],
|
||||
match3dItems: [
|
||||
{
|
||||
workId: 'match3d:item-object-key-cover',
|
||||
profileId: 'match3d-profile-item-object-key-cover',
|
||||
ownerUserId: 'user-1',
|
||||
gameName: '物品对象路径抓鹅',
|
||||
themeText: '糖果厨房',
|
||||
summary: '背景图缺失时用物品视角图对象路径。',
|
||||
tags: [],
|
||||
coverImageSrc: null,
|
||||
clearCount: 18,
|
||||
difficulty: 1,
|
||||
publicationStatus: 'draft',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-05-07T00:00:00.000Z',
|
||||
publishReady: false,
|
||||
generatedItemAssets: [
|
||||
{
|
||||
itemId: 'item-1',
|
||||
itemName: '糖果',
|
||||
imageObjectKey:
|
||||
'generated-match3d-assets/session/profile/items/item-1/image.png',
|
||||
imageViews: [
|
||||
{
|
||||
viewId: 'view-1',
|
||||
viewIndex: 1,
|
||||
imageObjectKey:
|
||||
'generated-match3d-assets/session/profile/items/item-1/views/view-1.png',
|
||||
},
|
||||
],
|
||||
status: 'image_ready',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(items.find((item) => item.kind === 'match3d')?.coverImageSrc).toBe(
|
||||
'generated-match3d-assets/session/profile/items/item-1/views/view-1.png',
|
||||
);
|
||||
});
|
||||
|
||||
test('getCreationWorkShelfItemTime parses backend seconds.microsZ values', () => {
|
||||
expect(getCreationWorkShelfItemTime('1778457601.234567Z')).toBe(
|
||||
1778457601234.567,
|
||||
|
||||
@@ -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),
|
||||
@@ -621,6 +627,11 @@ function resolvePuzzleWorkCoverImageSrc(item: PuzzleWorkSummary) {
|
||||
}
|
||||
|
||||
for (const level of item.levels ?? []) {
|
||||
const levelCoverImageSrc = normalizeCoverImageSrc(level.coverImageSrc);
|
||||
if (levelCoverImageSrc) {
|
||||
return levelCoverImageSrc;
|
||||
}
|
||||
|
||||
const selectedCandidateImageSrc =
|
||||
level.selectedCandidateId && level.candidates.length > 0
|
||||
? normalizeCoverImageSrc(
|
||||
@@ -632,13 +643,10 @@ function resolvePuzzleWorkCoverImageSrc(item: PuzzleWorkSummary) {
|
||||
const fallbackCandidateImageSrc = normalizeCoverImageSrc(
|
||||
level.candidates[level.candidates.length - 1]?.imageSrc,
|
||||
);
|
||||
const levelCoverImageSrc =
|
||||
selectedCandidateImageSrc ||
|
||||
normalizeCoverImageSrc(level.coverImageSrc) ||
|
||||
fallbackCandidateImageSrc;
|
||||
const candidateImageSrc = selectedCandidateImageSrc || fallbackCandidateImageSrc;
|
||||
|
||||
if (levelCoverImageSrc) {
|
||||
return levelCoverImageSrc;
|
||||
if (candidateImageSrc) {
|
||||
return candidateImageSrc;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -653,18 +661,27 @@ function resolveMatch3DWorkCoverImageSrc(item: Match3DWorkSummary) {
|
||||
|
||||
const backgroundImageSrc =
|
||||
normalizeCoverImageSrc(item.backgroundImageSrc) ||
|
||||
normalizeCoverImageSrc(item.backgroundImageObjectKey) ||
|
||||
normalizeCoverImageSrc(item.generatedBackgroundAsset?.imageSrc) ||
|
||||
normalizeCoverImageSrc(item.generatedBackgroundAsset?.containerImageSrc);
|
||||
normalizeCoverImageSrc(item.generatedBackgroundAsset?.imageObjectKey) ||
|
||||
normalizeCoverImageSrc(item.generatedBackgroundAsset?.containerImageSrc) ||
|
||||
normalizeCoverImageSrc(item.generatedBackgroundAsset?.containerImageObjectKey);
|
||||
if (backgroundImageSrc) {
|
||||
return backgroundImageSrc;
|
||||
}
|
||||
|
||||
for (const asset of item.generatedItemAssets ?? []) {
|
||||
const imageViewSrc = normalizeCoverImageSrc(
|
||||
asset.imageViews?.find((view) => normalizeCoverImageSrc(view.imageSrc))
|
||||
?.imageSrc,
|
||||
const imageView = asset.imageViews?.find(
|
||||
(view) =>
|
||||
normalizeCoverImageSrc(view.imageSrc) ||
|
||||
normalizeCoverImageSrc(view.imageObjectKey),
|
||||
);
|
||||
const itemImageSrc = normalizeCoverImageSrc(asset.imageSrc);
|
||||
const imageViewSrc =
|
||||
normalizeCoverImageSrc(imageView?.imageSrc) ||
|
||||
normalizeCoverImageSrc(imageView?.imageObjectKey);
|
||||
const itemImageSrc =
|
||||
normalizeCoverImageSrc(asset.imageSrc) ||
|
||||
normalizeCoverImageSrc(asset.imageObjectKey);
|
||||
if (imageViewSrc || itemImageSrc) {
|
||||
return imageViewSrc || itemImageSrc;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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%');
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
135
src/components/edutainment-runtime/babyLoveDrawingModel.ts
Normal file
135
src/components/edutainment-runtime/babyLoveDrawingModel.ts
Normal 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;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import type { Match3DAgentSessionSnapshot } from '../../../packages/shared/src/contracts/match3dAgent';
|
||||
@@ -57,6 +57,14 @@ const baseSession: Match3DAgentSessionSnapshot = {
|
||||
updatedAt: '2026-05-10T10:00:00.000Z',
|
||||
};
|
||||
|
||||
function confirmMatch3DPointCost() {
|
||||
const confirmDialog = screen.getByRole('dialog', {
|
||||
name: '确认消耗泥点',
|
||||
});
|
||||
expect(within(confirmDialog).getByText('消耗 10 泥点')).toBeTruthy();
|
||||
fireEvent.click(within(confirmDialog).getByRole('button', { name: '确定' }));
|
||||
}
|
||||
|
||||
test('match3d workspace submits derived entry form payload instead of agent chat', () => {
|
||||
const onCreateFromForm = vi.fn();
|
||||
const onExecuteAction = vi.fn();
|
||||
@@ -90,6 +98,9 @@ test('match3d workspace submits derived entry form payload instead of agent chat
|
||||
fireEvent.click(screen.getByRole('button', { name: '进阶' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /生成抓大鹅草稿/u }));
|
||||
|
||||
expect(onCreateFromForm).not.toHaveBeenCalled();
|
||||
confirmMatch3DPointCost();
|
||||
|
||||
expect(onCreateFromForm).toHaveBeenCalledWith({
|
||||
seedText: '赛博水果摊题材,消除16次,难度6',
|
||||
themeText: '赛博水果摊',
|
||||
@@ -128,6 +139,7 @@ test('match3d workspace supports custom 2d asset style prompt', () => {
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /生成抓大鹅草稿/u }));
|
||||
confirmMatch3DPointCost();
|
||||
|
||||
expect(onCreateFromForm).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -159,6 +171,7 @@ test('match3d workspace submits strict pixel-retro style prompt', () => {
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '像素复古' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /生成抓大鹅草稿/u }));
|
||||
confirmMatch3DPointCost();
|
||||
|
||||
expect(onCreateFromForm).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -190,6 +203,7 @@ test('match3d workspace keeps click sound generation disabled from entry form',
|
||||
target: { value: '海岛甜品' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /生成抓大鹅草稿/u }));
|
||||
confirmMatch3DPointCost();
|
||||
|
||||
expect(onCreateFromForm).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -224,6 +238,7 @@ test('match3d workspace falls back to compile action when restored from the lega
|
||||
).toBe('true');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /生成抓大鹅草稿/u }));
|
||||
confirmMatch3DPointCost();
|
||||
|
||||
expect(onExecuteAction).toHaveBeenCalledWith({
|
||||
action: 'match3d_compile_draft',
|
||||
|
||||
@@ -52,7 +52,7 @@ const MATCH3D_ASSET_STYLE_OPTIONS = [
|
||||
label: '扁平图标',
|
||||
imageSrc: '/match3d-style-references/flat-icon.png',
|
||||
prompt:
|
||||
'干净扁平的2D游戏道具图标风格,正面视角,色块清楚,边缘硬朗。',
|
||||
'干净扁平的 2D 游戏道具图标风格,正面视角,色块清楚,边缘硬朗,高可读性,适合移动端休闲游戏素材。',
|
||||
},
|
||||
{
|
||||
id: 'cel-cartoon',
|
||||
@@ -63,10 +63,10 @@ const MATCH3D_ASSET_STYLE_OPTIONS = [
|
||||
},
|
||||
{
|
||||
id: 'pixel-retro',
|
||||
label: '像素',
|
||||
label: '像素复古',
|
||||
imageSrc: '/match3d-style-references/pixel-retro.png',
|
||||
prompt:
|
||||
'像素2D游戏道具sprite风格',
|
||||
'64x64 复古像素 2D 游戏道具 sprite 风格,限制调色板,硬像素边缘,清晰正面剪影,禁止抗锯齿,禁止柔光渐变,透明背景。',
|
||||
},
|
||||
{
|
||||
id: 'watercolor',
|
||||
@@ -206,6 +206,7 @@ export function Match3DAgentWorkspace({
|
||||
resolveInitialFormState(session, initialFormPayload),
|
||||
);
|
||||
const [isCustomStylePanelOpen, setIsCustomStylePanelOpen] = useState(false);
|
||||
const [isPointCostConfirmOpen, setIsPointCostConfirmOpen] = useState(false);
|
||||
const [draftCustomStylePrompt, setDraftCustomStylePrompt] = useState('');
|
||||
const appliedInitialFormKeyRef = useRef<string | null>(null);
|
||||
|
||||
@@ -219,6 +220,7 @@ export function Match3DAgentWorkspace({
|
||||
appliedInitialFormKeyRef.current = nextInitialFormKey;
|
||||
setFormState(resolveInitialFormState(session, initialFormPayload));
|
||||
setIsCustomStylePanelOpen(false);
|
||||
setIsPointCostConfirmOpen(false);
|
||||
setDraftCustomStylePrompt('');
|
||||
}, [initialFormPayload, session]);
|
||||
|
||||
@@ -283,12 +285,22 @@ export function Match3DAgentWorkspace({
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPointCostConfirmOpen(true);
|
||||
};
|
||||
|
||||
const executeSubmitForm = () => {
|
||||
if (!canSubmit) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (onCreateFromForm) {
|
||||
setIsPointCostConfirmOpen(false);
|
||||
onCreateFromForm(formPayload);
|
||||
return;
|
||||
}
|
||||
|
||||
if (session) {
|
||||
setIsPointCostConfirmOpen(false);
|
||||
onExecuteAction({
|
||||
action: 'match3d_compile_draft',
|
||||
generateClickSound: false,
|
||||
@@ -539,6 +551,44 @@ export function Match3DAgentWorkspace({
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isPointCostConfirmOpen ? (
|
||||
<div className="platform-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center px-4 py-6">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="match3d-point-cost-confirm-title"
|
||||
className="platform-modal-shell platform-remap-surface w-full max-w-xs rounded-[1.35rem] p-5 shadow-[0_24px_70px_rgba(15,23,42,0.22)]"
|
||||
>
|
||||
<div
|
||||
id="match3d-point-cost-confirm-title"
|
||||
className="text-base font-black text-[var(--platform-text-strong)]"
|
||||
>
|
||||
确认消耗泥点
|
||||
</div>
|
||||
<div className="mt-2 text-sm font-semibold leading-6 text-[var(--platform-text-base)]">
|
||||
消耗 10 泥点
|
||||
</div>
|
||||
<div className="mt-5 grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsPointCostConfirmOpen(false)}
|
||||
className="platform-button platform-button--secondary justify-center"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canSubmit}
|
||||
onClick={executeSubmitForm}
|
||||
className={`platform-button platform-button--primary justify-center ${!canSubmit ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
>
|
||||
确定
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,9 +3,7 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { AudioGenerationTaskResponse } from '../../../packages/shared/src/contracts/creationAudio';
|
||||
import type { Match3DWorkProfile } from '../../../packages/shared/src/contracts/match3dWorks';
|
||||
import * as creationAudioService from '../../services/creation-audio';
|
||||
import * as match3dWorksService from '../../services/match3d-works';
|
||||
import { clearMatch3DGeneratedModelBytesCache } from '../../services/match3dGeneratedModelCache';
|
||||
import { Match3DResultView } from './Match3DResultView';
|
||||
@@ -32,6 +30,7 @@ vi.mock('../../services/assetReadUrlService', () => ({
|
||||
|
||||
vi.mock('../../services/match3d-works', () => ({
|
||||
generateMatch3DBackgroundImage: vi.fn(),
|
||||
generateMatch3DContainerImage: vi.fn(),
|
||||
generateMatch3DCoverImage: vi.fn(),
|
||||
generateMatch3DItemAssets: vi.fn(),
|
||||
generateMatch3DWorkTags: vi.fn(),
|
||||
@@ -40,16 +39,6 @@ vi.mock('../../services/match3d-works', () => ({
|
||||
updateMatch3DWork: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/creation-audio', () => ({
|
||||
createBackgroundMusicTask: vi.fn(),
|
||||
createSoundEffectTask: vi.fn(),
|
||||
publishBackgroundMusicAsset: vi.fn(),
|
||||
publishSoundEffectAsset: vi.fn(),
|
||||
waitForGeneratedAudioAsset: vi.fn((taskId: string, publish: () => unknown) =>
|
||||
publish(),
|
||||
),
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
clearMatch3DGeneratedModelBytesCache();
|
||||
vi.clearAllMocks();
|
||||
@@ -66,6 +55,28 @@ function createDeferred<T>() {
|
||||
return { promise, reject, resolve };
|
||||
}
|
||||
|
||||
function stubMatch3DCoverUpload(dataUrl: string) {
|
||||
class MockFileReader {
|
||||
result: string | ArrayBuffer | null = dataUrl;
|
||||
onload: null | (() => void) = null;
|
||||
onerror: null | (() => void) = null;
|
||||
|
||||
readAsDataURL() {
|
||||
this.onload?.();
|
||||
}
|
||||
}
|
||||
|
||||
vi.stubGlobal('FileReader', MockFileReader as unknown as typeof FileReader);
|
||||
}
|
||||
|
||||
function confirmPointCost() {
|
||||
const dialogs = screen.getAllByRole('dialog', { name: '确认消耗泥点' });
|
||||
const dialog = dialogs[dialogs.length - 1]!;
|
||||
fireEvent.click(
|
||||
dialog.querySelector('button:last-of-type') as HTMLButtonElement,
|
||||
);
|
||||
}
|
||||
|
||||
function createProfile(
|
||||
overrides: Partial<Match3DWorkProfile> = {},
|
||||
): Match3DWorkProfile {
|
||||
@@ -177,13 +188,14 @@ describe('Match3DResultView', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('碰面图独立面板支持引用物品素材后 AI 重绘', async () => {
|
||||
test('封面图独立面板支持引用物品素材作为多参考图生成', async () => {
|
||||
const profile = createProfile({
|
||||
generatedItemAssets: [
|
||||
{
|
||||
itemId: 'match3d-item-1',
|
||||
itemName: '草莓',
|
||||
imageSrc: '/generated-match3d-assets/session/profile/items/i1/image.png',
|
||||
imageSrc:
|
||||
'/generated-match3d-assets/session/profile/items/i1/image.png',
|
||||
imageObjectKey:
|
||||
'generated-match3d-assets/session/profile/items/i1/image.png',
|
||||
modelSrc: null,
|
||||
@@ -208,7 +220,7 @@ describe('Match3DResultView', () => {
|
||||
'/generated-match3d-assets/session/profile/cover/task/cover.png',
|
||||
coverImageObjectKey:
|
||||
'generated-match3d-assets/session/profile/cover/task/cover.png',
|
||||
prompt: '草莓抓大鹅碰面图',
|
||||
prompt: '草莓抓大鹅封面图',
|
||||
});
|
||||
|
||||
render(
|
||||
@@ -220,25 +232,84 @@ describe('Match3DResultView', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '碰面图' }));
|
||||
expect(screen.getByRole('dialog', { name: '碰面图' })).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole('button', { name: '封面图' }));
|
||||
expect(screen.getByRole('dialog', { name: '封面图' })).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole('button', { name: '引用草莓' }));
|
||||
fireEvent.change(screen.getByLabelText('碰面图提示词'), {
|
||||
target: { value: '草莓抓大鹅碰面图' },
|
||||
fireEvent.change(screen.getByLabelText('封面描述'), {
|
||||
target: { value: '草莓抓大鹅封面图' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '生成碰面图' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '生成封面图' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(match3dWorksService.generateMatch3DCoverImage).toHaveBeenCalledWith(
|
||||
profile.profileId,
|
||||
{
|
||||
prompt: '草莓抓大鹅碰面图',
|
||||
referenceImageSrc:
|
||||
'generated-match3d-assets/session/profile/items/i1/image.png',
|
||||
},
|
||||
);
|
||||
expect(
|
||||
match3dWorksService.generateMatch3DCoverImage,
|
||||
).toHaveBeenCalledWith(profile.profileId, {
|
||||
prompt: '草莓抓大鹅封面图',
|
||||
referenceImageSrcs: [
|
||||
'generated-match3d-assets/session/profile/items/i1/image.png',
|
||||
],
|
||||
uploadedImageSrc: null,
|
||||
});
|
||||
expect(onSaved).toHaveBeenCalledWith(nextProfile);
|
||||
expect(screen.queryByRole('dialog', { name: '碰面图' })).toBeNull();
|
||||
expect(screen.queryByRole('dialog', { name: '封面图' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
test('封面图上传后对齐拼图入口显示 AI 重绘开关和删除按钮', async () => {
|
||||
const uploadedDataUrl = 'data:image/png;base64,match3d-cover-uploaded';
|
||||
stubMatch3DCoverUpload(uploadedDataUrl);
|
||||
const profile = createProfile();
|
||||
const nextProfile = createProfile({
|
||||
coverImageSrc:
|
||||
'/generated-match3d-assets/session/profile/cover/task/cover.png',
|
||||
});
|
||||
vi.mocked(match3dWorksService.generateMatch3DCoverImage).mockResolvedValue({
|
||||
item: nextProfile,
|
||||
coverImageSrc:
|
||||
'/generated-match3d-assets/session/profile/cover/task/cover.png',
|
||||
coverImageObjectKey:
|
||||
'generated-match3d-assets/session/profile/cover/task/cover.png',
|
||||
prompt: '保留构图,改成节日果园',
|
||||
});
|
||||
|
||||
render(
|
||||
<Match3DResultView
|
||||
profile={profile}
|
||||
onBack={() => {}}
|
||||
onStartTestRun={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '封面图' }));
|
||||
fireEvent.change(
|
||||
screen.getByLabelText('上传封面图', { selector: 'input' }),
|
||||
{
|
||||
target: {
|
||||
files: [new File(['x'], 'cover.png', { type: 'image/png' })],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('switch', { name: 'AI重绘' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '移除封面图' })).toBeTruthy();
|
||||
expect(screen.getByLabelText('AI重绘要求')).toBeTruthy();
|
||||
});
|
||||
expect(screen.queryByText('参考图')).toBeNull();
|
||||
|
||||
fireEvent.change(screen.getByLabelText('AI重绘要求'), {
|
||||
target: { value: '保留构图,改成节日果园' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '生成封面图' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
match3dWorksService.generateMatch3DCoverImage,
|
||||
).toHaveBeenCalledWith(profile.profileId, {
|
||||
prompt: '保留构图,改成节日果园',
|
||||
uploadedImageSrc: uploadedDataUrl,
|
||||
referenceImageSrcs: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -495,7 +566,7 @@ describe('Match3DResultView', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
|
||||
expect(screen.getByRole('button', { name: '物品' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: 'UI' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '背景音乐' })).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: '背景音乐' })).toBeNull();
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '打开水果核心物件物品素材' }),
|
||||
@@ -503,8 +574,8 @@ describe('Match3DResultView', () => {
|
||||
|
||||
expect(screen.getByRole('dialog', { name: /水果核心物件/u })).toBeTruthy();
|
||||
expect(screen.getByText('素材名称')).toBeTruthy();
|
||||
expect(screen.getByText('暂无音效')).toBeTruthy();
|
||||
expect(screen.getByLabelText('生成点击音效,10泥点')).toBeTruthy();
|
||||
expect(screen.queryByText('暂无音效')).toBeNull();
|
||||
expect(screen.queryByLabelText('生成点击音效,10泥点')).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '重新生成' })).toBeNull();
|
||||
expect(screen.queryByText('用途')).toBeNull();
|
||||
});
|
||||
@@ -515,7 +586,8 @@ describe('Match3DResultView', () => {
|
||||
{
|
||||
itemId: 'match3d-item-1',
|
||||
itemName: '草莓',
|
||||
imageSrc: '/generated-match3d-assets/session/profile/items/i1/image.png',
|
||||
imageSrc:
|
||||
'/generated-match3d-assets/session/profile/items/i1/image.png',
|
||||
imageObjectKey:
|
||||
'generated-match3d-assets/session/profile/items/i1/image.png',
|
||||
modelSrc: null,
|
||||
@@ -529,7 +601,8 @@ describe('Match3DResultView', () => {
|
||||
{
|
||||
itemId: 'match3d-item-2',
|
||||
itemName: '苹果',
|
||||
imageSrc: '/generated-match3d-assets/session/profile/items/i2/image.png',
|
||||
imageSrc:
|
||||
'/generated-match3d-assets/session/profile/items/i2/image.png',
|
||||
imageObjectKey:
|
||||
'generated-match3d-assets/session/profile/items/i2/image.png',
|
||||
modelSrc: null,
|
||||
@@ -591,7 +664,8 @@ describe('Match3DResultView', () => {
|
||||
{
|
||||
itemId: 'match3d-item-1',
|
||||
itemName: '草莓',
|
||||
imageSrc: '/generated-match3d-assets/session/profile/items/i1/image.png',
|
||||
imageSrc:
|
||||
'/generated-match3d-assets/session/profile/items/i1/image.png',
|
||||
imageObjectKey:
|
||||
'generated-match3d-assets/session/profile/items/i1/image.png',
|
||||
modelSrc:
|
||||
@@ -638,14 +712,18 @@ describe('Match3DResultView', () => {
|
||||
fireEvent.change(screen.getByLabelText('物品名称 4'), {
|
||||
target: { value: '苹果' },
|
||||
});
|
||||
expect(screen.getByRole('button', { name: /生成物品素材 · 2泥点/u })).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /生成物品素材 · 2泥点/u }),
|
||||
).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole('button', { name: /生成物品素材/u }));
|
||||
confirmPointCost();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(match3dWorksService.generateMatch3DItemAssets).toHaveBeenCalledWith(
|
||||
profile.profileId,
|
||||
{ itemNames: ['草莓', '苹果', '蓝莓'] },
|
||||
);
|
||||
expect(
|
||||
match3dWorksService.generateMatch3DItemAssets,
|
||||
).toHaveBeenCalledWith(profile.profileId, {
|
||||
itemNames: ['草莓', '苹果', '蓝莓'],
|
||||
});
|
||||
expect(onSaved).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ generatedItemAssets }),
|
||||
);
|
||||
@@ -661,7 +739,9 @@ describe('Match3DResultView', () => {
|
||||
test('批量新增面板关闭后素材列表继续显示生成进度', async () => {
|
||||
const deferred = createDeferred<{
|
||||
item: Match3DWorkProfile;
|
||||
generatedItemAssets: NonNullable<Match3DWorkProfile['generatedItemAssets']>;
|
||||
generatedItemAssets: NonNullable<
|
||||
Match3DWorkProfile['generatedItemAssets']
|
||||
>;
|
||||
}>();
|
||||
vi.mocked(match3dWorksService.generateMatch3DItemAssets).mockReturnValue(
|
||||
deferred.promise,
|
||||
@@ -681,6 +761,7 @@ describe('Match3DResultView', () => {
|
||||
target: { value: '草莓' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /生成物品素材/u }));
|
||||
confirmPointCost();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('生成中').length).toBeGreaterThan(0);
|
||||
@@ -705,6 +786,133 @@ describe('Match3DResultView', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('批量重新生成会收集已有物品名称并按替换模式调用素材生成接口', async () => {
|
||||
const generatedItemAssets = [
|
||||
{ ...createReadyGeneratedItemAsset(1), itemName: '草莓' },
|
||||
{ ...createReadyGeneratedItemAsset(2), itemName: '苹果' },
|
||||
];
|
||||
const regeneratedAssets = [
|
||||
{
|
||||
...generatedItemAssets[0]!,
|
||||
imageSrc:
|
||||
'/generated-match3d-assets/session/profile/items/item-1/new-image.png',
|
||||
},
|
||||
generatedItemAssets[1]!,
|
||||
];
|
||||
const profile = createProfile({ generatedItemAssets });
|
||||
const onSaved = vi.fn();
|
||||
vi.mocked(match3dWorksService.generateMatch3DItemAssets).mockResolvedValue({
|
||||
item: createProfile({ generatedItemAssets: regeneratedAssets }),
|
||||
generatedItemAssets: regeneratedAssets,
|
||||
});
|
||||
|
||||
render(
|
||||
<Match3DResultView
|
||||
profile={profile}
|
||||
onBack={() => {}}
|
||||
onSaved={onSaved}
|
||||
onStartTestRun={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '批量重新生成' }));
|
||||
expect(
|
||||
screen.getByRole('dialog', { name: '批量重新生成物品' }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByLabelText('重新生成物品名称 1')).toHaveProperty(
|
||||
'value',
|
||||
'草莓',
|
||||
);
|
||||
expect(screen.getByLabelText('重新生成物品名称 2')).toHaveProperty(
|
||||
'value',
|
||||
'苹果',
|
||||
);
|
||||
fireEvent.change(screen.getByLabelText('重新生成物品名称 2'), {
|
||||
target: { value: '' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /重新生成物品素材/u }));
|
||||
confirmPointCost();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
match3dWorksService.generateMatch3DItemAssets,
|
||||
).toHaveBeenCalledWith(profile.profileId, {
|
||||
itemNames: ['草莓'],
|
||||
mode: 'replace',
|
||||
});
|
||||
expect(onSaved).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ generatedItemAssets: regeneratedAssets }),
|
||||
);
|
||||
expect(
|
||||
screen.getAllByText('已重新生成 1 种物品素材').length,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
expect(
|
||||
screen.getByRole('button', { name: '打开草莓物品素材' }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('批量重新生成只提交能匹配到的已有物品名称', async () => {
|
||||
const generatedItemAssets = [
|
||||
{ ...createReadyGeneratedItemAsset(1), itemName: '草莓' },
|
||||
{ ...createReadyGeneratedItemAsset(2), itemName: '苹果' },
|
||||
{ ...createReadyGeneratedItemAsset(3), itemName: '梨子' },
|
||||
{ ...createReadyGeneratedItemAsset(4), itemName: '香蕉' },
|
||||
{ ...createReadyGeneratedItemAsset(5), itemName: '葡萄' },
|
||||
{ ...createReadyGeneratedItemAsset(6), itemName: '橙子' },
|
||||
];
|
||||
const profile = createProfile({ generatedItemAssets });
|
||||
vi.mocked(match3dWorksService.generateMatch3DItemAssets).mockResolvedValue({
|
||||
item: profile,
|
||||
generatedItemAssets,
|
||||
});
|
||||
|
||||
render(
|
||||
<Match3DResultView
|
||||
profile={profile}
|
||||
onBack={() => {}}
|
||||
onStartTestRun={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '批量重新生成' }));
|
||||
fireEvent.change(screen.getByLabelText('重新生成物品名称 1'), {
|
||||
target: { value: '草莓' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('重新生成物品名称 2'), {
|
||||
target: { value: '不存在' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('重新生成物品名称 3'), {
|
||||
target: { value: '梨子' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('重新生成物品名称 4'), {
|
||||
target: { value: '' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('重新生成物品名称 5'), {
|
||||
target: { value: '' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('重新生成物品名称 6'), {
|
||||
target: { value: '' },
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /重新生成物品素材 · 2泥点/u }),
|
||||
).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole('button', { name: /重新生成物品素材/u }));
|
||||
confirmPointCost();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
match3dWorksService.generateMatch3DItemAssets,
|
||||
).toHaveBeenCalledWith(profile.profileId, {
|
||||
itemNames: ['草莓', '梨子'],
|
||||
mode: 'replace',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('难度配置对齐入口页并派生消除次数与物品数量', async () => {
|
||||
const onSaved = vi.fn();
|
||||
vi.mocked(match3dWorksService.updateMatch3DWork).mockResolvedValue({
|
||||
@@ -722,9 +930,7 @@ describe('Match3DResultView', () => {
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '难度配置' }));
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: '轻松 8次 · 3种' }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '轻松 8次 · 3种' })).toBeTruthy();
|
||||
const difficultySlider = screen.getByRole('slider', { name: '难度' });
|
||||
expect((difficultySlider as HTMLInputElement).value).toBe('1');
|
||||
expect(
|
||||
@@ -806,16 +1012,16 @@ describe('Match3DResultView', () => {
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '打开物品1物品素材' }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开物品1物品素材' }));
|
||||
|
||||
expect(screen.getByDisplayValue('物品1')).toBeTruthy();
|
||||
expect(
|
||||
[...document.querySelectorAll('img')].some((image) =>
|
||||
image
|
||||
.getAttribute('src')
|
||||
?.includes('generated-match3d-assets/session/profile/items/item-1/views/view-01.png'),
|
||||
?.includes(
|
||||
'generated-match3d-assets/session/profile/items/item-1/views/view-01.png',
|
||||
),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
@@ -843,12 +1049,10 @@ describe('Match3DResultView', () => {
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '打开物品1物品素材' }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开物品1物品素材' }));
|
||||
|
||||
const imageSources = [...document.querySelectorAll('img')].map((image) =>
|
||||
image.getAttribute('src') ?? '',
|
||||
const imageSources = [...document.querySelectorAll('img')].map(
|
||||
(image) => image.getAttribute('src') ?? '',
|
||||
);
|
||||
expect(
|
||||
imageSources.some((source) => source.includes('legacy-primary.png')),
|
||||
@@ -858,7 +1062,7 @@ describe('Match3DResultView', () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('物品详情五视角预览使用 1:1 五格布局', () => {
|
||||
test('物品详情五视角预览使用上方焦点区和底部缩略图栏', () => {
|
||||
render(
|
||||
<Match3DResultView
|
||||
profile={createProfile({
|
||||
@@ -871,14 +1075,21 @@ describe('Match3DResultView', () => {
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '打开物品1物品素材' }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开物品1物品素材' }));
|
||||
|
||||
const preview = screen.getByLabelText('物品1五视角预览');
|
||||
expect(preview.className).toContain('aspect-square');
|
||||
expect(preview.className).toContain('grid-cols-[repeat(5,minmax(0,1fr))]');
|
||||
expect(preview.querySelectorAll('img')).toHaveLength(5);
|
||||
const stage = screen.getByTestId('match3d-item-preview-stage');
|
||||
const focusFrame = screen.getByTestId('match3d-item-preview-focus-frame');
|
||||
const thumbnails = screen.getByTestId('match3d-item-preview-thumbnails');
|
||||
expect(stage.className).toContain('aspect-square');
|
||||
expect(focusFrame.className).toContain('inset-[7%]');
|
||||
expect(thumbnails.style.gridAutoColumns).toBe('calc((100% - 1.5rem) / 4)');
|
||||
expect(preview.querySelectorAll('img')).toHaveLength(10);
|
||||
expect(
|
||||
screen
|
||||
.getByRole('button', { name: '切换物品1视角3' })
|
||||
.getAttribute('aria-pressed'),
|
||||
).toBe('true');
|
||||
});
|
||||
|
||||
test('草稿阶段仅有切割图片时展示 2D 素材', () => {
|
||||
@@ -910,9 +1121,7 @@ describe('Match3DResultView', () => {
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '打开草莓物品素材' }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开草莓物品素材' }));
|
||||
|
||||
expect(screen.getByDisplayValue('草莓')).toBeTruthy();
|
||||
expect(
|
||||
@@ -1109,8 +1318,12 @@ describe('Match3DResultView', () => {
|
||||
fireEvent.change(screen.getByLabelText('UI背景图画面描述提示词'), {
|
||||
target: { value: '新背景提示词' },
|
||||
});
|
||||
expect(screen.getByRole('button', { name: /重新生成 · 2泥点/u })).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /重新生成 · 2泥点/u }),
|
||||
).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole('button', { name: /重新生成/u }));
|
||||
expect(screen.getByRole('dialog', { name: '确认消耗泥点' })).toBeTruthy();
|
||||
confirmPointCost();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
@@ -1137,6 +1350,171 @@ describe('Match3DResultView', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('素材配置 UI 子 Tab 重新生成后显示90秒倒计时进度', async () => {
|
||||
const deferred =
|
||||
createDeferred<
|
||||
Awaited<
|
||||
ReturnType<typeof match3dWorksService.generateMatch3DBackgroundImage>
|
||||
>
|
||||
>();
|
||||
vi.mocked(
|
||||
match3dWorksService.generateMatch3DBackgroundImage,
|
||||
).mockReturnValue(deferred.promise);
|
||||
|
||||
render(
|
||||
<Match3DResultView
|
||||
profile={createProfile({
|
||||
generatedBackgroundAsset: {
|
||||
prompt: '旧背景提示词',
|
||||
imageSrc:
|
||||
'/generated-match3d-assets/session/profile/background/old/background.png',
|
||||
imageObjectKey:
|
||||
'generated-match3d-assets/session/profile/background/old/background.png',
|
||||
containerPrompt: '旧容器提示词',
|
||||
containerImageSrc:
|
||||
'/generated-match3d-assets/session/profile/ui-container/old/container.png',
|
||||
containerImageObjectKey:
|
||||
'generated-match3d-assets/session/profile/ui-container/old/container.png',
|
||||
status: 'image_ready',
|
||||
error: null,
|
||||
},
|
||||
})}
|
||||
onBack={() => {}}
|
||||
onStartTestRun={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'UI' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /重新生成/u }));
|
||||
confirmPointCost();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole('progressbar', { name: 'UI背景图生成进度' }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText('预计剩余 90 秒')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
test('素材配置容器形象子 Tab 单独调用容器图生成接口并刷新素材', async () => {
|
||||
const profile = createProfile({
|
||||
generatedBackgroundAsset: {
|
||||
prompt: '旧背景提示词',
|
||||
imageSrc:
|
||||
'/generated-match3d-assets/session/profile/background/old/background.png',
|
||||
imageObjectKey:
|
||||
'generated-match3d-assets/session/profile/background/old/background.png',
|
||||
containerPrompt: '旧容器提示词',
|
||||
containerImageSrc:
|
||||
'/generated-match3d-assets/session/profile/ui-container/old/container.png',
|
||||
containerImageObjectKey:
|
||||
'generated-match3d-assets/session/profile/ui-container/old/container.png',
|
||||
status: 'image_ready',
|
||||
error: null,
|
||||
},
|
||||
generatedItemAssets: [
|
||||
{
|
||||
...createReadyGeneratedItemAsset(1),
|
||||
backgroundAsset: {
|
||||
prompt: '旧背景提示词',
|
||||
imageSrc:
|
||||
'/generated-match3d-assets/session/profile/background/old/background.png',
|
||||
imageObjectKey:
|
||||
'generated-match3d-assets/session/profile/background/old/background.png',
|
||||
containerPrompt: '旧容器提示词',
|
||||
containerImageSrc:
|
||||
'/generated-match3d-assets/session/profile/ui-container/old/container.png',
|
||||
containerImageObjectKey:
|
||||
'generated-match3d-assets/session/profile/ui-container/old/container.png',
|
||||
status: 'image_ready',
|
||||
error: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const nextBackgroundAsset = {
|
||||
prompt: '旧背景提示词',
|
||||
imageSrc:
|
||||
'/generated-match3d-assets/session/profile/background/old/background.png',
|
||||
imageObjectKey:
|
||||
'generated-match3d-assets/session/profile/background/old/background.png',
|
||||
containerPrompt: '新容器提示词',
|
||||
containerImageSrc:
|
||||
'/generated-match3d-assets/session/profile/ui-container/new/container.png',
|
||||
containerImageObjectKey:
|
||||
'generated-match3d-assets/session/profile/ui-container/new/container.png',
|
||||
status: 'image_ready',
|
||||
error: null,
|
||||
};
|
||||
const nextProfile = createProfile({
|
||||
...profile,
|
||||
generatedBackgroundAsset: nextBackgroundAsset,
|
||||
generatedItemAssets: [
|
||||
{
|
||||
...profile.generatedItemAssets![0]!,
|
||||
backgroundAsset: nextBackgroundAsset,
|
||||
},
|
||||
],
|
||||
});
|
||||
const onSaved = vi.fn();
|
||||
vi.mocked(
|
||||
match3dWorksService.generateMatch3DContainerImage,
|
||||
).mockResolvedValue({
|
||||
item: nextProfile,
|
||||
containerImageSrc:
|
||||
'/generated-match3d-assets/session/profile/ui-container/new/container.png',
|
||||
containerImageObjectKey:
|
||||
'generated-match3d-assets/session/profile/ui-container/new/container.png',
|
||||
generatedBackgroundAsset: nextBackgroundAsset,
|
||||
prompt: '新容器提示词',
|
||||
});
|
||||
|
||||
render(
|
||||
<Match3DResultView
|
||||
profile={profile}
|
||||
onBack={() => {}}
|
||||
onSaved={onSaved}
|
||||
onStartTestRun={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '容器形象' }));
|
||||
fireEvent.change(screen.getByLabelText('容器形象画面描述提示词'), {
|
||||
target: { value: '新容器提示词' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /重新生成/u }));
|
||||
confirmPointCost();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
match3dWorksService.generateMatch3DContainerImage,
|
||||
).toHaveBeenCalledWith(profile.profileId, {
|
||||
prompt: '新容器提示词',
|
||||
});
|
||||
expect(
|
||||
match3dWorksService.generateMatch3DBackgroundImage,
|
||||
).not.toHaveBeenCalled();
|
||||
expect(onSaved).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
generatedItemAssets: [
|
||||
expect.objectContaining({
|
||||
itemId: 'match3d-item-1',
|
||||
backgroundAsset: expect.objectContaining({
|
||||
prompt: '旧背景提示词',
|
||||
imageSrc:
|
||||
'/generated-match3d-assets/session/profile/background/old/background.png',
|
||||
containerImageSrc:
|
||||
'/generated-match3d-assets/session/profile/ui-container/new/container.png',
|
||||
}),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('历史草稿同时带旧 draft 和 profile 素材时以 profile 多视角素材补齐试玩资产', async () => {
|
||||
const draftAsset = {
|
||||
...createReadyGeneratedItemAsset(1),
|
||||
@@ -1178,9 +1556,7 @@ describe('Match3DResultView', () => {
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '打开草莓物品素材' }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开草莓物品素材' }));
|
||||
|
||||
expect(screen.getByDisplayValue('草莓')).toBeTruthy();
|
||||
expect(
|
||||
@@ -1211,8 +1587,7 @@ describe('Match3DResultView', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('物品音效提示词可编辑并用于生成音效', async () => {
|
||||
const createTaskDeferred = createDeferred<AudioGenerationTaskResponse>();
|
||||
test('物品详情隐藏点击音效生成入口', () => {
|
||||
const profile = createProfile({
|
||||
generatedItemAssets: [
|
||||
{
|
||||
@@ -1233,23 +1608,6 @@ describe('Match3DResultView', () => {
|
||||
},
|
||||
],
|
||||
});
|
||||
vi.mocked(creationAudioService.createSoundEffectTask).mockReturnValue(
|
||||
createTaskDeferred.promise,
|
||||
);
|
||||
vi.mocked(creationAudioService.publishSoundEffectAsset).mockResolvedValue({
|
||||
kind: 'sound_effect',
|
||||
taskId: 'sound-task-1',
|
||||
provider: 'vector-engine-vidu',
|
||||
status: 'completed',
|
||||
assetObjectId: 'asset-sound-1',
|
||||
assetKind: 'match3d_click_sound',
|
||||
audioSrc: '/generated-match3d-assets/audio/click.wav',
|
||||
});
|
||||
vi.mocked(
|
||||
match3dWorksService.updateMatch3DGeneratedItemAssets,
|
||||
).mockResolvedValue({
|
||||
item: createProfile({ generatedItemAssets: profile.generatedItemAssets }),
|
||||
});
|
||||
|
||||
render(
|
||||
<Match3DResultView
|
||||
@@ -1260,53 +1618,17 @@ describe('Match3DResultView', () => {
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '打开草莓物品素材' }),
|
||||
);
|
||||
fireEvent.change(screen.getByLabelText('草莓点击音效提示词'), {
|
||||
target: { value: '草莓泡泡破裂音效' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /生成点击音效/u }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开草莓物品素材' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(creationAudioService.createSoundEffectTask).toHaveBeenCalledWith({
|
||||
prompt: '草莓泡泡破裂音效',
|
||||
duration: 3,
|
||||
});
|
||||
expect(screen.getByLabelText('音效生成中')).toBeTruthy();
|
||||
});
|
||||
|
||||
createTaskDeferred.resolve({
|
||||
kind: 'sound_effect',
|
||||
taskId: 'sound-task-1',
|
||||
provider: 'vector-engine-vidu',
|
||||
status: 'submitted',
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
match3dWorksService.updateMatch3DGeneratedItemAssets,
|
||||
).toHaveBeenCalledWith(
|
||||
'match3d-profile-1',
|
||||
expect.objectContaining({
|
||||
generatedItemAssets: [
|
||||
expect.objectContaining({
|
||||
soundPrompt: '草莓泡泡破裂音效',
|
||||
clickSound: expect.objectContaining({
|
||||
audioSrc: '/generated-match3d-assets/audio/click.wav',
|
||||
}),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(screen.getByLabelText('草莓点击音效').getAttribute('src')).toBe(
|
||||
'https://signed.example.com/generated-match3d-assets/audio/click.wav',
|
||||
);
|
||||
});
|
||||
expect(screen.getByRole('dialog', { name: /草莓/u })).toBeTruthy();
|
||||
expect(screen.queryByLabelText('草莓点击音效提示词')).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /生成点击音效/u })).toBeNull();
|
||||
expect(
|
||||
match3dWorksService.updateMatch3DGeneratedItemAssets,
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('背景音乐子 Tab 使用草稿生成的背景音乐参数并显示进度', async () => {
|
||||
const createTaskDeferred = createDeferred<AudioGenerationTaskResponse>();
|
||||
test('素材配置隐藏背景音乐子 Tab', () => {
|
||||
const profile = createProfile({
|
||||
generatedItemAssets: [
|
||||
{
|
||||
@@ -1330,25 +1652,6 @@ describe('Match3DResultView', () => {
|
||||
},
|
||||
],
|
||||
});
|
||||
vi.mocked(creationAudioService.createBackgroundMusicTask).mockReturnValue(
|
||||
createTaskDeferred.promise,
|
||||
);
|
||||
vi.mocked(
|
||||
creationAudioService.publishBackgroundMusicAsset,
|
||||
).mockResolvedValue({
|
||||
kind: 'background_music',
|
||||
taskId: 'music-task-1',
|
||||
provider: 'vector-engine-suno',
|
||||
status: 'completed',
|
||||
assetObjectId: 'asset-music-1',
|
||||
assetKind: 'match3d_background_music',
|
||||
audioSrc: '/generated-match3d-assets/audio/music.wav',
|
||||
});
|
||||
vi.mocked(
|
||||
match3dWorksService.updateMatch3DGeneratedItemAssets,
|
||||
).mockResolvedValue({
|
||||
item: createProfile({ generatedItemAssets: profile.generatedItemAssets }),
|
||||
});
|
||||
|
||||
render(
|
||||
<Match3DResultView
|
||||
@@ -1359,60 +1662,14 @@ describe('Match3DResultView', () => {
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '背景音乐' }));
|
||||
expect(screen.getByLabelText('抓大鹅背景音乐曲名')).toHaveProperty(
|
||||
'value',
|
||||
'果园轻舞',
|
||||
);
|
||||
expect(screen.getByLabelText('抓大鹅背景音乐风格')).toHaveProperty(
|
||||
'value',
|
||||
'轻快, 休闲',
|
||||
);
|
||||
expect(screen.queryByRole('button', { name: '背景音乐' })).toBeNull();
|
||||
expect(screen.queryByLabelText('抓大鹅背景音乐曲名')).toBeNull();
|
||||
expect(screen.queryByLabelText('抓大鹅背景音乐风格')).toBeNull();
|
||||
expect(screen.queryByLabelText('抓大鹅背景音乐提示词')).toBeNull();
|
||||
expect(screen.getByRole('button', { name: /生成音乐 · 5泥点/u })).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /生成音乐/u }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
creationAudioService.createBackgroundMusicTask,
|
||||
).toHaveBeenCalledWith({
|
||||
prompt: '',
|
||||
title: '果园轻舞',
|
||||
tags: '轻快, 休闲',
|
||||
});
|
||||
expect(screen.getByLabelText('音乐生成中')).toBeTruthy();
|
||||
});
|
||||
|
||||
createTaskDeferred.resolve({
|
||||
kind: 'background_music',
|
||||
taskId: 'music-task-1',
|
||||
provider: 'vector-engine-suno',
|
||||
status: 'submitted',
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
match3dWorksService.updateMatch3DGeneratedItemAssets,
|
||||
).toHaveBeenCalledWith(
|
||||
'match3d-profile-1',
|
||||
expect.objectContaining({
|
||||
generatedItemAssets: [
|
||||
expect.objectContaining({
|
||||
backgroundMusicTitle: '果园轻舞',
|
||||
backgroundMusicStyle: '轻快, 休闲',
|
||||
backgroundMusicPrompt: '',
|
||||
backgroundMusic: expect.objectContaining({
|
||||
audioSrc: '/generated-match3d-assets/audio/music.wav',
|
||||
}),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(screen.getByLabelText('抓大鹅背景音乐').getAttribute('src')).toBe(
|
||||
'https://signed.example.com/generated-match3d-assets/audio/music.wav',
|
||||
);
|
||||
});
|
||||
expect(screen.queryByRole('button', { name: /生成音乐/u })).toBeNull();
|
||||
expect(
|
||||
match3dWorksService.updateMatch3DGeneratedItemAssets,
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('背景音乐在非首个素材时仍显示并进入试玩 profile', async () => {
|
||||
@@ -1455,14 +1712,7 @@ describe('Match3DResultView', () => {
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '背景音乐' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText('抓大鹅背景音乐').getAttribute('src')).toBe(
|
||||
'https://signed.example.com/generated-match3d-assets/audio/floating-song.mp3',
|
||||
);
|
||||
});
|
||||
expect(screen.queryByText('暂无音乐')).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '背景音乐' })).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '试玩' }));
|
||||
|
||||
@@ -1473,8 +1723,7 @@ describe('Match3DResultView', () => {
|
||||
expect.objectContaining({
|
||||
itemId: 'match3d-item-1',
|
||||
backgroundMusic: expect.objectContaining({
|
||||
audioSrc:
|
||||
'/generated-match3d-assets/audio/floating-song.mp3',
|
||||
audioSrc: '/generated-match3d-assets/audio/floating-song.mp3',
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -474,6 +474,114 @@ test('运行态会换签并渲染抓大鹅中心容器 UI 图', async () => {
|
||||
screen.getByTestId('match3d-container-image').getAttribute('src'),
|
||||
).toBe('https://oss.example.com/match3d-container.png');
|
||||
});
|
||||
fireEvent.load(screen.getByTestId('match3d-container-image'));
|
||||
expect(screen.getByTestId('match3d-board').className).toContain(
|
||||
'bg-transparent',
|
||||
);
|
||||
expect(screen.getByTestId('match3d-board').className).not.toContain(
|
||||
'rounded-full',
|
||||
);
|
||||
});
|
||||
|
||||
test('容器图换签失败时保留默认圆形容器兜底', async () => {
|
||||
const run = startLocalMatch3DRun(3);
|
||||
const generatedItemAssets: Match3DGeneratedItemAsset[] = [
|
||||
{
|
||||
itemId: 'match3d-item-1',
|
||||
itemName: '草莓',
|
||||
imageSrc: null,
|
||||
imageObjectKey: null,
|
||||
imageViews: [],
|
||||
status: 'image_ready',
|
||||
modelSrc: null,
|
||||
modelObjectKey: null,
|
||||
backgroundAsset: {
|
||||
prompt: '果园纯背景',
|
||||
imageSrc: null,
|
||||
imageObjectKey: null,
|
||||
containerPrompt: '果园浅盘容器',
|
||||
containerImageSrc: null,
|
||||
containerImageObjectKey:
|
||||
'generated-match3d-assets/session/profile/ui-container/failing-task/container.png',
|
||||
status: 'image_ready',
|
||||
error: null,
|
||||
},
|
||||
},
|
||||
];
|
||||
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('read-url failed'));
|
||||
|
||||
renderRuntime(run, generatedItemAssets);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(globalThis.fetch).toHaveBeenCalled();
|
||||
});
|
||||
expect(screen.queryByTestId('match3d-container-image')).toBeNull();
|
||||
expect(screen.getByTestId('match3d-board').className).toContain(
|
||||
'rounded-full',
|
||||
);
|
||||
expect(screen.getByTestId('match3d-board').className).not.toContain(
|
||||
'bg-transparent',
|
||||
);
|
||||
});
|
||||
|
||||
test('运行态会从顶层 UI 资产加载背景和容器图', async () => {
|
||||
const run = startLocalMatch3DRun(3);
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation((input) => {
|
||||
const url = String(input);
|
||||
const signedUrl = url.includes('ui-container')
|
||||
? 'https://oss.example.com/match3d-container.png'
|
||||
: 'https://oss.example.com/match3d-background.png';
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
read: {
|
||||
signedUrl,
|
||||
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
render(
|
||||
<Match3DRuntimeShell
|
||||
run={run}
|
||||
generatedItemAssets={[]}
|
||||
generatedBackgroundAsset={{
|
||||
prompt: '果园纯背景',
|
||||
imageSrc: null,
|
||||
imageObjectKey:
|
||||
'generated-match3d-assets/session/profile/background/task/background.png',
|
||||
containerPrompt: '果园浅盘容器',
|
||||
containerImageSrc: null,
|
||||
containerImageObjectKey:
|
||||
'generated-match3d-assets/session/profile/ui-container/task/container.png',
|
||||
status: 'image_ready',
|
||||
error: null,
|
||||
}}
|
||||
onBack={vi.fn()}
|
||||
onRestart={vi.fn()}
|
||||
onOptimisticRunChange={vi.fn()}
|
||||
onClickItem={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId('match3d-background-image').getAttribute('src'),
|
||||
).toBe('https://oss.example.com/match3d-background.png');
|
||||
expect(
|
||||
screen.getByTestId('match3d-container-image').getAttribute('src'),
|
||||
).toBe('https://oss.example.com/match3d-container.png');
|
||||
});
|
||||
fireEvent.load(screen.getByTestId('match3d-container-image'));
|
||||
expect(screen.getByTestId('match3d-board').className).toContain(
|
||||
'bg-transparent',
|
||||
);
|
||||
});
|
||||
|
||||
test('运行态从任意素材读取作品级背景音乐并换签播放', async () => {
|
||||
|
||||
@@ -22,7 +22,10 @@ import type {
|
||||
Match3DRunSnapshot,
|
||||
Match3DTraySlot,
|
||||
} from '../../../packages/shared/src/contracts/match3dRuntime';
|
||||
import type { Match3DGeneratedItemAsset } from '../../../packages/shared/src/contracts/match3dWorks';
|
||||
import type {
|
||||
Match3DGeneratedBackgroundAsset,
|
||||
Match3DGeneratedItemAsset,
|
||||
} from '../../../packages/shared/src/contracts/match3dWorks';
|
||||
import {
|
||||
isGeneratedLegacyPath,
|
||||
resolveAssetReadUrl,
|
||||
@@ -58,6 +61,7 @@ import {
|
||||
type Match3DRuntimeShellProps = {
|
||||
run: Match3DRunSnapshot | null;
|
||||
generatedItemAssets?: Match3DGeneratedItemAsset[];
|
||||
generatedBackgroundAsset?: Match3DGeneratedBackgroundAsset | null;
|
||||
backgroundImageSrc?: string | null;
|
||||
isBusy?: boolean;
|
||||
error?: string | null;
|
||||
@@ -459,6 +463,7 @@ function Match3DSettlement({
|
||||
export function Match3DRuntimeShell({
|
||||
run,
|
||||
generatedItemAssets = [],
|
||||
generatedBackgroundAsset = null,
|
||||
backgroundImageSrc = null,
|
||||
isBusy = false,
|
||||
error = null,
|
||||
@@ -564,6 +569,8 @@ export function Match3DRuntimeShell({
|
||||
|
||||
const backgroundAssetSrc =
|
||||
backgroundImageSrc?.trim() ||
|
||||
generatedBackgroundAsset?.imageSrc?.trim() ||
|
||||
generatedBackgroundAsset?.imageObjectKey?.trim() ||
|
||||
runtimeGeneratedItemAssets
|
||||
.map(
|
||||
(asset) =>
|
||||
@@ -574,6 +581,8 @@ export function Match3DRuntimeShell({
|
||||
.find(Boolean) ||
|
||||
'';
|
||||
const containerAssetSrc =
|
||||
generatedBackgroundAsset?.containerImageSrc?.trim() ||
|
||||
generatedBackgroundAsset?.containerImageObjectKey?.trim() ||
|
||||
runtimeGeneratedItemAssets
|
||||
.map(
|
||||
(asset) =>
|
||||
@@ -606,6 +615,10 @@ export function Match3DRuntimeShell({
|
||||
?.backgroundMusic?.audioSrc ?? null;
|
||||
const [resolvedBackgroundMusicSrc, setResolvedBackgroundMusicSrc] = useState('');
|
||||
const [resolvedContainerImageSrc, setResolvedContainerImageSrc] = useState('');
|
||||
const [isContainerImageLoaded, setIsContainerImageLoaded] = useState(false);
|
||||
const hasRenderedContainerAsset = Boolean(
|
||||
resolvedContainerImageSrc && isContainerImageLoaded,
|
||||
);
|
||||
const clickSoundByTypeId = useMemo(() => {
|
||||
if (!run) {
|
||||
return new Map<string, string>();
|
||||
@@ -715,11 +728,14 @@ export function Match3DRuntimeShell({
|
||||
useEffect(() => {
|
||||
if (!containerAssetSrc) {
|
||||
setResolvedContainerImageSrc('');
|
||||
setIsContainerImageLoaded(false);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const controller = new AbortController();
|
||||
setResolvedContainerImageSrc('');
|
||||
setIsContainerImageLoaded(false);
|
||||
void resolveAssetReadUrl(containerAssetSrc, {
|
||||
signal: controller.signal,
|
||||
expireSeconds: 300,
|
||||
@@ -727,11 +743,13 @@ export function Match3DRuntimeShell({
|
||||
.then((resolvedSrc) => {
|
||||
if (!cancelled) {
|
||||
setResolvedContainerImageSrc(resolvedSrc);
|
||||
setIsContainerImageLoaded(false);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setResolvedContainerImageSrc('');
|
||||
setIsContainerImageLoaded(false);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -875,6 +893,7 @@ export function Match3DRuntimeShell({
|
||||
src={resolvedBackgroundImageSrc}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
data-testid="match3d-background-image"
|
||||
className="pointer-events-none absolute inset-0 h-full w-full object-cover"
|
||||
/>
|
||||
) : null}
|
||||
@@ -921,7 +940,11 @@ export function Match3DRuntimeShell({
|
||||
<section className="relative mt-3 flex flex-1 min-h-0 items-center justify-center">
|
||||
<div
|
||||
ref={stageRef}
|
||||
className="relative aspect-square max-w-full overflow-hidden rounded-full border-[10px] border-[#e6d19b] bg-[radial-gradient(circle_at_50%_42%,#f2d993_0%,#c88f43_56%,#835223_100%)] shadow-[inset_0_8px_34px_rgba(72,41,16,0.34),0_22px_42px_rgba(15,23,42,0.28)]"
|
||||
className={`relative aspect-square max-w-full ${
|
||||
hasRenderedContainerAsset
|
||||
? 'overflow-visible bg-transparent'
|
||||
: 'overflow-hidden rounded-full border-[10px] border-[#e6d19b] bg-[radial-gradient(circle_at_50%_42%,#f2d993_0%,#c88f43_56%,#835223_100%)] shadow-[inset_0_8px_34px_rgba(72,41,16,0.34),0_22px_42px_rgba(15,23,42,0.28)]'
|
||||
}`}
|
||||
style={{
|
||||
width: 'min(92vw, 58dvh, 100%)',
|
||||
}}
|
||||
@@ -933,8 +956,15 @@ export function Match3DRuntimeShell({
|
||||
src={resolvedContainerImageSrc}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-[-4%] z-0 h-[108%] w-[108%] object-contain"
|
||||
className={`pointer-events-none absolute inset-[-8%] z-0 h-[116%] w-[116%] object-contain drop-shadow-[0_22px_42px_rgba(15,23,42,0.28)] ${
|
||||
isContainerImageLoaded ? 'opacity-100' : 'opacity-0'
|
||||
}`}
|
||||
data-testid="match3d-container-image"
|
||||
onLoad={() => setIsContainerImageLoaded(true)}
|
||||
onError={() => {
|
||||
setIsContainerImageLoaded(false);
|
||||
setResolvedContainerImageSrc('');
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="pointer-events-none absolute inset-[7%] z-0 rounded-full border border-white/22 bg-[radial-gradient(circle_at_44%_35%,rgba(255,255,255,0.22),transparent_28%)]" />
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -642,6 +654,7 @@ function mapPublicWorkDetailToMatch3DWork(
|
||||
backgroundImageSrc: entry.backgroundImageSrc ?? null,
|
||||
backgroundImageObjectKey: entry.backgroundImageObjectKey ?? null,
|
||||
generatedBackgroundAsset:
|
||||
entry.generatedBackgroundAsset ??
|
||||
entry.generatedItemAssets
|
||||
?.map((asset) => asset.backgroundAsset ?? null)
|
||||
.find(Boolean) ?? null,
|
||||
@@ -744,6 +757,32 @@ function resolveMatch3DRuntimeGeneratedItemAssets(
|
||||
: normalizeMatch3DGeneratedItemAssetsForRuntime(profileAssets);
|
||||
}
|
||||
|
||||
function resolveMatch3DRuntimeGeneratedBackgroundAsset(
|
||||
run: Match3DRunSnapshot | null,
|
||||
profile: Match3DWorkProfile | null,
|
||||
publicWorkDetail: PlatformPublicGalleryCard | null,
|
||||
) {
|
||||
const runProfileId = run?.profileId?.trim() ?? '';
|
||||
const profileBackground = profile?.generatedBackgroundAsset ?? null;
|
||||
const publicBackground =
|
||||
publicWorkDetail && isMatch3DGalleryEntry(publicWorkDetail)
|
||||
? (publicWorkDetail.generatedBackgroundAsset ?? null)
|
||||
: null;
|
||||
|
||||
if (runProfileId && profile?.profileId === runProfileId) {
|
||||
return profileBackground ?? publicBackground;
|
||||
}
|
||||
if (
|
||||
runProfileId &&
|
||||
publicWorkDetail &&
|
||||
isMatch3DGalleryEntry(publicWorkDetail) &&
|
||||
publicWorkDetail.profileId === runProfileId
|
||||
) {
|
||||
return publicBackground ?? profileBackground;
|
||||
}
|
||||
return profileBackground ?? publicBackground;
|
||||
}
|
||||
|
||||
function resolveActiveMatch3DRuntimeProfile(
|
||||
run: Match3DRunSnapshot | null,
|
||||
runtimeProfile: Match3DWorkProfile | null,
|
||||
@@ -1906,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 {
|
||||
@@ -1963,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 {
|
||||
@@ -2115,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<{
|
||||
@@ -2301,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<
|
||||
@@ -2718,6 +2788,12 @@ export function PlatformEntryFlowShellImpl({
|
||||
}, [resolvePuzzleErrorMessage]);
|
||||
|
||||
const refreshVisualNovelShelf = useCallback(async () => {
|
||||
if (!isVisualNovelCreationOpen) {
|
||||
setVisualNovelWorks([]);
|
||||
visualNovelErrorSetterRef.current(null);
|
||||
return [];
|
||||
}
|
||||
|
||||
setIsVisualNovelLoadingLibrary(true);
|
||||
|
||||
try {
|
||||
@@ -2733,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);
|
||||
@@ -2747,7 +2829,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}, [resolvePuzzleErrorMessage]);
|
||||
}, [isVisualNovelCreationOpen, resolvePuzzleErrorMessage]);
|
||||
|
||||
const handleRpgDraftGenerationStarted = useCallback(
|
||||
(sessionId: string) => {
|
||||
@@ -2775,8 +2857,8 @@ export function PlatformEntryFlowShellImpl({
|
||||
[markDraftReady, platformBootstrap],
|
||||
);
|
||||
|
||||
const refreshBabyObjectMatchShelf = useCallback(() => {
|
||||
setBabyObjectMatchDrafts(listLocalBabyObjectMatchDrafts());
|
||||
const refreshBabyObjectMatchShelf = useCallback(async () => {
|
||||
setBabyObjectMatchDrafts(await listLocalBabyObjectMatchDrafts());
|
||||
}, []);
|
||||
|
||||
const sessionController = useRpgCreationSessionController({
|
||||
@@ -3004,7 +3086,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
...match3dPublicEntries,
|
||||
...puzzlePublicEntries,
|
||||
...squareHolePublicEntries,
|
||||
...visualNovelPublicEntries,
|
||||
...(isVisualNovelCreationOpen ? visualNovelPublicEntries : []),
|
||||
...babyObjectMatchPublicEntries,
|
||||
],
|
||||
).slice(0, 6);
|
||||
@@ -3012,6 +3094,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
babyObjectMatchDrafts,
|
||||
isBigFishCreationVisible,
|
||||
isBabyObjectMatchVisible,
|
||||
isVisualNovelCreationOpen,
|
||||
bigFishGalleryEntries,
|
||||
match3dGalleryEntries,
|
||||
platformBootstrap.publishedGalleryEntries,
|
||||
@@ -3032,9 +3115,11 @@ export function PlatformEntryFlowShellImpl({
|
||||
...squareHoleGalleryEntries.map(
|
||||
mapSquareHoleWorkToPlatformGalleryCard,
|
||||
),
|
||||
...visualNovelGalleryEntries.map(
|
||||
mapVisualNovelWorkToPlatformGalleryCard,
|
||||
),
|
||||
...(isVisualNovelCreationOpen
|
||||
? visualNovelGalleryEntries.map(
|
||||
mapVisualNovelWorkToPlatformGalleryCard,
|
||||
)
|
||||
: []),
|
||||
...(isBabyObjectMatchVisible
|
||||
? babyObjectMatchDrafts
|
||||
.filter((draft) => draft.publicationStatus === 'published')
|
||||
@@ -3046,6 +3131,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
babyObjectMatchDrafts,
|
||||
isBabyObjectMatchVisible,
|
||||
isBigFishCreationVisible,
|
||||
isVisualNovelCreationOpen,
|
||||
bigFishGalleryEntries,
|
||||
match3dGalleryEntries,
|
||||
platformBootstrap.publishedGalleryEntries,
|
||||
@@ -3112,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) => {
|
||||
@@ -4886,7 +4975,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
try {
|
||||
const response = await createBabyObjectMatchDraft(payload);
|
||||
setBabyObjectMatchDraft(response.draft);
|
||||
refreshBabyObjectMatchShelf();
|
||||
void refreshBabyObjectMatchShelf();
|
||||
setBabyObjectMatchGenerationPhase('ready');
|
||||
setBabyObjectMatchGenerationState((current) =>
|
||||
current
|
||||
@@ -5140,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);
|
||||
@@ -5169,9 +5267,12 @@ export function PlatformEntryFlowShellImpl({
|
||||
prepareCreationLaunch,
|
||||
runProtectedAction,
|
||||
sessionController,
|
||||
setActiveCreationFormType,
|
||||
setBarkBattleError,
|
||||
setMatch3DError,
|
||||
setPuzzleCreationError,
|
||||
setPuzzleError,
|
||||
setSelectionStage,
|
||||
setVisualNovelError,
|
||||
],
|
||||
);
|
||||
@@ -5201,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);
|
||||
@@ -5239,7 +5371,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
try {
|
||||
const response = await saveBabyObjectMatchDraft({ draft });
|
||||
setBabyObjectMatchDraft(response.draft);
|
||||
refreshBabyObjectMatchShelf();
|
||||
void refreshBabyObjectMatchShelf();
|
||||
} catch (error) {
|
||||
setBabyObjectMatchError(
|
||||
resolvePuzzleErrorMessage(error, '保存宝贝识物草稿失败。'),
|
||||
@@ -5251,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:
|
||||
@@ -5268,13 +5438,17 @@ export function PlatformEntryFlowShellImpl({
|
||||
});
|
||||
} catch (error) {
|
||||
setBabyObjectMatchError(
|
||||
resolvePuzzleErrorMessage(error, '发布宝贝识物作品失败。'),
|
||||
resolvePuzzleErrorMessage(
|
||||
error,
|
||||
'生成宝贝识物 image-2 资源失败,请重试后再发布。',
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
setIsBabyObjectMatchBusy(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
ensureBabyObjectMatchGeneratedAssets,
|
||||
openPublishShareModal,
|
||||
refreshBabyObjectMatchShelf,
|
||||
resolvePuzzleErrorMessage,
|
||||
@@ -5282,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;
|
||||
}
|
||||
@@ -5324,7 +5524,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
babyObjectMatchDrafts.find(
|
||||
(draft) => draft.profileId === entry.profileId,
|
||||
) ??
|
||||
listLocalBabyObjectMatchDrafts().find(
|
||||
(await listLocalBabyObjectMatchDrafts()).find(
|
||||
(draft) => draft.profileId === entry.profileId,
|
||||
) ??
|
||||
null
|
||||
@@ -5334,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(
|
||||
'当前宝贝识物作品缺少本地草稿,暂时无法进入玩法。',
|
||||
@@ -5347,7 +5547,11 @@ export function PlatformEntryFlowShellImpl({
|
||||
return false;
|
||||
}
|
||||
|
||||
return startBabyObjectMatchRuntimeFromDraft(draft, returnStage, options);
|
||||
return await startBabyObjectMatchRuntimeFromDraft(
|
||||
draft,
|
||||
returnStage,
|
||||
options,
|
||||
);
|
||||
},
|
||||
[resolveBabyObjectMatchRuntimeDraft, startBabyObjectMatchRuntimeFromDraft],
|
||||
);
|
||||
@@ -6968,7 +7172,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
return;
|
||||
}
|
||||
|
||||
runProtectedAction(() => {
|
||||
runProtectedAction(async () => {
|
||||
setIsPublicWorkDetailBusy(true);
|
||||
setIsPuzzleBusy(true);
|
||||
setPuzzleError(null);
|
||||
@@ -7447,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);
|
||||
@@ -8641,7 +8906,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
|
||||
if (isEdutainmentGalleryEntry(selectedPublicWorkDetail)) {
|
||||
setPublicWorkDetailError(null);
|
||||
startBabyObjectMatchRuntimeFromEntry(
|
||||
void startBabyObjectMatchRuntimeFromEntry(
|
||||
selectedPublicWorkDetail,
|
||||
'work-detail',
|
||||
);
|
||||
@@ -8773,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;
|
||||
}
|
||||
@@ -8911,6 +9180,11 @@ export function PlatformEntryFlowShellImpl({
|
||||
activeMatch3DRuntimeProfile,
|
||||
activeEntry,
|
||||
)}
|
||||
generatedBackgroundAsset={resolveMatch3DRuntimeGeneratedBackgroundAsset(
|
||||
match3dRun,
|
||||
activeMatch3DRuntimeProfile,
|
||||
activeEntry,
|
||||
)}
|
||||
backgroundImageSrc={resolveMatch3DRuntimeBackgroundImageSrc(
|
||||
match3dRun,
|
||||
activeMatch3DRuntimeProfile,
|
||||
@@ -9328,7 +9602,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
return;
|
||||
}
|
||||
|
||||
runProtectedAction(() => {
|
||||
runProtectedAction(async () => {
|
||||
setPublicWorkDetailError(null);
|
||||
|
||||
// 中文注释:自有公开作品必须恢复原草稿,不能复用 remix 复制链路。
|
||||
@@ -9396,7 +9670,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
}
|
||||
|
||||
if (isEdutainmentGalleryEntry(entry)) {
|
||||
const matchedDraft = resolveBabyObjectMatchRuntimeDraft(entry);
|
||||
const matchedDraft = await resolveBabyObjectMatchRuntimeDraft(entry);
|
||||
if (!matchedDraft) {
|
||||
setPublicWorkDetailError('这份宝贝识物缺少可编辑草稿。');
|
||||
return;
|
||||
@@ -9623,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) => {
|
||||
@@ -9667,7 +9941,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
}
|
||||
|
||||
if (shouldSearchBabyObjectFirst) {
|
||||
tryOpenBabyObjectMatchGalleryEntry();
|
||||
await tryOpenBabyObjectMatchGalleryEntry();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -9927,11 +10201,14 @@ export function PlatformEntryFlowShellImpl({
|
||||
if (isSquareHoleCreationVisible) {
|
||||
void refreshSquareHoleGallery();
|
||||
}
|
||||
void refreshVisualNovelGallery();
|
||||
if (isVisualNovelCreationOpen) {
|
||||
void refreshVisualNovelGallery();
|
||||
}
|
||||
}
|
||||
}, [
|
||||
isBigFishCreationVisible,
|
||||
isSquareHoleCreationVisible,
|
||||
isVisualNovelCreationOpen,
|
||||
refreshBigFishGallery,
|
||||
refreshMatch3DGallery,
|
||||
refreshPuzzleGallery,
|
||||
@@ -9951,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,
|
||||
@@ -9998,7 +10278,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
isMatch3DLoadingLibrary ||
|
||||
(isSquareHoleCreationVisible && isSquareHoleLoadingLibrary) ||
|
||||
isPuzzleLoadingLibrary ||
|
||||
isVisualNovelLoadingLibrary ||
|
||||
(isVisualNovelCreationOpen && isVisualNovelLoadingLibrary) ||
|
||||
isBabyObjectMatchBusy
|
||||
}
|
||||
error={
|
||||
@@ -10007,7 +10287,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
isMatch3DLoadingLibrary ||
|
||||
(isSquareHoleCreationVisible && isSquareHoleLoadingLibrary) ||
|
||||
isPuzzleLoadingLibrary ||
|
||||
isVisualNovelLoadingLibrary ||
|
||||
(isVisualNovelCreationOpen && isVisualNovelLoadingLibrary) ||
|
||||
isBabyObjectMatchBusy
|
||||
? null
|
||||
: (platformBootstrap.platformError ??
|
||||
@@ -10017,7 +10297,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
(isSquareHoleCreationVisible ? squareHoleError : null) ??
|
||||
puzzleShelfError ??
|
||||
puzzleError ??
|
||||
visualNovelError ??
|
||||
(isVisualNovelCreationOpen ? visualNovelError : null) ??
|
||||
babyObjectMatchError)
|
||||
}
|
||||
onRetry={() => {
|
||||
@@ -10053,8 +10333,10 @@ export function PlatformEntryFlowShellImpl({
|
||||
void refreshSquareHoleShelf();
|
||||
}
|
||||
void refreshPuzzleShelf();
|
||||
void refreshVisualNovelShelf();
|
||||
refreshBabyObjectMatchShelf();
|
||||
if (isVisualNovelCreationOpen) {
|
||||
void refreshVisualNovelShelf();
|
||||
}
|
||||
void refreshBabyObjectMatchShelf();
|
||||
}}
|
||||
createError={
|
||||
creationEntryConfigError ??
|
||||
@@ -10064,7 +10346,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
(isSquareHoleCreationVisible ? squareHoleError : null) ??
|
||||
puzzleCreationError ??
|
||||
puzzleError ??
|
||||
visualNovelError ??
|
||||
(isVisualNovelCreationOpen ? visualNovelError : null) ??
|
||||
babyObjectMatchError
|
||||
}
|
||||
createBusy={
|
||||
@@ -10076,8 +10358,8 @@ export function PlatformEntryFlowShellImpl({
|
||||
isMatch3DBusy ||
|
||||
(isSquareHoleCreationVisible && isSquareHoleBusy) ||
|
||||
isPuzzleBusy ||
|
||||
isVisualNovelBusy ||
|
||||
isVisualNovelStreamingReply ||
|
||||
(isVisualNovelCreationOpen && isVisualNovelBusy) ||
|
||||
(isVisualNovelCreationOpen && isVisualNovelStreamingReply) ||
|
||||
isBabyObjectMatchBusy
|
||||
}
|
||||
entryConfig={creationEntryConfig}
|
||||
@@ -10174,6 +10456,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
openBabyObjectMatchDraft(item);
|
||||
});
|
||||
}}
|
||||
onDeleteBabyObjectMatch={(item) => {
|
||||
handleDeleteBabyObjectMatchWork(item);
|
||||
}}
|
||||
visualNovelItems={visualNovelShelfItems}
|
||||
onOpenVisualNovelDetail={(item) => {
|
||||
runProtectedAction(() => {
|
||||
@@ -10408,6 +10693,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
onOpenCreateWorld={openCreationTypePicker}
|
||||
onOpenCreateTypePicker={openCreationTypePicker}
|
||||
onOpenGalleryDetail={openPublicGalleryDetail}
|
||||
onOpenBabyLoveDrawing={startBabyLoveDrawingRuntime}
|
||||
onOpenRecommendGalleryDetail={openRecommendGalleryDetail}
|
||||
recommendRuntimeContent={recommendRuntimeContent}
|
||||
activeRecommendEntryKey={activeRecommendEntryKey}
|
||||
@@ -10985,6 +11271,11 @@ export function PlatformEntryFlowShellImpl({
|
||||
activeMatch3DRuntimeProfile,
|
||||
selectedPublicWorkDetail,
|
||||
)}
|
||||
generatedBackgroundAsset={resolveMatch3DRuntimeGeneratedBackgroundAsset(
|
||||
match3dRun,
|
||||
activeMatch3DRuntimeProfile,
|
||||
selectedPublicWorkDetail,
|
||||
)}
|
||||
backgroundImageSrc={resolveMatch3DRuntimeBackgroundImageSrc(
|
||||
match3dRun,
|
||||
activeMatch3DRuntimeProfile,
|
||||
@@ -11159,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',
|
||||
);
|
||||
@@ -11192,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"
|
||||
@@ -11901,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
|
||||
@@ -12094,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 ??
|
||||
@@ -12102,7 +12477,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
match3dError ??
|
||||
squareHoleError ??
|
||||
puzzleCreationError ??
|
||||
visualNovelError ??
|
||||
(isVisualNovelCreationOpen ? visualNovelError : null) ??
|
||||
babyObjectMatchError ??
|
||||
puzzleError ??
|
||||
sessionController.creationTypeError
|
||||
@@ -12110,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);
|
||||
@@ -12141,6 +12527,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
void openCreativeAgentWorkspace();
|
||||
});
|
||||
}}
|
||||
onSelectBarkBattle={() => {
|
||||
handleCreationHubCreateType('bark-battle');
|
||||
}}
|
||||
onSelectVisualNovel={() => {
|
||||
handleCreationHubCreateType('visual-novel');
|
||||
}}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,前端不再保留入口默认配置。
|
||||
*/
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -150,6 +150,14 @@ function stubCanvas(dataUrl: string, drawImage = vi.fn()) {
|
||||
return drawImage;
|
||||
}
|
||||
|
||||
function confirmPuzzlePointCost() {
|
||||
const confirmDialog = screen.getByRole('dialog', {
|
||||
name: '确认消耗泥点',
|
||||
});
|
||||
expect(within(confirmDialog).getByText('消耗 2 泥点')).toBeTruthy();
|
||||
fireEvent.click(within(confirmDialog).getByRole('button', { name: '确定' }));
|
||||
}
|
||||
|
||||
test('puzzle workspace submits the work form instead of agent chat', () => {
|
||||
const onCreateFromForm = vi.fn();
|
||||
|
||||
@@ -174,6 +182,9 @@ test('puzzle workspace submits the work form instead of agent chat', () => {
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /生成拼图游戏草稿/u }));
|
||||
|
||||
expect(onCreateFromForm).not.toHaveBeenCalled();
|
||||
confirmPuzzlePointCost();
|
||||
|
||||
expect(onCreateFromForm).toHaveBeenCalledWith({
|
||||
seedText: '一只猫在雨夜灯牌下回头。',
|
||||
pictureDescription: '一只猫在雨夜灯牌下回头。',
|
||||
@@ -243,6 +254,7 @@ test('puzzle workspace keeps the reference image upload as a primary panel', ()
|
||||
target: { value: '一只猫在阳光窗台上看着毛线球。' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /生成拼图游戏草稿/u }));
|
||||
confirmPuzzlePointCost();
|
||||
expect(onCreateFromForm).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
pictureDescription: '一只猫在阳光窗台上看着毛线球。',
|
||||
@@ -303,6 +315,7 @@ test('puzzle workspace selects a history image from the upload card', async () =
|
||||
target: { value: '保留历史图里的主体,改成晴天花园。' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /生成拼图游戏草稿/u }));
|
||||
confirmPuzzlePointCost();
|
||||
|
||||
expect(onCreateFromForm).toHaveBeenCalledWith({
|
||||
seedText: '保留历史图里的主体,改成晴天花园。',
|
||||
@@ -356,6 +369,7 @@ test('puzzle workspace falls back to compile action for restored sessions', () =
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /生成拼图游戏草稿/u }));
|
||||
confirmPuzzlePointCost();
|
||||
|
||||
expect(onCreateFromForm).not.toHaveBeenCalled();
|
||||
expect(onExecuteAction).toHaveBeenCalledWith({
|
||||
@@ -389,6 +403,7 @@ test('puzzle workspace switches the image model from the description box', () =>
|
||||
expect(screen.queryByRole('menuitemradio', { name: '原模型' })).toBeNull();
|
||||
fireEvent.click(screen.getByRole('menuitemradio', { name: 'nanobanana2' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /生成拼图游戏草稿/u }));
|
||||
confirmPuzzlePointCost();
|
||||
|
||||
expect(onCreateFromForm).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -538,6 +553,7 @@ test('puzzle workspace submits uploaded reference image when AI redraw is on', a
|
||||
target: { value: '保留上传画面的主体和构图,改成雨夜灯街。' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /生成拼图游戏草稿/u }));
|
||||
confirmPuzzlePointCost();
|
||||
|
||||
expect(onCreateFromForm).toHaveBeenCalledWith({
|
||||
seedText: '保留上传画面的主体和构图,改成雨夜灯街。',
|
||||
|
||||
@@ -162,6 +162,7 @@ export function PuzzleAgentWorkspace({
|
||||
const [isHistoryPickerOpen, setIsHistoryPickerOpen] = useState(false);
|
||||
const [isRemoveImageConfirmOpen, setIsRemoveImageConfirmOpen] =
|
||||
useState(false);
|
||||
const [isPointCostConfirmOpen, setIsPointCostConfirmOpen] = useState(false);
|
||||
const previousSessionIdRef = useRef<string | null>(
|
||||
session?.sessionId ?? null,
|
||||
);
|
||||
@@ -192,6 +193,7 @@ export function PuzzleAgentWorkspace({
|
||||
setCropState(null);
|
||||
setIsHistoryPickerOpen(false);
|
||||
setIsRemoveImageConfirmOpen(false);
|
||||
setIsPointCostConfirmOpen(false);
|
||||
}, [initialFormPayload, session]);
|
||||
|
||||
const pictureDescription = formState.pictureDescription.trim();
|
||||
@@ -359,6 +361,19 @@ export function PuzzleAgentWorkspace({
|
||||
return;
|
||||
}
|
||||
|
||||
if (formState.aiRedraw) {
|
||||
setIsPointCostConfirmOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
executeSubmitForm();
|
||||
};
|
||||
|
||||
const executeSubmitForm = () => {
|
||||
if (!canSubmit) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payloadPictureDescription = formState.aiRedraw
|
||||
? pictureDescription
|
||||
: pictureDescription || formState.referenceImageLabel || '上传拼图图片';
|
||||
@@ -371,10 +386,12 @@ export function PuzzleAgentWorkspace({
|
||||
};
|
||||
|
||||
if (!session && onCreateFromForm) {
|
||||
setIsPointCostConfirmOpen(false);
|
||||
onCreateFromForm(payload);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPointCostConfirmOpen(false);
|
||||
onExecuteAction({
|
||||
action: 'compile_puzzle_draft',
|
||||
promptText: payloadPictureDescription,
|
||||
@@ -697,6 +714,43 @@ export function PuzzleAgentWorkspace({
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{isPointCostConfirmOpen ? (
|
||||
<div className="platform-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center px-4 py-6">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="puzzle-point-cost-confirm-title"
|
||||
className="platform-modal-shell platform-remap-surface w-full max-w-xs rounded-[1.35rem] p-5 shadow-[0_24px_70px_rgba(15,23,42,0.22)]"
|
||||
>
|
||||
<div
|
||||
id="puzzle-point-cost-confirm-title"
|
||||
className="text-base font-black text-[var(--platform-text-strong)]"
|
||||
>
|
||||
确认消耗泥点
|
||||
</div>
|
||||
<div className="mt-2 text-sm font-semibold leading-6 text-[var(--platform-text-base)]">
|
||||
消耗 2 泥点
|
||||
</div>
|
||||
<div className="mt-5 grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsPointCostConfirmOpen(false)}
|
||||
className="platform-button platform-button--secondary justify-center"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canSubmit}
|
||||
onClick={executeSubmitForm}
|
||||
className={`platform-button platform-button--primary justify-center ${!canSubmit ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
>
|
||||
确定
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -542,7 +542,7 @@ describe('PuzzleResultView', () => {
|
||||
const publishDialog = screen.getByRole('dialog', { name: '发布拼图作品' });
|
||||
expect(within(publishDialog).getByText('还有关卡画面正在生成。')).toBeTruthy();
|
||||
expect(
|
||||
within(publishDialog).getByRole('button', { name: '发布到广场' }),
|
||||
within(publishDialog).getByRole('button', { name: /发布到广场/u }),
|
||||
).toHaveProperty('disabled', true);
|
||||
});
|
||||
|
||||
@@ -561,7 +561,7 @@ describe('PuzzleResultView', () => {
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('dialog', { name: '发布拼图作品' })).getByRole(
|
||||
'button',
|
||||
{ name: '发布到广场' },
|
||||
{ name: /发布到广场/u },
|
||||
),
|
||||
);
|
||||
|
||||
@@ -598,7 +598,7 @@ describe('PuzzleResultView', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /发布/u }));
|
||||
const dialog = screen.getByRole('dialog', { name: '发布拼图作品' });
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole('button', { name: '发布到广场' }),
|
||||
within(dialog).getByRole('button', { name: /发布到广场/u }),
|
||||
);
|
||||
|
||||
rerender(
|
||||
@@ -715,6 +715,56 @@ describe('PuzzleResultView', () => {
|
||||
expect(within(preview).getByLabelText('拼图区边界')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('UI背景只有 objectKey 时草稿页仍显示生成图', () => {
|
||||
const base = createSession();
|
||||
const level = base.draft!.levels![0]!;
|
||||
|
||||
render(
|
||||
<PuzzleResultView
|
||||
session={createSession({
|
||||
draft: {
|
||||
...base.draft!,
|
||||
levels: [
|
||||
{
|
||||
...level,
|
||||
uiBackgroundPrompt: '雨夜猫街竖屏拼图UI背景',
|
||||
uiBackgroundImageSrc: null,
|
||||
uiBackgroundImageObjectKey:
|
||||
'generated-puzzle-assets/session/ui/background-object-key.png',
|
||||
},
|
||||
],
|
||||
},
|
||||
})}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
|
||||
|
||||
expect(screen.getByAltText('拼图UI背景图').getAttribute('src')).toBe(
|
||||
'/generated-puzzle-assets/session/ui/background-object-key.png',
|
||||
);
|
||||
expect(screen.getByRole('button', { name: /重新生成/u })).toBeTruthy();
|
||||
});
|
||||
|
||||
test('does not display local fallback as saved UI background prompt', () => {
|
||||
render(
|
||||
<PuzzleResultView
|
||||
session={createSession()}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
|
||||
|
||||
expect(screen.getByLabelText('拼图UI背景提示词')).toHaveProperty(
|
||||
'value',
|
||||
'',
|
||||
);
|
||||
});
|
||||
|
||||
test('generates UI background with edited prompt and current levels snapshot', () => {
|
||||
const onExecuteAction = vi.fn();
|
||||
|
||||
@@ -732,6 +782,11 @@ describe('PuzzleResultView', () => {
|
||||
});
|
||||
expect(screen.getByRole('button', { name: /生成UI背景 · 2泥点/u })).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole('button', { name: /生成UI背景/u }));
|
||||
const confirmDialog = screen.getByRole('dialog', {
|
||||
name: '确认消耗泥点',
|
||||
});
|
||||
expect(within(confirmDialog).getByText('消耗 2 泥点')).toBeTruthy();
|
||||
fireEvent.click(within(confirmDialog).getByRole('button', { name: '确定' }));
|
||||
|
||||
expect(onExecuteAction).toHaveBeenCalledWith({
|
||||
action: 'generate_puzzle_ui_background',
|
||||
@@ -752,7 +807,7 @@ describe('PuzzleResultView', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test('素材配置背景音乐试听使用签名地址', () => {
|
||||
test('素材配置隐藏背景音乐入口', () => {
|
||||
const base = createSession();
|
||||
const level = base.draft!.levels![0]!;
|
||||
|
||||
@@ -784,15 +839,12 @@ describe('PuzzleResultView', () => {
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '背景音乐' }));
|
||||
expect(screen.getByRole('button', { name: /重新生成音乐 · 5泥点/u })).toBeTruthy();
|
||||
|
||||
expect(screen.getByLabelText('拼图背景音乐').getAttribute('src')).toBe(
|
||||
'https://signed.example.com/generated-puzzle-assets/session/audio/music.mp3',
|
||||
);
|
||||
expect(screen.queryByRole('button', { name: '背景音乐' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /重新生成音乐/u })).toBeNull();
|
||||
expect(screen.queryByLabelText('拼图背景音乐')).toBeNull();
|
||||
});
|
||||
|
||||
test('生成完成回包合并音乐和UI背景后试玩使用最新资源', () => {
|
||||
test('生成完成回包合并历史音乐和UI背景后试玩使用最新资源', () => {
|
||||
const onStartTestRun = vi.fn();
|
||||
const base = createSession();
|
||||
const localLevel = {
|
||||
@@ -857,10 +909,7 @@ describe('PuzzleResultView', () => {
|
||||
expect(screen.getByAltText('拼图UI背景图').getAttribute('src')).toBe(
|
||||
'/generated-puzzle-assets/session/ui/fruit-background.png',
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '背景音乐' }));
|
||||
expect(screen.getByLabelText('拼图背景音乐').getAttribute('src')).toBe(
|
||||
'https://signed.example.com/generated-puzzle-assets/session/audio/fruit.mp3',
|
||||
);
|
||||
expect(screen.queryByRole('button', { name: '背景音乐' })).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '试玩' }));
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
LayoutTemplate,
|
||||
Loader2,
|
||||
MessageSquareText,
|
||||
Music,
|
||||
Play,
|
||||
Plus,
|
||||
Sparkles,
|
||||
@@ -18,7 +17,6 @@ import {
|
||||
import { type ChangeEvent, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import type { CreationAudioAsset } from '../../../packages/shared/src/contracts/creationAudio';
|
||||
import type { CreativeDraftEditResult } from '../../../packages/shared/src/contracts/creativeAgent';
|
||||
import type { PuzzleAgentActionRequest } from '../../../packages/shared/src/contracts/puzzleAgentActions';
|
||||
import type {
|
||||
@@ -26,14 +24,9 @@ import type {
|
||||
PuzzleResultDraft,
|
||||
} from '../../../packages/shared/src/contracts/puzzleAgentDraft';
|
||||
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
import {
|
||||
createBackgroundMusicTask,
|
||||
publishBackgroundMusicAsset,
|
||||
waitForGeneratedAudioAsset,
|
||||
} from '../../services/creation-audio';
|
||||
import { updatePuzzleWork } from '../../services/puzzle-works';
|
||||
import { resolvePuzzleUiBackgroundSource } from '../../services/puzzle-runtime/puzzleUiBackgroundSource';
|
||||
import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage';
|
||||
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import PuzzleHistoryAssetPickerDialog from '../puzzle-agent/PuzzleHistoryAssetPickerDialog';
|
||||
import {
|
||||
@@ -63,7 +56,7 @@ type PuzzleResultViewProps = {
|
||||
|
||||
type PuzzleAutoSaveState = 'idle' | 'saving' | 'saved' | 'error';
|
||||
type PuzzleResultTab = 'levels' | 'work' | 'assets';
|
||||
type PuzzleAssetConfigTabId = 'ui' | 'music';
|
||||
type PuzzleAssetConfigTabId = 'ui';
|
||||
|
||||
type DraftEditState = {
|
||||
workTitle: string;
|
||||
@@ -76,10 +69,8 @@ const PUZZLE_MIN_THEME_TAG_COUNT = 3;
|
||||
const PUZZLE_MAX_THEME_TAG_COUNT = 6;
|
||||
const PUZZLE_AUTOSAVE_DEBOUNCE_MS = 600;
|
||||
const PUZZLE_IMAGE_GENERATION_POINT_COST = 2;
|
||||
const PUZZLE_BACKGROUND_MUSIC_POINT_COST = 5;
|
||||
const PUZZLE_PUBLISH_POINT_COST = 1;
|
||||
const PUZZLE_IMAGE_GENERATION_ESTIMATE_SECONDS = 90;
|
||||
const PUZZLE_BACKGROUND_MUSIC_ASSET_KIND = 'puzzle_background_music';
|
||||
const PUZZLE_BACKGROUND_MUSIC_SLOT = 'background_music';
|
||||
const PUZZLE_UI_BACKGROUND_REFERENCE_SRC =
|
||||
'/ui-previews/puzzle-image-compact-ui-2026-05-08.png';
|
||||
|
||||
@@ -94,7 +85,6 @@ const PUZZLE_ASSET_CONFIG_TABS: Array<{
|
||||
label: string;
|
||||
}> = [
|
||||
{ id: 'ui', label: 'UI' },
|
||||
{ id: 'music', label: '背景音乐' },
|
||||
];
|
||||
|
||||
type PuzzleLevelGenerationRuntime = {
|
||||
@@ -1099,8 +1089,13 @@ function PuzzlePublishDialog({
|
||||
{actionError}
|
||||
</div>
|
||||
) : publishReady ? (
|
||||
<div className="platform-banner platform-banner--success text-sm leading-6">
|
||||
当前作品已满足发布条件。
|
||||
<div className="space-y-2">
|
||||
<div className="platform-banner platform-banner--success text-sm leading-6">
|
||||
当前作品已满足发布条件。
|
||||
</div>
|
||||
<div className="platform-banner platform-banner--warning text-sm font-semibold leading-6">
|
||||
消耗 {PUZZLE_PUBLISH_POINT_COST} 泥点
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
@@ -1151,7 +1146,9 @@ function PuzzlePublishDialog({
|
||||
disabled={!publishReady || isBusy}
|
||||
className={`platform-button platform-button--primary ${!publishReady || isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
>
|
||||
{isBusy ? '发布中...' : '发布到广场'}
|
||||
{isBusy
|
||||
? '发布中...'
|
||||
: `发布到广场 · ${PUZZLE_PUBLISH_POINT_COST}泥点`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1426,11 +1423,13 @@ function PuzzleUiAssetsTab({
|
||||
editState,
|
||||
firstLevel,
|
||||
);
|
||||
const prompt = firstLevel?.uiBackgroundPrompt ?? defaultPrompt;
|
||||
const prompt = firstLevel?.uiBackgroundPrompt ?? '';
|
||||
const normalizedPrompt = prompt.trim() || defaultPrompt.trim();
|
||||
const backgroundPreviewSrc =
|
||||
firstLevel?.uiBackgroundImageSrc?.trim() || PUZZLE_UI_BACKGROUND_REFERENCE_SRC;
|
||||
resolvePuzzleUiBackgroundSource(firstLevel) || PUZZLE_UI_BACKGROUND_REFERENCE_SRC;
|
||||
const hasGeneratedUiBackground = Boolean(resolvePuzzleUiBackgroundSource(firstLevel));
|
||||
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
|
||||
const [isCostConfirmOpen, setIsCostConfirmOpen] = useState(false);
|
||||
|
||||
const updateFirstLevel = (nextLevel: PuzzleDraftLevel) => {
|
||||
onChange({
|
||||
@@ -1495,11 +1494,7 @@ function PuzzleUiAssetsTab({
|
||||
if (!firstLevel || !normalizedPrompt) {
|
||||
return;
|
||||
}
|
||||
updateFirstLevel({
|
||||
...firstLevel,
|
||||
uiBackgroundPrompt: normalizedPrompt,
|
||||
});
|
||||
onGenerate(normalizedPrompt);
|
||||
setIsCostConfirmOpen(true);
|
||||
}}
|
||||
className={`platform-button platform-button--primary min-h-11 justify-center gap-2 px-4 py-3 ${!firstLevel || !normalizedPrompt || isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
>
|
||||
@@ -1508,7 +1503,7 @@ function PuzzleUiAssetsTab({
|
||||
) : (
|
||||
<Wand2 className="h-4 w-4" />
|
||||
)}
|
||||
{firstLevel?.uiBackgroundImageSrc ? '重新生成' : '生成UI背景'} · {PUZZLE_IMAGE_GENERATION_POINT_COST}泥点
|
||||
{hasGeneratedUiBackground ? '重新生成' : '生成UI背景'} · {PUZZLE_IMAGE_GENERATION_POINT_COST}泥点
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1524,6 +1519,53 @@ function PuzzleUiAssetsTab({
|
||||
onClose={() => setIsPreviewOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
{isCostConfirmOpen ? (
|
||||
<div className="platform-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center px-4 py-6">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="puzzle-ui-point-cost-confirm-title"
|
||||
className="platform-modal-shell platform-remap-surface w-full max-w-xs rounded-[1.35rem] p-5 shadow-[0_24px_70px_rgba(15,23,42,0.22)]"
|
||||
>
|
||||
<div
|
||||
id="puzzle-ui-point-cost-confirm-title"
|
||||
className="text-base font-black text-[var(--platform-text-strong)]"
|
||||
>
|
||||
确认消耗泥点
|
||||
</div>
|
||||
<div className="mt-2 text-sm font-semibold leading-6 text-[var(--platform-text-base)]">
|
||||
消耗 {PUZZLE_IMAGE_GENERATION_POINT_COST} 泥点
|
||||
</div>
|
||||
<div className="mt-5 grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsCostConfirmOpen(false)}
|
||||
className="platform-button platform-button--secondary justify-center"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!firstLevel || !normalizedPrompt || isBusy}
|
||||
onClick={() => {
|
||||
if (!firstLevel || !normalizedPrompt) {
|
||||
return;
|
||||
}
|
||||
updateFirstLevel({
|
||||
...firstLevel,
|
||||
uiBackgroundPrompt: normalizedPrompt,
|
||||
});
|
||||
setIsCostConfirmOpen(false);
|
||||
onGenerate(normalizedPrompt);
|
||||
}}
|
||||
className={`platform-button platform-button--primary justify-center ${!firstLevel || !normalizedPrompt || isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
>
|
||||
确定
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1648,187 +1690,11 @@ function PuzzleUiRuntimePreviewPanel({
|
||||
);
|
||||
}
|
||||
|
||||
function PuzzleMusicTab({
|
||||
editState,
|
||||
profileId,
|
||||
sessionId,
|
||||
isBusy,
|
||||
onChange,
|
||||
}: {
|
||||
editState: DraftEditState;
|
||||
profileId: string | null;
|
||||
sessionId: string;
|
||||
isBusy: boolean;
|
||||
onChange: (nextState: DraftEditState) => void;
|
||||
}) {
|
||||
const currentMusic = editState.levels[0]?.backgroundMusic ?? null;
|
||||
const [title, setTitle] = useState(() =>
|
||||
(
|
||||
currentMusic?.title?.trim() ||
|
||||
editState.levels[0]?.levelName.trim() ||
|
||||
editState.workTitle.trim() ||
|
||||
'拼图'
|
||||
).slice(0, 40),
|
||||
);
|
||||
const [tags, setTags] = useState('轻快, 游戏, 循环, instrumental');
|
||||
const [statusText, setStatusText] = useState<string | null>(null);
|
||||
const [errorText, setErrorText] = useState<string | null>(null);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const { resolvedUrl: resolvedMusicSrc } = useResolvedAssetReadUrl(
|
||||
currentMusic?.audioSrc,
|
||||
{ expireSeconds: 300 },
|
||||
);
|
||||
|
||||
const canGenerate = title.trim().length > 0;
|
||||
const writeMusic = (music: CreationAudioAsset) => {
|
||||
const firstLevel = editState.levels[0];
|
||||
if (!firstLevel) {
|
||||
return;
|
||||
}
|
||||
onChange({
|
||||
...editState,
|
||||
levels: [
|
||||
{ ...firstLevel, backgroundMusic: music },
|
||||
...editState.levels.slice(1),
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const generateMusic = async () => {
|
||||
if (!canGenerate || isGenerating || !editState.levels[0]) {
|
||||
return;
|
||||
}
|
||||
setIsGenerating(true);
|
||||
setStatusText('生成中');
|
||||
setErrorText(null);
|
||||
try {
|
||||
const task = await createBackgroundMusicTask({
|
||||
prompt: '',
|
||||
title: title.trim(),
|
||||
tags: tags.trim() || null,
|
||||
});
|
||||
const asset = await waitForGeneratedAudioAsset(task.taskId, () =>
|
||||
publishBackgroundMusicAsset(task.taskId, {
|
||||
entityKind: 'puzzle_work',
|
||||
entityId: profileId ?? sessionId,
|
||||
slot: PUZZLE_BACKGROUND_MUSIC_SLOT,
|
||||
assetKind: PUZZLE_BACKGROUND_MUSIC_ASSET_KIND,
|
||||
profileId,
|
||||
storagePrefix: 'puzzle_assets',
|
||||
}),
|
||||
);
|
||||
if (!asset.audioSrc) {
|
||||
throw new Error('音频生成完成但缺少播放地址。');
|
||||
}
|
||||
writeMusic({
|
||||
taskId: asset.taskId,
|
||||
provider: asset.provider,
|
||||
assetObjectId: asset.assetObjectId ?? null,
|
||||
assetKind: asset.assetKind ?? PUZZLE_BACKGROUND_MUSIC_ASSET_KIND,
|
||||
audioSrc: asset.audioSrc,
|
||||
prompt: '',
|
||||
title: title.trim(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
setStatusText('已生成');
|
||||
} catch (caughtError) {
|
||||
setErrorText(
|
||||
caughtError instanceof Error ? caughtError.message : '背景音乐生成失败。',
|
||||
);
|
||||
setStatusText(null);
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<section className="platform-subpanel rounded-[1.35rem] p-4 sm:p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
背景音乐
|
||||
</div>
|
||||
{statusText ? (
|
||||
<span className="platform-pill platform-pill--cool px-3 py-1 text-[11px]">
|
||||
{statusText}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{currentMusic?.audioSrc && resolvedMusicSrc ? (
|
||||
<audio
|
||||
className="mt-3 w-full"
|
||||
controls
|
||||
src={resolvedMusicSrc}
|
||||
aria-label="拼图背景音乐"
|
||||
/>
|
||||
) : currentMusic?.audioSrc ? (
|
||||
<div className="mt-3 rounded-[0.9rem] border border-[var(--platform-subpanel-border)] bg-white/62 px-3 py-3 text-sm font-semibold text-[var(--platform-text-soft)]">
|
||||
音频已绑定
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-3 flex h-12 items-center gap-2 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/62 px-3 text-sm font-semibold text-[var(--platform-text-soft)]">
|
||||
<Music className="h-4 w-4" />
|
||||
暂无音乐
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="platform-subpanel rounded-[1.35rem] p-4 sm:p-5">
|
||||
<label className="block">
|
||||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
曲名
|
||||
</span>
|
||||
<input
|
||||
value={title}
|
||||
disabled={isBusy || isGenerating}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
|
||||
aria-label="背景音乐曲名"
|
||||
/>
|
||||
</label>
|
||||
<label className="mt-3 block">
|
||||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
风格
|
||||
</span>
|
||||
<input
|
||||
value={tags}
|
||||
disabled={isBusy || isGenerating}
|
||||
onChange={(event) => setTags(event.target.value)}
|
||||
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
|
||||
aria-label="背景音乐风格"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canGenerate || isBusy || isGenerating}
|
||||
onClick={() => void generateMusic()}
|
||||
className={`platform-button platform-button--primary mt-3 min-h-11 w-full justify-center gap-2 ${!canGenerate || isBusy || isGenerating ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
>
|
||||
{isGenerating ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Music className="h-4 w-4" />
|
||||
)}
|
||||
{currentMusic ? '重新生成音乐' : '生成音乐'} · {PUZZLE_BACKGROUND_MUSIC_POINT_COST}泥点
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{errorText ? (
|
||||
<div className="platform-banner platform-banner--danger rounded-2xl text-sm leading-6">
|
||||
{errorText}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PuzzleAssetConfigTab({
|
||||
activeAssetConfigTab,
|
||||
editState,
|
||||
imageRefreshKey,
|
||||
isBusy,
|
||||
profileId,
|
||||
sessionId,
|
||||
onAssetConfigTabChange,
|
||||
onChange,
|
||||
onGenerateUiBackground,
|
||||
@@ -1837,8 +1703,6 @@ function PuzzleAssetConfigTab({
|
||||
editState: DraftEditState;
|
||||
imageRefreshKey: string;
|
||||
isBusy: boolean;
|
||||
profileId: string | null;
|
||||
sessionId: string;
|
||||
onAssetConfigTabChange: (tab: PuzzleAssetConfigTabId) => void;
|
||||
onChange: (nextState: DraftEditState) => void;
|
||||
onGenerateUiBackground: (prompt: string) => void;
|
||||
@@ -1858,15 +1722,6 @@ function PuzzleAssetConfigTab({
|
||||
onGenerate={onGenerateUiBackground}
|
||||
/>
|
||||
) : null}
|
||||
{activeAssetConfigTab === 'music' ? (
|
||||
<PuzzleMusicTab
|
||||
editState={editState}
|
||||
profileId={profileId}
|
||||
sessionId={sessionId}
|
||||
isBusy={isBusy}
|
||||
onChange={onChange}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2300,8 +2155,6 @@ export function PuzzleResultView({
|
||||
editState={editState}
|
||||
imageRefreshKey={imageRefreshKey}
|
||||
isBusy={isBusy}
|
||||
profileId={profileId ?? null}
|
||||
sessionId={session.sessionId}
|
||||
onAssetConfigTabChange={setActiveAssetConfigTab}
|
||||
onChange={setEditState}
|
||||
onGenerateUiBackground={(prompt) => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { act, fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import type { PuzzleRunSnapshot } from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
||||
import { AuthUiContext } from '../auth/AuthUiContext';
|
||||
@@ -33,41 +33,6 @@ vi.mock('../ResolvedAssetImage', () => ({
|
||||
}) => (src ? <img src={src} alt={alt} className={className} /> : null),
|
||||
}));
|
||||
|
||||
const mocapMock = vi.hoisted(() => ({
|
||||
state: 'grab',
|
||||
x: 0.42,
|
||||
y: 0.58,
|
||||
}));
|
||||
|
||||
const debugModeMock = vi.hoisted(() => ({
|
||||
enabled: true,
|
||||
}));
|
||||
|
||||
vi.mock('../../config/debugMode', () => ({
|
||||
IS_DEBUG_MODE: debugModeMock.enabled,
|
||||
isDebugMode: () => debugModeMock.enabled,
|
||||
}));
|
||||
|
||||
vi.mock('../../services/useMocapInput', () => ({
|
||||
useMocapInput: () => ({
|
||||
status: 'connected',
|
||||
latestCommand: {
|
||||
actions: [mocapMock.state],
|
||||
primaryHand: {x: mocapMock.x, y: mocapMock.y, state: mocapMock.state, source: 'palm_center'},
|
||||
parseWarnings: [],
|
||||
},
|
||||
rawPacketPreview: {text: '{"hands":[{"state":"grab"}]}', receivedAtMs: 1},
|
||||
error: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
debugModeMock.enabled = true;
|
||||
mocapMock.state = 'grab';
|
||||
mocapMock.x = 0.42;
|
||||
mocapMock.y = 0.58;
|
||||
});
|
||||
|
||||
function createAuthValue() {
|
||||
return {
|
||||
user: null,
|
||||
@@ -181,42 +146,7 @@ const clearedRun: PuzzleRunSnapshot = {
|
||||
},
|
||||
};
|
||||
|
||||
test('调试模式下拼图界面折叠展示 mocap 连接状态,展开后显示最近动作调试信息', () => {
|
||||
renderPuzzleRuntime(
|
||||
<PuzzleRuntimeShell
|
||||
run={{
|
||||
...clearedRun,
|
||||
currentLevel: {
|
||||
...clearedRun.currentLevel!,
|
||||
status: 'playing',
|
||||
},
|
||||
}}
|
||||
onBack={vi.fn()}
|
||||
onSwapPieces={vi.fn()}
|
||||
onDragPiece={vi.fn()}
|
||||
onAdvanceNextLevel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const debugPanel = screen.getByTestId('puzzle-mocap-debug');
|
||||
expect(within(debugPanel).getByText('mocap: connected')).toBeTruthy();
|
||||
const toggleButton = within(debugPanel).getByRole('button', {
|
||||
name: 'mocap: connected',
|
||||
});
|
||||
expect(toggleButton.getAttribute('aria-expanded')).toBe('false');
|
||||
expect(within(debugPanel).queryByText('动作: grab')).toBeNull();
|
||||
|
||||
fireEvent.click(toggleButton);
|
||||
|
||||
expect(toggleButton.getAttribute('aria-expanded')).toBe('true');
|
||||
expect(within(debugPanel).getByText('动作: grab')).toBeTruthy();
|
||||
expect(within(debugPanel).getByText('手势: grab @ 0.42, 0.58')).toBeTruthy();
|
||||
expect(within(debugPanel).getByText('解析: 无')).toBeTruthy();
|
||||
expect(within(debugPanel).getByText(/原始:/)).toBeTruthy();
|
||||
});
|
||||
|
||||
test('非调试模式下拼图界面不渲染 mocap 调试面板', () => {
|
||||
debugModeMock.enabled = false;
|
||||
test('拼图界面不调用 mocap,也不渲染 mocap 光标或调试面板', () => {
|
||||
renderPuzzleRuntime(
|
||||
<PuzzleRuntimeShell
|
||||
run={{
|
||||
@@ -234,44 +164,10 @@ test('非调试模式下拼图界面不渲染 mocap 调试面板', () => {
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId('puzzle-mocap-debug')).toBeNull();
|
||||
expect(screen.queryByTestId('puzzle-mocap-cursor')).toBeNull();
|
||||
});
|
||||
|
||||
test('拼图界面在 mocap open_palm 时显示体感光标', () => {
|
||||
mocapMock.state = 'open_palm';
|
||||
mocapMock.x = 0.42;
|
||||
mocapMock.y = 0.58;
|
||||
renderPuzzleRuntime(
|
||||
<PuzzleRuntimeShell
|
||||
run={{
|
||||
...clearedRun,
|
||||
currentLevel: {
|
||||
...clearedRun.currentLevel!,
|
||||
status: 'playing',
|
||||
startedAtMs: Date.now(),
|
||||
remainingMs: 300_000,
|
||||
timeLimitMs: 300_000,
|
||||
},
|
||||
}}
|
||||
onBack={vi.fn()}
|
||||
onSwapPieces={vi.fn()}
|
||||
onDragPiece={vi.fn()}
|
||||
onAdvanceNextLevel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const cursor = screen.getByTestId('puzzle-mocap-cursor');
|
||||
expect(cursor).toBeTruthy();
|
||||
expect(Number.parseFloat(cursor.style.left)).toBeCloseTo(42);
|
||||
expect(Number.parseFloat(cursor.style.top)).toBeCloseTo(58);
|
||||
mocapMock.state = 'grab';
|
||||
mocapMock.x = 0.42;
|
||||
mocapMock.y = 0.58;
|
||||
});
|
||||
|
||||
test('抓握时会触发拖拽提交并在松开时落子', () => {
|
||||
mocapMock.state = 'grab';
|
||||
mocapMock.x = 0.34;
|
||||
mocapMock.y = 0.34;
|
||||
test('指针拖拽时会触发拖拽提交并在松开时落子', () => {
|
||||
const onDragPiece = vi.fn();
|
||||
const playingRun: PuzzleRunSnapshot = {
|
||||
...clearedRun,
|
||||
@@ -358,12 +254,9 @@ test('抓握时会触发拖拽提交并在松开时落子', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('mocap 抓握合并大块时按大块锚点提交拖拽', () => {
|
||||
test('指针拖拽合并大块时按大块锚点提交拖拽', () => {
|
||||
const originalRequestAnimationFrame = window.requestAnimationFrame;
|
||||
const originalCancelAnimationFrame = window.cancelAnimationFrame;
|
||||
mocapMock.state = 'open_palm';
|
||||
mocapMock.x = 0.2;
|
||||
mocapMock.y = 0.2;
|
||||
const onDragPiece = vi.fn();
|
||||
const mergedRun: PuzzleRunSnapshot = {
|
||||
...clearedRun,
|
||||
@@ -405,7 +298,7 @@ test('mocap 抓握合并大块时按大块锚点提交拖拽', () => {
|
||||
value: vi.fn(),
|
||||
});
|
||||
|
||||
const { container, rerender, unmount } = renderPuzzleRuntime(
|
||||
const { container, unmount } = renderPuzzleRuntime(
|
||||
<PuzzleRuntimeShell
|
||||
run={mergedRun}
|
||||
onBack={vi.fn()}
|
||||
@@ -432,48 +325,34 @@ test('mocap 抓握合并大块时按大块锚点提交拖拽', () => {
|
||||
height: 300,
|
||||
toJSON: () => ({}),
|
||||
}) as DOMRect;
|
||||
const mergedPiece = container.querySelector(
|
||||
'[data-merged-piece-outline="true"]',
|
||||
) as HTMLElement | null;
|
||||
if (!mergedPiece) {
|
||||
throw new Error('缺少测试合并拼图片');
|
||||
}
|
||||
|
||||
mocapMock.state = 'grab';
|
||||
mocapMock.x = 0.2;
|
||||
mocapMock.y = 0.2;
|
||||
rerender(
|
||||
<AuthUiContext.Provider value={createAuthValue()}>
|
||||
<PuzzleRuntimeShell
|
||||
run={mergedRun}
|
||||
onBack={vi.fn()}
|
||||
onSwapPieces={vi.fn()}
|
||||
onDragPiece={onDragPiece}
|
||||
onAdvanceNextLevel={vi.fn()}
|
||||
/>
|
||||
</AuthUiContext.Provider>,
|
||||
);
|
||||
|
||||
mocapMock.x = 0.7;
|
||||
mocapMock.y = 0.7;
|
||||
rerender(
|
||||
<AuthUiContext.Provider value={createAuthValue()}>
|
||||
<PuzzleRuntimeShell
|
||||
run={mergedRun}
|
||||
onBack={vi.fn()}
|
||||
onSwapPieces={vi.fn()}
|
||||
onDragPiece={onDragPiece}
|
||||
onAdvanceNextLevel={vi.fn()}
|
||||
/>
|
||||
</AuthUiContext.Provider>,
|
||||
);
|
||||
|
||||
mocapMock.state = 'open_palm';
|
||||
rerender(
|
||||
<AuthUiContext.Provider value={createAuthValue()}>
|
||||
<PuzzleRuntimeShell
|
||||
run={mergedRun}
|
||||
onBack={vi.fn()}
|
||||
onSwapPieces={vi.fn()}
|
||||
onDragPiece={onDragPiece}
|
||||
onAdvanceNextLevel={vi.fn()}
|
||||
/>
|
||||
</AuthUiContext.Provider>,
|
||||
);
|
||||
act(() => {
|
||||
dispatchPointerEvent(mergedPiece, 'pointerdown', {
|
||||
pointerId: 12,
|
||||
clientX: 60,
|
||||
clientY: 60,
|
||||
});
|
||||
});
|
||||
act(() => {
|
||||
dispatchPointerEvent(mergedPiece, 'pointermove', {
|
||||
pointerId: 12,
|
||||
clientX: 210,
|
||||
clientY: 210,
|
||||
});
|
||||
});
|
||||
act(() => {
|
||||
dispatchPointerEvent(mergedPiece, 'pointerup', {
|
||||
pointerId: 12,
|
||||
clientX: 210,
|
||||
clientY: 210,
|
||||
});
|
||||
});
|
||||
|
||||
expect(onDragPiece).toHaveBeenCalledTimes(1);
|
||||
expect(onDragPiece).toHaveBeenCalledWith({
|
||||
@@ -491,9 +370,6 @@ test('mocap 抓握合并大块时按大块锚点提交拖拽', () => {
|
||||
configurable: true,
|
||||
value: originalCancelAnimationFrame,
|
||||
});
|
||||
mocapMock.state = 'grab';
|
||||
mocapMock.x = 0.42;
|
||||
mocapMock.y = 0.58;
|
||||
});
|
||||
|
||||
test('通关后显示结算弹窗、排行榜和下一关按钮', () => {
|
||||
@@ -661,6 +537,37 @@ test('运行态优先把关卡 UI 背景渲染为舞台背景', () => {
|
||||
expect(backgroundImage).toBeTruthy();
|
||||
});
|
||||
|
||||
test('运行态在只有 UI 背景 objectKey 时仍渲染生成背景', () => {
|
||||
const runWithUiBackground: PuzzleRunSnapshot = {
|
||||
...clearedRun,
|
||||
currentLevel: {
|
||||
...clearedRun.currentLevel!,
|
||||
status: 'playing',
|
||||
coverImageSrc: '/generated-puzzle-assets/session/cover.png',
|
||||
uiBackgroundImageSrc: null,
|
||||
uiBackgroundImageObjectKey:
|
||||
'generated-puzzle-assets/session/ui/background-object-key.png',
|
||||
remainingMs: 300_000,
|
||||
timeLimitMs: 300_000,
|
||||
},
|
||||
};
|
||||
|
||||
const { container } = renderPuzzleRuntime(
|
||||
<PuzzleRuntimeShell
|
||||
run={runWithUiBackground}
|
||||
onBack={vi.fn()}
|
||||
onSwapPieces={vi.fn()}
|
||||
onDragPiece={vi.fn()}
|
||||
onAdvanceNextLevel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const backgroundImage = container.querySelector(
|
||||
'img[src="/generated-puzzle-assets/session/ui/background-object-key.png"]',
|
||||
);
|
||||
expect(backgroundImage).toBeTruthy();
|
||||
});
|
||||
|
||||
test('关闭通关弹窗后保留底部下一关入口', () => {
|
||||
vi.useFakeTimers();
|
||||
const onAdvanceNextLevel = vi.fn();
|
||||
@@ -1046,9 +953,6 @@ test('移动端点击拼图片时立即触发一次震动反馈', () => {
|
||||
const originalRequestAnimationFrame = window.requestAnimationFrame;
|
||||
const originalCancelAnimationFrame = window.cancelAnimationFrame;
|
||||
const vibrate = vi.fn();
|
||||
mocapMock.state = 'open_palm';
|
||||
mocapMock.x = 0.42;
|
||||
mocapMock.y = 0.58;
|
||||
const playingRun: PuzzleRunSnapshot = {
|
||||
...clearedRun,
|
||||
currentLevel: {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Clock,
|
||||
Eye,
|
||||
Lightbulb,
|
||||
@@ -24,12 +22,11 @@ import type {
|
||||
PuzzleRuntimePropKind,
|
||||
SwapPuzzlePiecesRequest,
|
||||
} from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
||||
import { isDebugMode } from '../../config/debugMode';
|
||||
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
|
||||
import { resolvePuzzleUiBackgroundSource } from '../../services/puzzle-runtime/puzzleUiBackgroundSource';
|
||||
import {
|
||||
createRuntimeDragInputController,
|
||||
createRuntimeInputPointFromClient,
|
||||
createRuntimeInputPointFromNormalized,
|
||||
readRuntimeInputElementBounds,
|
||||
resolveRuntimeInputGridCell,
|
||||
type RuntimeDragInputSession,
|
||||
@@ -42,7 +39,6 @@ import {
|
||||
playRuntimeLevelClearSound,
|
||||
resolveRuntimeCountdownSecondBucket,
|
||||
} from '../../services/runtimeAudioFeedback';
|
||||
import { useMocapInput } from '../../services/useMocapInput';
|
||||
import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../../uiAssets';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import { PixelIcon } from '../PixelIcon';
|
||||
@@ -230,8 +226,6 @@ const PUZZLE_HINT_DEMO_DURATION_MS = 1_250;
|
||||
const PUZZLE_PIECE_PRESS_HAPTIC_PATTERN_MS = 12;
|
||||
const PUZZLE_EXIT_REMODEL_PROMPT_STORAGE_PREFIX =
|
||||
'genarrative.puzzle-runtime.exit-remodel-prompt.v1';
|
||||
const PUZZLE_MOCAP_DRAG_INPUT_ID = 'mocap:primary-hand';
|
||||
const PUZZLE_MOCAP_CURSOR_FRAME_MS = 1000 / 60;
|
||||
|
||||
const shownExitRemodelPromptProfileIds = new Set<string>();
|
||||
|
||||
@@ -305,16 +299,6 @@ type PuzzleHintDemoState = {
|
||||
offsetYPercent: number;
|
||||
};
|
||||
|
||||
type PuzzleMocapCursorState = {
|
||||
x: number;
|
||||
y: number;
|
||||
state: string;
|
||||
};
|
||||
|
||||
type PuzzleMocapCursorSample = PuzzleMocapCursorState & {
|
||||
receivedAtMs: number;
|
||||
};
|
||||
|
||||
type PuzzleRuntimeDragTargetState = {
|
||||
pieceId: string;
|
||||
groupId: string | null;
|
||||
@@ -376,7 +360,6 @@ export function PuzzleRuntimeShell({
|
||||
const [isFreezeEffectVisible, setIsFreezeEffectVisible] = useState(false);
|
||||
const [isPropConfirming, setIsPropConfirming] = useState(false);
|
||||
const [propConfirmError, setPropConfirmError] = useState<string | null>(null);
|
||||
const [isMocapDebugExpanded, setIsMocapDebugExpanded] = useState(false);
|
||||
const [hintDemo, setHintDemo] = useState<PuzzleHintDemoState | null>(null);
|
||||
const [mergeFlash, setMergeFlash] = useState<PuzzleMergeFlashState | null>(
|
||||
null,
|
||||
@@ -414,17 +397,6 @@ export function PuzzleRuntimeShell({
|
||||
pieceId: string;
|
||||
groupId: string | null;
|
||||
} | null>(null);
|
||||
const [mocapCursor, setMocapCursor] = useState<PuzzleMocapCursorState | null>(
|
||||
null,
|
||||
);
|
||||
const mocapCursorPreviousSampleRef = useRef<PuzzleMocapCursorSample | null>(
|
||||
null,
|
||||
);
|
||||
const mocapCursorTargetSampleRef = useRef<PuzzleMocapCursorSample | null>(null);
|
||||
const mocapCursorIntervalRef = useRef<number | null>(null);
|
||||
const updateMocapCursorSampleRef = useRef<(
|
||||
nextSample: PuzzleMocapCursorSample,
|
||||
) => void>(() => {});
|
||||
const runtimeDragInputControllerRef = useRef(
|
||||
createRuntimeDragInputController<string>(),
|
||||
);
|
||||
@@ -470,7 +442,7 @@ export function PuzzleRuntimeShell({
|
||||
currentLevel?.coverImageSrc ?? null,
|
||||
);
|
||||
const { resolvedUrl: resolvedUiBackgroundImage } = useResolvedAssetReadUrl(
|
||||
currentLevel?.uiBackgroundImageSrc ?? null,
|
||||
resolvePuzzleUiBackgroundSource(currentLevel) ?? null,
|
||||
);
|
||||
const tryPlayBackgroundMusic = useCallback(() => {
|
||||
const audio = backgroundAudioRef.current;
|
||||
@@ -483,26 +455,6 @@ export function PuzzleRuntimeShell({
|
||||
audio.volume = Math.max(0, Math.min(1, musicVolume));
|
||||
void audio.play().catch(() => {});
|
||||
}, [musicVolume, resolvedBackgroundMusicSrc, runtimeStatus]);
|
||||
const mocapInput = useMocapInput({enabled: runtimeStatus === 'playing'});
|
||||
const primaryMocapHand = mocapInput.latestCommand?.primaryHand;
|
||||
const primaryMocapHandState = primaryMocapHand?.state;
|
||||
const primaryMocapHandX = primaryMocapHand?.x;
|
||||
const primaryMocapHandY = primaryMocapHand?.y;
|
||||
const mocapActionsLabel =
|
||||
mocapInput.latestCommand?.actions.length
|
||||
? mocapInput.latestCommand.actions.join(', ')
|
||||
: '无';
|
||||
const mocapHandLabel =
|
||||
primaryMocapHandState &&
|
||||
typeof primaryMocapHandX === 'number' &&
|
||||
typeof primaryMocapHandY === 'number'
|
||||
? `${primaryMocapHandState} @ ${primaryMocapHandX.toFixed(2)}, ${primaryMocapHandY.toFixed(2)}`
|
||||
: '无';
|
||||
const mocapParseWarningLabel = mocapInput.latestCommand?.parseWarnings?.length
|
||||
? mocapInput.latestCommand.parseWarnings.join(';')
|
||||
: '无';
|
||||
const mocapRawPacketLabel = mocapInput.rawPacketPreview?.text ?? '未收到';
|
||||
const shouldShowMocapDebugPanel = isDebugMode();
|
||||
|
||||
useEffect(() => {
|
||||
currentLevelRef.current = currentLevel;
|
||||
@@ -1018,31 +970,6 @@ export function PuzzleRuntimeShell({
|
||||
readRuntimeInputElementBounds(boardRef.current),
|
||||
);
|
||||
|
||||
const resolveBoardInputPointFromNormalized = (
|
||||
normalizedX: number,
|
||||
normalizedY: number,
|
||||
) =>
|
||||
createRuntimeInputPointFromNormalized(
|
||||
normalizedX,
|
||||
normalizedY,
|
||||
readRuntimeInputElementBounds(boardRef.current),
|
||||
);
|
||||
|
||||
const resetMocapCursorInterpolation = () => {
|
||||
mocapCursorPreviousSampleRef.current = null;
|
||||
mocapCursorTargetSampleRef.current = null;
|
||||
setMocapCursor(null);
|
||||
};
|
||||
|
||||
updateMocapCursorSampleRef.current = (nextSample: PuzzleMocapCursorSample) => {
|
||||
const previousTarget = mocapCursorTargetSampleRef.current;
|
||||
mocapCursorPreviousSampleRef.current = previousTarget ?? nextSample;
|
||||
mocapCursorTargetSampleRef.current = nextSample;
|
||||
if (!previousTarget) {
|
||||
setMocapCursor(nextSample);
|
||||
}
|
||||
};
|
||||
|
||||
const syncRuntimeDragFromController = (
|
||||
session: RuntimeDragInputSession<string> | null,
|
||||
) => {
|
||||
@@ -1103,136 +1030,6 @@ export function PuzzleRuntimeShell({
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const activeSession = runtimeDragInputControllerRef.current.getSession();
|
||||
if (!board || runtimeStatus !== 'playing' || isInteractionLocked) {
|
||||
runtimeDragInputControllerRef.current.cancel();
|
||||
resetMocapCursorInterpolation();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!primaryMocapHandState ||
|
||||
typeof primaryMocapHandX !== 'number' ||
|
||||
typeof primaryMocapHandY !== 'number'
|
||||
) {
|
||||
runtimeDragInputControllerRef.current.cancel(PUZZLE_MOCAP_DRAG_INPUT_ID);
|
||||
resetMocapCursorInterpolation();
|
||||
return;
|
||||
}
|
||||
|
||||
const nextSample = {
|
||||
x: primaryMocapHandX,
|
||||
y: primaryMocapHandY,
|
||||
state: primaryMocapHandState,
|
||||
receivedAtMs: performance.now(),
|
||||
};
|
||||
updateMocapCursorSampleRef.current(nextSample);
|
||||
const handPoint = resolveBoardInputPointFromNormalized(nextSample.x, nextSample.y);
|
||||
if (primaryMocapHandState === 'grab') {
|
||||
if (activeSession?.inputId !== PUZZLE_MOCAP_DRAG_INPUT_ID) {
|
||||
const sourceCell = resolveRuntimeInputGridCell(handPoint, board);
|
||||
const sourcePiece = sourceCell
|
||||
? pieceByCell.get(`${sourceCell.row}:${sourceCell.col}`) ?? null
|
||||
: null;
|
||||
if (!sourcePiece) {
|
||||
runtimeDragInputControllerRef.current.cancel(
|
||||
PUZZLE_MOCAP_DRAG_INPUT_ID,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
runtimeDragInputControllerRef.current.press({
|
||||
targetId: sourcePiece.pieceId,
|
||||
inputId: PUZZLE_MOCAP_DRAG_INPUT_ID,
|
||||
deviceKind: 'mocap',
|
||||
point: handPoint,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
runtimeDragInputControllerRef.current.move({
|
||||
inputId: PUZZLE_MOCAP_DRAG_INPUT_ID,
|
||||
point: handPoint,
|
||||
forceDragging: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeSession?.inputId === PUZZLE_MOCAP_DRAG_INPUT_ID) {
|
||||
runtimeDragInputControllerRef.current.release({
|
||||
inputId: PUZZLE_MOCAP_DRAG_INPUT_ID,
|
||||
point: handPoint,
|
||||
forceDrop: activeSession.deviceKind === 'mocap',
|
||||
});
|
||||
}
|
||||
}, [
|
||||
board,
|
||||
isInteractionLocked,
|
||||
pieceByCell,
|
||||
primaryMocapHandState,
|
||||
primaryMocapHandX,
|
||||
primaryMocapHandY,
|
||||
runtimeStatus,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!board || runtimeStatus !== 'playing') {
|
||||
if (mocapCursorIntervalRef.current !== null) {
|
||||
window.clearInterval(mocapCursorIntervalRef.current);
|
||||
mocapCursorIntervalRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const tickMocapCursor = () => {
|
||||
const targetSample = mocapCursorTargetSampleRef.current;
|
||||
if (!targetSample) {
|
||||
return;
|
||||
}
|
||||
const previousSample = mocapCursorPreviousSampleRef.current ?? targetSample;
|
||||
const durationMs = Math.max(
|
||||
PUZZLE_MOCAP_CURSOR_FRAME_MS,
|
||||
targetSample.receivedAtMs - previousSample.receivedAtMs,
|
||||
);
|
||||
const progress = targetSample.receivedAtMs === previousSample.receivedAtMs
|
||||
? 1
|
||||
: Math.min(
|
||||
1,
|
||||
Math.max(0, (performance.now() - targetSample.receivedAtMs) / durationMs),
|
||||
);
|
||||
const nextCursor = {
|
||||
x: previousSample.x + (targetSample.x - previousSample.x) * progress,
|
||||
y: previousSample.y + (targetSample.y - previousSample.y) * progress,
|
||||
state: targetSample.state,
|
||||
};
|
||||
const nextPoint = resolveBoardInputPointFromNormalized(
|
||||
nextCursor.x,
|
||||
nextCursor.y,
|
||||
);
|
||||
setMocapCursor(nextCursor);
|
||||
const activeSession = runtimeDragInputControllerRef.current.getSession();
|
||||
if (activeSession?.inputId === PUZZLE_MOCAP_DRAG_INPUT_ID) {
|
||||
runtimeDragInputControllerRef.current.move({
|
||||
inputId: PUZZLE_MOCAP_DRAG_INPUT_ID,
|
||||
point: nextPoint,
|
||||
forceDragging: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
tickMocapCursor();
|
||||
mocapCursorIntervalRef.current = window.setInterval(
|
||||
tickMocapCursor,
|
||||
PUZZLE_MOCAP_CURSOR_FRAME_MS,
|
||||
);
|
||||
return () => {
|
||||
if (mocapCursorIntervalRef.current !== null) {
|
||||
window.clearInterval(mocapCursorIntervalRef.current);
|
||||
mocapCursorIntervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [board, runtimeStatus]);
|
||||
|
||||
if (!run || !currentLevel || !board) {
|
||||
return (
|
||||
<div
|
||||
@@ -1810,21 +1607,6 @@ export function PuzzleRuntimeShell({
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{mocapCursor ? (
|
||||
<div
|
||||
data-testid="puzzle-mocap-cursor"
|
||||
className={`pointer-events-none absolute z-[70] flex h-8 w-8 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full border-2 ${
|
||||
mocapCursor.state === 'grab'
|
||||
? 'border-amber-200 bg-amber-400/90 text-amber-950'
|
||||
: 'border-cyan-200 bg-cyan-300/90 text-cyan-950'
|
||||
} shadow-[0_10px_24px_rgba(15,23,42,0.25)]`}
|
||||
style={{left: `${mocapCursor.x * 100}%`, top: `${mocapCursor.y * 100}%`}}
|
||||
>
|
||||
<span className="text-[10px] font-black leading-none">
|
||||
{mocapCursor.state === 'grab' ? '抓' : '手'}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{mergeFlash ? (
|
||||
<div
|
||||
key={mergeFlash.key}
|
||||
@@ -1852,45 +1634,6 @@ export function PuzzleRuntimeShell({
|
||||
已选择
|
||||
</div>
|
||||
) : null}
|
||||
{shouldShowMocapDebugPanel ? (
|
||||
<section
|
||||
data-testid="puzzle-mocap-debug"
|
||||
className="w-[min(92vw,34rem)] overflow-hidden rounded-[0.9rem] border border-white/20 bg-slate-950/70 font-mono text-[10px] leading-4 text-white shadow-[0_12px_32px_rgba(15,23,42,0.25)] backdrop-blur"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={isMocapDebugExpanded}
|
||||
aria-controls="puzzle-mocap-debug-content"
|
||||
onClick={() => {
|
||||
setIsMocapDebugExpanded((current) => !current);
|
||||
}}
|
||||
className="flex min-h-9 w-full items-center justify-between gap-3 px-3 py-2 text-left transition hover:bg-white/10"
|
||||
>
|
||||
<span className="min-w-0 truncate">
|
||||
mocap: {mocapInput.status}
|
||||
</span>
|
||||
{isMocapDebugExpanded ? (
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0" />
|
||||
) : (
|
||||
<ChevronUp className="h-3.5 w-3.5 shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
{isMocapDebugExpanded ? (
|
||||
<div
|
||||
id="puzzle-mocap-debug-content"
|
||||
className="border-t border-white/10 px-3 pb-2 pt-2"
|
||||
>
|
||||
<div>动作: {mocapActionsLabel}</div>
|
||||
<div>手势: {mocapHandLabel}</div>
|
||||
<div>解析: {mocapParseWarningLabel}</div>
|
||||
<div className="max-h-20 overflow-auto break-all text-white/75">
|
||||
原始: {mocapRawPacketLabel}
|
||||
</div>
|
||||
{mocapInput.error ? <div>错误: {mocapInput.error}</div> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
) : null}
|
||||
{canShowNextAction ? (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -18,7 +18,9 @@ export function RpgEntryBrandLogo({
|
||||
aria-hidden={decorative || undefined}
|
||||
aria-label={decorative ? undefined : '陶泥儿 GENARRATIVE'}
|
||||
>
|
||||
<span className="platform-brand-logo__title">陶泥儿</span>
|
||||
<span className="platform-brand-logo__title">
|
||||
陶泥<span className="platform-brand-logo__title-suffix">儿</span>
|
||||
</span>
|
||||
<span className="platform-brand-logo__subtitle">GENARRATIVE</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -94,6 +94,7 @@ import {
|
||||
} from '../../services/puzzle-runtime';
|
||||
import {
|
||||
dragLocalPuzzlePiece,
|
||||
startLocalPuzzleRun,
|
||||
swapLocalPuzzlePieces,
|
||||
} from '../../services/puzzle-runtime/puzzleLocalRuntime';
|
||||
import {
|
||||
@@ -142,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,
|
||||
@@ -289,10 +292,10 @@ const testCreationEntryConfig = {
|
||||
id: 'visual-novel',
|
||||
title: '视觉小说',
|
||||
subtitle: '分支叙事体验',
|
||||
badge: '可创建',
|
||||
badge: '敬请期待',
|
||||
imageSrc: '/creation-type-references/visual-novel.webp',
|
||||
visible: true,
|
||||
open: true,
|
||||
visible: false,
|
||||
open: false,
|
||||
sortOrder: 60,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
@@ -318,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;
|
||||
|
||||
@@ -526,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(),
|
||||
@@ -542,6 +578,10 @@ vi.mock('../../services/puzzle-runtime/puzzleLocalRuntime', async () => {
|
||||
return {
|
||||
...actual,
|
||||
dragLocalPuzzlePiece: vi.fn(actual.dragLocalPuzzlePiece),
|
||||
startLocalPuzzleRun: vi.fn(
|
||||
(...args: Parameters<typeof actual.startLocalPuzzleRun>) =>
|
||||
actual.startLocalPuzzleRun(...args),
|
||||
),
|
||||
swapLocalPuzzlePieces: vi.fn(actual.swapLocalPuzzlePieces),
|
||||
};
|
||||
});
|
||||
@@ -810,10 +850,12 @@ vi.mock('../match3d-runtime/Match3DRuntimeShell', () => ({
|
||||
Match3DRuntimeShell: ({
|
||||
run,
|
||||
generatedItemAssets = [],
|
||||
generatedBackgroundAsset = null,
|
||||
onBack,
|
||||
}: {
|
||||
run: Match3DRunSnapshot | null;
|
||||
generatedItemAssets?: Match3DWorkSummary['generatedItemAssets'];
|
||||
generatedBackgroundAsset?: Match3DWorkSummary['generatedBackgroundAsset'];
|
||||
onBack: () => void;
|
||||
}) => (
|
||||
<div className="match3d-runtime-shell-mock">
|
||||
@@ -873,6 +915,22 @@ vi.mock('../match3d-runtime/Match3DRuntimeShell', () => ({
|
||||
).length
|
||||
}
|
||||
</div>
|
||||
<div data-testid="match3d-runtime-top-level-background-count">
|
||||
{
|
||||
generatedBackgroundAsset?.imageSrc?.trim() ||
|
||||
generatedBackgroundAsset?.imageObjectKey?.trim()
|
||||
? 1
|
||||
: 0
|
||||
}
|
||||
</div>
|
||||
<div data-testid="match3d-runtime-top-level-container-ui-count">
|
||||
{
|
||||
generatedBackgroundAsset?.containerImageSrc?.trim() ||
|
||||
generatedBackgroundAsset?.containerImageObjectKey?.trim()
|
||||
? 1
|
||||
: 0
|
||||
}
|
||||
</div>
|
||||
<button type="button" onClick={onBack}>
|
||||
返回
|
||||
</button>
|
||||
@@ -1946,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) => ({
|
||||
@@ -2672,6 +2732,30 @@ beforeEach(() => {
|
||||
vi.mocked(usePuzzleRuntimeProp).mockImplementation(async (runId) => ({
|
||||
run: buildMockPuzzleRun(`${runId}-profile`, '后端同步关卡'),
|
||||
}));
|
||||
vi.mocked(startLocalPuzzleRun).mockImplementation((item, levelId) => {
|
||||
const runId = `local-puzzle-run-${item.profileId}`;
|
||||
const firstLevel = item.levels?.[0] ?? null;
|
||||
return {
|
||||
...buildMockPuzzleRun(item.profileId, firstLevel?.levelName ?? item.levelName),
|
||||
runId,
|
||||
entryProfileId: item.profileId,
|
||||
currentLevel: {
|
||||
...buildMockPuzzleRun(item.profileId, firstLevel?.levelName ?? item.levelName)
|
||||
.currentLevel!,
|
||||
runId,
|
||||
levelId: levelId ?? firstLevel?.levelId ?? null,
|
||||
coverImageSrc: firstLevel?.coverImageSrc ?? item.coverImageSrc,
|
||||
uiBackgroundImageSrc:
|
||||
firstLevel?.uiBackgroundImageSrc ??
|
||||
(firstLevel?.uiBackgroundImageObjectKey
|
||||
? `/${firstLevel.uiBackgroundImageObjectKey.replace(/^\/+/u, '')}`
|
||||
: null),
|
||||
uiBackgroundImageObjectKey:
|
||||
firstLevel?.uiBackgroundImageObjectKey ?? null,
|
||||
backgroundMusic: firstLevel?.backgroundMusic ?? null,
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mocked(submitPuzzleLeaderboard).mockImplementation(
|
||||
async (runId, payload) => ({
|
||||
run: {
|
||||
@@ -2795,15 +2879,15 @@ 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/puzzle.webp');
|
||||
expect(
|
||||
screen.getByRole('tab', { name: '视觉小说' }).querySelector('img')?.src,
|
||||
).toContain('/creation-type-references/visual-novel.webp');
|
||||
expect(
|
||||
screen.getByRole('tab', { name: 'AIRP' }).querySelector('img')?.src,
|
||||
).toContain('/creation-type-references/airp.webp');
|
||||
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();
|
||||
@@ -2814,7 +2898,9 @@ test('create tab shows template tabs and embeds puzzle form by default', async (
|
||||
expect(screen.queryByPlaceholderText('问一问陶泥儿')).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /角色扮演/u })).toBeNull();
|
||||
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();
|
||||
@@ -3875,27 +3961,46 @@ test('puzzle draft generation auto starts trial and runtime back opens draft res
|
||||
await openCreateTemplateHub(user);
|
||||
await user.click(screen.getByRole('button', { name: '生成草稿' }));
|
||||
|
||||
expect(await screen.findByText('雨夜猫街')).toBeTruthy();
|
||||
expect(updatePuzzleWork).toHaveBeenCalledWith(
|
||||
'puzzle-profile-auto-1',
|
||||
expect.objectContaining({
|
||||
levelName: '雨夜猫街',
|
||||
coverImageSrc: '/puzzle/auto-candidate.png',
|
||||
levels: [
|
||||
expect.objectContaining({
|
||||
uiBackgroundImageSrc:
|
||||
'/generated-puzzle-assets/puzzle-session-auto-1/ui/background.png',
|
||||
backgroundMusic: expect.objectContaining({
|
||||
audioSrc:
|
||||
'/generated-puzzle-assets/puzzle-session-auto-1/audio/background.mp3',
|
||||
await waitFor(() => {
|
||||
expect(updatePuzzleWork).toHaveBeenCalledWith(
|
||||
'puzzle-profile-auto-1',
|
||||
expect.objectContaining({
|
||||
levelName: '雨夜猫街',
|
||||
coverImageSrc: '/puzzle/auto-candidate.png',
|
||||
levels: [
|
||||
expect.objectContaining({
|
||||
uiBackgroundImageSrc:
|
||||
'/generated-puzzle-assets/puzzle-session-auto-1/ui/background.png',
|
||||
backgroundMusic: expect.objectContaining({
|
||||
audioSrc:
|
||||
'/generated-puzzle-assets/puzzle-session-auto-1/audio/background.mp3',
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
],
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(startLocalPuzzleRun).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
const runtimeWork = vi.mocked(startLocalPuzzleRun).mock.calls[0]?.[0];
|
||||
expect(runtimeWork?.levels?.[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
uiBackgroundImageSrc:
|
||||
'/generated-puzzle-assets/puzzle-session-auto-1/ui/background.png',
|
||||
uiBackgroundImageObjectKey:
|
||||
'generated-puzzle-assets/puzzle-session-auto-1/ui/background.png',
|
||||
}),
|
||||
);
|
||||
const runtimeSnapshot = vi.mocked(startLocalPuzzleRun).mock.results[0]?.value;
|
||||
expect(runtimeSnapshot?.currentLevel?.uiBackgroundImageSrc).toBe(
|
||||
'/generated-puzzle-assets/puzzle-session-auto-1/ui/background.png',
|
||||
);
|
||||
expect(screen.queryByText('拼图结果页')).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '返回上一页' }));
|
||||
await user.click(
|
||||
await screen.findByRole('button', { name: '返回上一页' }),
|
||||
);
|
||||
|
||||
expect(await screen.findByText('拼图结果页')).toBeTruthy();
|
||||
expect(screen.getByDisplayValue('雨夜猫街')).toBeTruthy();
|
||||
@@ -4705,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 = {
|
||||
@@ -4950,6 +5079,80 @@ test('home recommendation Match3D runtime keeps image, music and UI assets witho
|
||||
);
|
||||
});
|
||||
|
||||
test('home recommendation Match3D runtime passes top-level UI background assets', async () => {
|
||||
const match3dCard: Match3DWorkSummary = {
|
||||
workId: 'match3d-work-card-top-level-ui',
|
||||
profileId: 'match3d-profile-card-top-level-ui',
|
||||
ownerUserId: 'user-2',
|
||||
sourceSessionId: 'match3d-session-card-top-level-ui',
|
||||
gameName: '果园抓大鹅',
|
||||
themeText: '果园',
|
||||
summary: '消除果园素材。',
|
||||
tags: ['果园', '抓大鹅'],
|
||||
coverImageSrc: null,
|
||||
referenceImageSrc: null,
|
||||
clearCount: 3,
|
||||
difficulty: 5,
|
||||
publicationStatus: 'published',
|
||||
playCount: 3,
|
||||
updatedAt: '2026-04-25T10:30:00.000Z',
|
||||
publishedAt: '2026-04-25T10:30:00.000Z',
|
||||
publishReady: true,
|
||||
backgroundImageObjectKey:
|
||||
'generated-match3d-assets/session/profile/background/background.png',
|
||||
generatedBackgroundAsset: {
|
||||
prompt: '果园竖屏纯背景',
|
||||
imageSrc: null,
|
||||
imageObjectKey:
|
||||
'generated-match3d-assets/session/profile/background/background.png',
|
||||
containerPrompt: '果园浅盘容器',
|
||||
containerImageSrc: null,
|
||||
containerImageObjectKey:
|
||||
'generated-match3d-assets/session/profile/ui-container/container.png',
|
||||
status: 'image_ready',
|
||||
error: null,
|
||||
},
|
||||
generatedItemAssets: [
|
||||
{
|
||||
itemId: 'match3d-item-1',
|
||||
itemName: '草莓',
|
||||
imageSrc:
|
||||
'/generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png',
|
||||
imageObjectKey:
|
||||
'generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png',
|
||||
modelSrc: null,
|
||||
modelObjectKey: null,
|
||||
modelFileName: null,
|
||||
taskUuid: null,
|
||||
subscriptionKey: null,
|
||||
status: 'image_ready',
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.mocked(listMatch3DGallery).mockResolvedValue({
|
||||
items: [match3dCard],
|
||||
});
|
||||
match3dServerRuntimeAdapterMock.startRun.mockResolvedValue({
|
||||
run: buildMockMatch3DRun(match3dCard.profileId),
|
||||
});
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId('match3d-runtime-top-level-background-count'),
|
||||
).toHaveProperty('textContent', '1');
|
||||
});
|
||||
expect(
|
||||
screen.getByTestId('match3d-runtime-top-level-container-ui-count'),
|
||||
).toHaveProperty('textContent', '1');
|
||||
expect(getMatch3DWorkDetail).not.toHaveBeenCalledWith(
|
||||
'match3d-profile-card-top-level-ui',
|
||||
);
|
||||
});
|
||||
|
||||
test('home recommendation Match3D runtime reloads detail when card only has UI assets', async () => {
|
||||
const match3dCard: Match3DWorkSummary = {
|
||||
workId: 'match3d-work-card-ui-only',
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
LogIn,
|
||||
MessageCircle,
|
||||
Pencil,
|
||||
Palette,
|
||||
Plus,
|
||||
Search,
|
||||
Settings,
|
||||
@@ -157,6 +158,7 @@ export interface RpgEntryHomeViewProps {
|
||||
onOpenCreateWorld: () => void;
|
||||
onOpenCreateTypePicker: () => void;
|
||||
onOpenGalleryDetail: (entry: PlatformPublicGalleryCard) => void;
|
||||
onOpenBabyLoveDrawing?: () => void;
|
||||
onOpenRecommendGalleryDetail?: (entry: PlatformPublicGalleryCard) => void;
|
||||
recommendRuntimeContent?: ReactNode;
|
||||
activeRecommendEntryKey?: string | null;
|
||||
@@ -267,6 +269,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;
|
||||
@@ -3367,6 +3374,7 @@ export function RpgEntryHomeView({
|
||||
onResumeSave,
|
||||
onOpenCreateTypePicker,
|
||||
onOpenGalleryDetail,
|
||||
onOpenBabyLoveDrawing,
|
||||
onOpenRecommendGalleryDetail,
|
||||
recommendRuntimeContent,
|
||||
activeRecommendEntryKey = null,
|
||||
@@ -4950,7 +4958,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);
|
||||
@@ -4966,6 +4974,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="暂时还没有可展示的作品。" />
|
||||
@@ -5082,7 +5108,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
|
||||
@@ -5093,6 +5119,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="暂时还没有可展示的作品。" />
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/
|
||||
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
import { BABY_OBJECT_MATCH_EDUTAINMENT_TAG } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
import type {
|
||||
Match3DGeneratedBackgroundAsset,
|
||||
Match3DGeneratedItemAsset,
|
||||
Match3DWorkSummary,
|
||||
} from '../../../packages/shared/src/contracts/match3dWorks';
|
||||
@@ -117,6 +118,7 @@ export type PlatformMatch3DGalleryCard = {
|
||||
backgroundPrompt?: string | null;
|
||||
backgroundImageSrc?: string | null;
|
||||
backgroundImageObjectKey?: string | null;
|
||||
generatedBackgroundAsset?: Match3DGeneratedBackgroundAsset | null;
|
||||
generatedItemAssets?: Match3DGeneratedItemAsset[];
|
||||
};
|
||||
|
||||
@@ -298,6 +300,7 @@ export function mapMatch3DWorkToPlatformGalleryCard(
|
||||
backgroundPrompt: work.backgroundPrompt ?? null,
|
||||
backgroundImageSrc: work.backgroundImageSrc ?? null,
|
||||
backgroundImageObjectKey: work.backgroundImageObjectKey ?? null,
|
||||
generatedBackgroundAsset: work.generatedBackgroundAsset ?? null,
|
||||
generatedItemAssets: work.generatedItemAssets ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user